diff --git a/thoughts-frontend/app/globals.css b/thoughts-frontend/app/globals.css index bc2d8d3..093e14c 100644 --- a/thoughts-frontend/app/globals.css +++ b/thoughts-frontend/app/globals.css @@ -48,10 +48,10 @@ /* Frutiger Aero Gradients */ --gradient-fa-blue: 135deg, hsl(217 91% 60%) 0%, hsl(200 90% 70%) 100%; --gradient-fa-green: 135deg, hsl(155 70% 55%) 0%, hsl(170 80% 65%) 100%; - --gradient-fa-card: 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%, - hsl(var(--card)) 100%; - --gradient-fa-gloss: 135deg, rgba(255, 255, 255, 0.2) 0%, - rgba(255, 255, 255, 0) 100%; + --gradient-fa-card: + 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%, hsl(var(--card)) 100%; + --gradient-fa-gloss: + 135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%; --shadow-fa-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06); --shadow-fa-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06); @@ -183,11 +183,6 @@ body { @apply bg-background text-foreground; - background-image: url("/background.avif"); - background-size: cover; - background-position: center; - background-attachment: fixed; - background-repeat: no-repeat; } .glossy-effect::before { @@ -312,3 +307,165 @@ z-index: 1; } } + +/* ── Frutiger Aero interaction keyframes ── */ +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-8px); + max-height: 0; + } + to { + opacity: 1; + transform: translateY(0); + max-height: 300px; + } +} + +@keyframes shake { + 0%, + 100% { + transform: translateX(0) rotate(0deg); + } + 15% { + transform: translateX(-4px) rotate(-1.5deg); + } + 30% { + transform: translateX(4px) rotate(1.5deg); + } + 45% { + transform: translateX(-3px) rotate(-1deg); + } + 60% { + transform: translateX(3px) rotate(1deg); + } + 75% { + transform: translateX(-1px) rotate(-0.5deg); + } +} + +@keyframes fadeOut { + from { + opacity: 1; + transform: scale(1) translateY(0); + } + to { + opacity: 0; + transform: scale(0.9) translateY(8px); + } +} + +@keyframes floatBob { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-6px); + } +} + +@keyframes shimmerAero { + 0% { + background-position: -400px 0; + } + 100% { + background-position: 400px 0; + } +} + +@layer components { + .animate-slide-down { + overflow: hidden; + animation: slideDown 0.22s ease-out forwards; + } + .animate-shake { + animation: shake 0.45s ease-out; + } + .animate-fade-out { + animation: fadeOut 0.3s ease-out forwards; + } + .animate-float-bob { + animation: floatBob 2.8s ease-in-out infinite; + } + + /* Aero-tinted shimmer for skeleton loaders */ + .shimmer-aero { + background: linear-gradient( + 90deg, + rgba(96, 165, 250, 0.12) 25%, + rgba(96, 165, 250, 0.3) 50%, + rgba(96, 165, 250, 0.12) 75% + ); + background-size: 800px 100%; + background-repeat: no-repeat; + animation: shimmerAero 1.5s infinite linear; + } + + /* Widget title icon badges */ + .widget-icon { + width: 22px; + height: 22px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + flex-shrink: 0; + } + .widget-icon-blue { + background: linear-gradient(135deg, #60a5fa, #2563eb); + box-shadow: + 0 2px 4px rgba(37, 99, 235, 0.3), + inset 0 1px 1px rgba(255, 255, 255, 0.3); + } + .widget-icon-green { + background: linear-gradient(135deg, #6ee7b7, #10b981); + box-shadow: + 0 2px 4px rgba(16, 185, 129, 0.3), + inset 0 1px 1px rgba(255, 255, 255, 0.3); + } + .widget-icon-purple { + background: linear-gradient(135deg, #c4b5fd, #7c3aed); + box-shadow: + 0 2px 4px rgba(124, 58, 237, 0.3), + inset 0 1px 1px rgba(255, 255, 255, 0.3); + } + + /* Landing page ambient orbs */ + .orb { + position: absolute; + border-radius: 50%; + filter: blur(40px); + opacity: 0.45; + pointer-events: none; + } + + /* Gradient avatar fallback */ + .avatar-gradient { + background: linear-gradient(135deg, #60a5fa, #34d399); + box-shadow: + 0 0 0 2px white, + 0 0 0 3.5px rgba(59, 130, 246, 0.45); + } +} + +/* Respect reduced motion */ +@media (prefers-reduced-motion: reduce) { + .animate-slide-down { + animation: none; + } + .animate-shake { + animation: none; + } + .animate-fade-out { + animation: none; + } + .animate-float-bob { + animation: none; + } + .shimmer-aero { + animation: none; + background: rgba(96, 165, 250, 0.18); + } +} diff --git a/thoughts-frontend/app/layout.tsx b/thoughts-frontend/app/layout.tsx index 4771eb7..d5ca379 100644 --- a/thoughts-frontend/app/layout.tsx +++ b/thoughts-frontend/app/layout.tsx @@ -4,6 +4,7 @@ import { AuthProvider } from "@/hooks/use-auth"; import { Toaster } from "@/components/ui/sonner"; import { Header } from "@/components/header"; import localFont from "next/font/local"; +import Image from "next/image"; import InstallPrompt from "@/components/install-prompt"; export const metadata: Metadata = { @@ -52,6 +53,14 @@ export default function RootLayout({ return ( +
{children}
diff --git a/thoughts-frontend/app/page.tsx b/thoughts-frontend/app/page.tsx index a4dc23a..ac4d34c 100644 --- a/thoughts-frontend/app/page.tsx +++ b/thoughts-frontend/app/page.tsx @@ -13,7 +13,11 @@ import { UsersCount } from "@/components/users-count"; import { PaginationNav } from "@/components/pagination-nav"; import { redirect } from "next/navigation"; import { Suspense } from "react"; -import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton"; +import { + ProfileSkeleton, + TagsSkeleton, + CountSkeleton, +} from "@/components/loading-skeleton"; export const metadata: Metadata = { title: "Home", @@ -86,9 +90,7 @@ async function FeedPage({
-
- {sidebar} -
+
{sidebar}
{thoughtThreads.map((thought) => ( @@ -99,7 +101,13 @@ async function FeedPage({ /> ))} {thoughtThreads.length === 0 && ( - + )}
@@ -121,28 +127,112 @@ async function FeedPage({ function LandingPage() { return ( - <> -
-
-

+ {/* Ambient orbs */} +
+
+
+ + {/* Hero card */} +
+ {/* Gloss sweep */} +
+ +

+ Welcome to Thoughts +

+

+ A federated social network for short-form thoughts. +
+ Connect with the Fediverse. +

+ +
+ + +
+ + {/* Fediverse badge */} +
+ - Welcome to Thoughts -

-

- Throwback to the golden age of microblogging. -

-
- - -
+ + Works with Mastodon, Pixelfed & more +
- + ); } diff --git a/thoughts-frontend/app/search/page.tsx b/thoughts-frontend/app/search/page.tsx index 69b99a6..3530c9b 100644 --- a/thoughts-frontend/app/search/page.tsx +++ b/thoughts-frontend/app/search/page.tsx @@ -68,7 +68,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { ) : ( - + ) ) : results ? ( @@ -91,7 +91,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { ) : ( - + )} diff --git a/thoughts-frontend/app/tags/[tagName]/page.tsx b/thoughts-frontend/app/tags/[tagName]/page.tsx index 953df10..d991cc8 100644 --- a/thoughts-frontend/app/tags/[tagName]/page.tsx +++ b/thoughts-frontend/app/tags/[tagName]/page.tsx @@ -67,7 +67,7 @@ export default async function TagPage({ params }: TagPageProps) { /> ))} {thoughtThreads.length === 0 && ( - + )} diff --git a/thoughts-frontend/app/users/[username]/page.tsx b/thoughts-frontend/app/users/[username]/page.tsx index 8e8c30c..b71f556 100644 --- a/thoughts-frontend/app/users/[username]/page.tsx +++ b/thoughts-frontend/app/users/[username]/page.tsx @@ -270,7 +270,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) { /> ))} {thoughtThreads.length === 0 && ( - + )} {isOwnProfile && ( diff --git a/thoughts-frontend/components/empty-state.tsx b/thoughts-frontend/components/empty-state.tsx index 5dcc01d..84ce31a 100644 --- a/thoughts-frontend/components/empty-state.tsx +++ b/thoughts-frontend/components/empty-state.tsx @@ -1,12 +1,39 @@ +import Link from "next/link"; + interface EmptyStateProps { - message: string - className?: string + emoji?: string; + title?: string; + message: string; + ctaLabel?: string; + ctaHref?: string; + className?: string; } -export function EmptyState({ message, className }: EmptyStateProps) { +export function EmptyState({ + emoji = "💭", + title, + message, + ctaLabel, + ctaHref, + className = "", +}: EmptyStateProps) { return ( -

- {message} -

- ) +
+ + {title && ( +

{title}

+ )} +

{message}

+ {ctaLabel && ctaHref && ( + + {ctaLabel} + + )} +
+ ); } diff --git a/thoughts-frontend/components/follow-button.tsx b/thoughts-frontend/components/follow-button.tsx index 494292c..dbbebe2 100644 --- a/thoughts-frontend/components/follow-button.tsx +++ b/thoughts-frontend/components/follow-button.tsx @@ -1,6 +1,6 @@ "use client" -import { useOptimistic } from "react" +import { useOptimistic, useRef } from "react" import { followUser, unfollowUser } from "@/app/actions/social" import { Button } from "@/components/ui/button" import { toast } from "sonner" @@ -11,31 +11,101 @@ interface FollowButtonProps { isInitiallyFollowing: boolean } +const BURST_COLORS = ["#2563eb", "#06b6d4", "#10b981", "#f59e0b", "#a855f7", "#ef4444"] + +function burstParticles(canvas: HTMLCanvasElement) { + const ctx = canvas.getContext("2d") + if (!ctx) return + + const cx = canvas.width / 2 + const cy = canvas.height / 2 + const particles = Array.from({ length: 14 }, (_, i) => { + const angle = (i / 14) * Math.PI * 2 + const speed = 2.5 + Math.random() * 2 + return { + x: cx, + y: cy, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + r: 3 + Math.random() * 3, + color: BURST_COLORS[i % BURST_COLORS.length], + life: 1, + } + }) + + let rafId: number + + function frame() { + if (!canvas.isConnected) { + cancelAnimationFrame(rafId) + return + } + ctx!.clearRect(0, 0, canvas.width, canvas.height) + let alive = false + for (const p of particles) { + p.x += p.vx + p.y += p.vy + p.vy += 0.08 + p.life -= 0.03 + if (p.life > 0) { + alive = true + ctx!.globalAlpha = p.life + ctx!.fillStyle = p.color + ctx!.beginPath() + ctx!.arc(p.x, p.y, p.r, 0, Math.PI * 2) + ctx!.fill() + } + } + ctx!.globalAlpha = 1 + if (alive) { + rafId = requestAnimationFrame(frame) + } + } + + rafId = requestAnimationFrame(frame) +} + export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) { const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing) + const canvasRef = useRef(null) async function handleClick() { const next = !optimisticFollowing setOptimisticFollowing(next) + + if (next && canvasRef.current) { + burstParticles(canvasRef.current) + } + try { await (next ? followUser(username) : unfollowUser(username)) } catch { - setOptimisticFollowing(!next) // revert + setOptimisticFollowing(!next) toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`) } } return ( - +
+ + +
) } diff --git a/thoughts-frontend/components/header.tsx b/thoughts-frontend/components/header.tsx index 020b080..99e5425 100644 --- a/thoughts-frontend/components/header.tsx +++ b/thoughts-frontend/components/header.tsx @@ -1,6 +1,7 @@ "use client"; import { useAuth } from "@/hooks/use-auth"; +import Image from "next/image"; import Link from "next/link"; import { Button } from "./ui/button"; import { UserNav } from "./user-nav"; @@ -10,25 +11,33 @@ export function Header() { const { token } = useAuth(); return ( -
+
-
- - - Thoughts - - - -
+ {/* Logo */} + + Thoughts + + Thoughts + + + + +
{token ? ( ) : ( <> - - diff --git a/thoughts-frontend/components/popular-tags.tsx b/thoughts-frontend/components/popular-tags.tsx index 0b5b3f1..07ec806 100644 --- a/thoughts-frontend/components/popular-tags.tsx +++ b/thoughts-frontend/components/popular-tags.tsx @@ -2,21 +2,21 @@ import Link from "next/link"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { getPopularTags } from "@/lib/api"; -import { Hash } from "lucide-react"; export async function PopularTags() { const tags = await getPopularTags().catch(() => []); if (tags.length === 0) { return ( - - - Popular Tags + + + + 🏷 + Popular Tags + - -

- No popular tags to display. -

+ +

No tags yet.

); @@ -24,24 +24,20 @@ export async function PopularTags() { return ( - - Popular Tags + + + 🏷 + Popular Tags + - {tags.map((tag) => ( + {tags.map((tag, i) => ( - - - {tag} + + {i < 2 ? "🔥 " : "#"}{tag} ))} - {tags.length === 0 && ( -

No popular tags yet.

- )}
); diff --git a/thoughts-frontend/components/thought-card.tsx b/thoughts-frontend/components/thought-card.tsx index cbfe1f4..aaad4b6 100644 --- a/thoughts-frontend/components/thought-card.tsx +++ b/thoughts-frontend/components/thought-card.tsx @@ -46,6 +46,18 @@ interface ThoughtCardProps { isReply?: boolean; } +function renderWithHashtags(content: string) { + return content.split(/(#\w+)/g).map((part, i) => + /^#\w+$/.test(part) ? ( + + {part} + + ) : ( + part + ) + ); +} + export function ThoughtCard({ thought, currentUser, @@ -54,6 +66,7 @@ export function ThoughtCard({ const { author } = thought; const [isAlertOpen, setIsAlertOpen] = useState(false); const [isReplyOpen, setIsReplyOpen] = useState(false); + const [deletingState, setDeletingState] = useState<"idle" | "shaking" | "fading">("idle"); const { token } = useAuth(); const timeAgo = formatDistanceToNow(new Date(thought.createdAt), { addSuffix: true, @@ -62,14 +75,18 @@ export function ThoughtCard({ const isAuthor = currentUser?.username === thought.author.username; const handleDelete = async () => { + setIsAlertOpen(false); + setDeletingState("shaking"); + await new Promise((r) => setTimeout(r, 450)); + setDeletingState("fading"); + await new Promise((r) => setTimeout(r, 300)); try { await deleteThought(thought.id); - toast.success("Thought deleted successfully."); + toast.success("Thought deleted."); } catch (error) { console.error("Failed to delete thought:", error); + setDeletingState("idle"); toast.error("Failed to delete thought."); - } finally { - setIsAlertOpen(false); } }; @@ -115,7 +132,13 @@ export function ThoughtCard({
)}
- + {thought.author.local ? (

- {thought.content} + {renderWithHashtags(thought.content)}

) : (
setIsReplyOpen(!isReplyOpen)} > @@ -194,7 +218,7 @@ export function ThoughtCard({ )} {isReplyOpen && ( -
+
setIsReplyOpen(false)} diff --git a/thoughts-frontend/components/top-friends.tsx b/thoughts-frontend/components/top-friends.tsx index 9d4f23a..1ce37a9 100644 --- a/thoughts-frontend/components/top-friends.tsx +++ b/thoughts-frontend/components/top-friends.tsx @@ -17,12 +17,13 @@ export async function TopFriends({ username }: TopFriendsProps) { return ( - - + + + 👥 Top Friends - + {friends.map((friend) => ( - - - {friend.displayName || friend.username} + +
+ + {friend.displayName || friend.username} + + + @{friend.username} + +
+ + following ))} diff --git a/thoughts-frontend/components/ui/badge.tsx b/thoughts-frontend/components/ui/badge.tsx index adf337a..3ea478e 100644 --- a/thoughts-frontend/components/ui/badge.tsx +++ b/thoughts-frontend/components/ui/badge.tsx @@ -12,10 +12,14 @@ const badgeVariants = cva( default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80 glossy-effect bottom text-shadow-sm", secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 glossy-effect bottom text-shadow-sm", // Use green for secondary + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 glossy-effect bottom text-shadow-sm", destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 glossy-effect bottom text-shadow-sm", outline: "text-foreground glossy-effect bottom text-shadow-sm", + branded: + "border border-primary/20 bg-primary/8 text-primary font-semibold hover:bg-primary/15 hover:scale-105 transition-transform cursor-pointer", + trending: + "border border-red-300/30 bg-gradient-to-r from-orange-500/10 to-red-500/8 text-red-600 font-semibold hover:from-orange-500/18 hover:to-red-500/14 hover:scale-105 transition-transform cursor-pointer", }, }, defaultVariants: { diff --git a/thoughts-frontend/components/ui/skeleton.tsx b/thoughts-frontend/components/ui/skeleton.tsx index 2ec2ca7..ec7bd63 100644 --- a/thoughts-frontend/components/ui/skeleton.tsx +++ b/thoughts-frontend/components/ui/skeleton.tsx @@ -1,13 +1,13 @@ -import { cn } from "@/lib/utils"; +import * as React from "react" +import { cn } from "@/lib/utils" function Skeleton({ className, ...props }: React.ComponentProps<"div">) { return (
- ); + ) } -export { Skeleton }; +export { Skeleton } diff --git a/thoughts-frontend/components/user-avatar.tsx b/thoughts-frontend/components/user-avatar.tsx index b0fdf1b..6811d50 100644 --- a/thoughts-frontend/components/user-avatar.tsx +++ b/thoughts-frontend/components/user-avatar.tsx @@ -1,6 +1,5 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; -import { User } from "lucide-react"; interface UserAvatarProps { src?: string | null; @@ -9,8 +8,10 @@ interface UserAvatarProps { } export function UserAvatar({ src, alt, className }: UserAvatarProps) { + const initial = alt?.trim()[0]?.toUpperCase() ?? "?"; + return ( - + {src && ( )} - - + + {initial} ); diff --git a/thoughts-frontend/components/users-count.tsx b/thoughts-frontend/components/users-count.tsx index fdb681d..0940153 100644 --- a/thoughts-frontend/components/users-count.tsx +++ b/thoughts-frontend/components/users-count.tsx @@ -1,68 +1,57 @@ -import { Link } from "lucide-react"; import { Card, CardHeader, CardTitle, CardContent, - CardDescription, } from "@/components/ui/card"; import { getAllUsersCount } from "@/lib/api"; export async function UsersCount() { const usersCount = await getAllUsersCount().catch(() => null); - if (usersCount === null) { - return ( - - - Users Count - - Total number of registered users on Thoughts. - - - -
- Could not load users count. -
-
-
- ); - } - - if (usersCount.count === 0) { - return ( - - - Users Count - - Total number of registered users on Thoughts. - - - -
- No registered users yet. Be the first to{" "} - - sign up - - ! -
-
-
- ); - } + const count = usersCount?.count ?? null; return ( - - Users Count - - Total number of registered users on Thoughts. - + + + + Community + - -
- {usersCount.count} registered users. -
+ + {count === null ? ( +

+ Could not load member count. +

+ ) : count === 0 ? ( +

+ Be the first to join! +

+ ) : ( +
+
+ {count} +
+
+ members +
+
+ )}
); diff --git a/thoughts-frontend/public/bg1.avif b/thoughts-frontend/public/bg1.avif new file mode 100644 index 0000000..0aefc04 Binary files /dev/null and b/thoughts-frontend/public/bg1.avif differ