feat: Frutiger Aero redesign — glass panels, Aero shimmer, interaction moments
Some checks failed
lint / lint (push) Failing after 5m7s
test / unit (push) Successful in 16m24s
test / integration (push) Failing after 18m14s

This commit is contained in:
2026-05-16 14:55:51 +02:00
parent 7ce2901c2a
commit 32bfb00970
19 changed files with 549 additions and 1512 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,147 +0,0 @@
# Thoughts Frontend — Frutiger Aero Redesign
**Date:** 2026-05-16
**Scope:** `thoughts-frontend` only
**Approach:** Component-by-component redesign (B) — apply existing FA utilities purposefully, add interaction moments
---
## Summary
The thoughts frontend already has the Frutiger Aero foundation (font, CSS utilities, background image, gradients) but almost no components use it. Cards are plain shadcn white. The header is text-only. The landing page is a single minimal card. This redesign applies the aesthetic throughout and adds a layer of subtle, unexpected delight moments modelled on the existing confetti+sound interaction.
**Vibe:** "Calm surface, surprise inside." Tasteful Frutiger Aero — not maximalist, not flat. One or two playful moments per interaction, short animations (150450ms), never in the way.
---
## Design Decisions
### 1. Header
**Current:** Plain text "Thoughts" + nav links + flat buttons.
**Proposed:**
- Full glass/blur treatment: `backdrop-filter: blur(16px)`, white highlight border, `box-shadow` with inset top highlight
- Logo: glossy rounded-square pill (border-radius: 50% 50% 50% 8px) with 💭 emoji, blue gradient fill, gloss sweep pseudo-element
- Logo text: "Thoughts" with white text-shadow
- Nav links: subtle opacity treatment, white text-shadow
- Search: pill-shaped with translucent background
- Login/Register: rounded pill buttons — Login is ghost-glass, Register is glossy blue gradient
### 2. Landing Page
**Current:** Single centered glass card, h1 "Welcome to Thoughts", two flat buttons.
**Proposed:**
- Full-screen Aero sky background visible (already provided by `background.avif` + body rule)
- Three ambient CSS orbs (blurred radial gradients, `position: absolute`, no images) float in the background
- Hero card: deeper glass (`rgba(255,255,255,0.28)` + `backdrop-filter: blur(20px)`), larger border-radius (20px), inset top border highlight, gloss sweep pseudo-element covers top 55%
- Heading: larger (text-5xl is fine), stronger text-shadow
- CTA buttons: glossy gradient pills — Login is blue, Register is green, both with top-half specular highlight pseudo-element
- Fediverse badge: small pill below buttons ("Works with Mastodon, Pixelfed & more" + green dot)
### 3. Thought Cards
**Current:** Standard shadcn `<Card>` — white, 8px radius, flat border, grey avatar circle, flat Reply ghost button.
**Proposed:**
- Body: `rgba(255,255,255,0.72)` + `backdrop-filter: blur(12px)`, 16px border-radius, white highlight border
- Gloss sweep: `::before` pseudo covering top 52%, `rgba(255,255,255,0.5) → transparent`
- Avatar: gradient fill (blue→teal) with initial letter fallback, `box-shadow: 0 0 0 2px white, 0 0 0 3.5px rgba(59,130,246,0.45)` glow ring
- Hover: `transform: translateY(-3px)` + deeper blue-tinted shadow — CSS transition, no JS
- Reply button: blue-tinted pill (`rgba(37,99,235,0.08)` bg, matching border) instead of flat ghost
- Hashtags in content: colored `text-primary` inline
- Reply inline form: slides in with `slideDown` keyframe animation (opacity + translateY + max-height), styled as translucent panel
### 4. Sidebar Widgets (PopularTags, TopFriends, UsersCount)
All three get the same glass panel treatment as cards.
**PopularTags:**
- Widget title gets a glossy gradient icon badge (blue, 22px rounded-square)
- Tag pills: `rgba(37,99,235,0.08)` bg, branded border, hover scales to 1.05
- Trending tags (top 2 by count): 🔥 prefix + red tint variant
**TopFriends:**
- Widget title: green gradient icon badge
- Avatars: gradient fill with initial letter + glow ring (same pattern as thought cards)
- Handle shown beneath display name (`@username` — local users only, no full fediverse instance suffix needed)
- "following" badge per entry: green-tinted pill
**UsersCount → Community widget:**
- Widget title: purple gradient icon badge, renamed "Community"
- Single glossy stat cell: user count with gradient number text (`background-clip: text`, blue→teal) + "members" label
- No new API data required — uses existing `getUsersCount` endpoint only
### 5. Interaction Moments
**Follow button:**
- On click: canvas-based particle burst (14 particles, random angles, brand colors)
- Button transitions: blue gradient → green gradient, label "+ Follow" → "✓ Following"
- Particles: 400ms lifetime, gravity applied
**Delete thought:**
- On confirm: card plays `shake` keyframe (450ms, ±4px horizontal + slight rotation)
- After shake: `fadeout` keyframe (scale 0.9 + translateY 8px + opacity 0), 300ms
- Card removed from DOM after animation
**Reply form:**
- `slideDown` animation: opacity 0→1, translateY -8px→0, max-height 0→120px, 220ms ease-out
- Form is styled as translucent panel matching card footer background
**Loading skeletons:**
- Shimmer gradient updated to use blue/teal palette: `rgba(96,165,250,0.15) → rgba(96,165,250,0.35) → rgba(96,165,250,0.15)`
- Avatar skeleton: circular, same shimmer
**Empty states:**
- Replace plain string with a structured component: floating emoji (CSS `float` keyframe, 2.8s loop), title, subtitle, optional CTA button
- Variants per context: 💭 feed empty, 🔍 search no results, 🏷 no tags, 👥 no users
- CTA is a glossy blue pill button where relevant (e.g. "Discover people ✨" on empty feed)
**Post thought (existing):**
- Keep confetti + sound as-is — it already nails the vibe
---
## Responsive Behaviour
- All glass effects degrade gracefully: `backdrop-filter` not supported → `background-opacity` fallback already handled by the rgba values
- Cards: full-width on mobile, no sidebar (existing layout already handles this with `hidden lg:block`)
- Header: search pill collapses on small screens (existing behaviour kept)
- Landing orbs are static (no animation) — no motion concern
- Empty state floating emoji: `animation: none` on `prefers-reduced-motion`
- Skeleton shimmer: `prefers-reduced-motion` → static grey instead of animated
- Card hover lift: `transition: none` on `prefers-reduced-motion`
---
## Files Affected
| File | Change |
|------|--------|
| `app/globals.css` | Add `slideDown`, `shake`, `fadeout`, `float`, `shimmer-aero` keyframes; add `.empty-state`, `.widget-icon`, `.orb` utility classes |
| `app/layout.tsx` | No change |
| `app/page.tsx` | LandingPage: add orbs, deeper glass card, fediverse badge, glossy buttons |
| `components/header.tsx` | Add logo bubble, glass treatment, pill buttons |
| `components/thought-card.tsx` | Glass body, gradient avatar, hover lift, pill reply button, hashtag color |
| `components/thought-form.tsx` | Slide-in animation wrapper when used as reply form |
| `components/thought-thread.tsx` | Pass through animation class |
| `components/popular-tags.tsx` | Glass widget, icon badge, branded tag pills, trending variant |
| `components/top-friends.tsx` | Glass widget, icon badge, gradient avatars, handle + following badge |
| `components/users-count.tsx` | Rename to Community widget, 2-col grid, gradient number text |
| `components/user-avatar.tsx` | Add gradient fallback + glow ring variant |
| `components/empty-state.tsx` | Full redesign: floating emoji, copy, optional CTA |
| `components/loading-skeleton.tsx` | Aero shimmer palette, respects prefers-reduced-motion |
| `components/follow-button.tsx` | Canvas particle burst on follow/unfollow |
| `components/ui/button.tsx` | Add `glossy` variant (pill shape, gradient, gloss pseudo) |
| `components/ui/card.tsx` | Add `glass` variant (frosted, gloss sweep) |
| `components/ui/badge.tsx` | Add `branded` and `trending` variants |
---
## Out of Scope
- Dark mode improvements (existing dark mode kept as-is, no new dark-mode-specific FA treatment)
- Shared component library / cross-project unification
- New features (likes, bookmarks, etc.)
- Portfolio or movies-diary projects

View File

@@ -48,10 +48,10 @@
/* Frutiger Aero Gradients */ /* Frutiger Aero Gradients */
--gradient-fa-blue: 135deg, hsl(217 91% 60%) 0%, hsl(200 90% 70%) 100%; --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-green: 135deg, hsl(155 70% 55%) 0%, hsl(170 80% 65%) 100%;
--gradient-fa-card: 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%, --gradient-fa-card:
hsl(var(--card)) 100%; 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%, hsl(var(--card)) 100%;
--gradient-fa-gloss: 135deg, rgba(255, 255, 255, 0.2) 0%, --gradient-fa-gloss:
rgba(255, 255, 255, 0) 100%; 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-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); --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 { body {
@apply bg-background text-foreground; @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 { .glossy-effect::before {
@@ -312,3 +307,165 @@
z-index: 1; 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);
}
}

View File

@@ -4,6 +4,7 @@ import { AuthProvider } from "@/hooks/use-auth";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { Header } from "@/components/header"; import { Header } from "@/components/header";
import localFont from "next/font/local"; import localFont from "next/font/local";
import Image from "next/image";
import InstallPrompt from "@/components/install-prompt"; import InstallPrompt from "@/components/install-prompt";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -52,6 +53,14 @@ export default function RootLayout({
return ( return (
<html lang="en"> <html lang="en">
<body className={`${frutiger.className} antialiased`}> <body className={`${frutiger.className} antialiased`}>
<Image
src="/bg1.avif"
alt=""
fill
priority
quality={85}
className="fixed inset-0 -z-10 object-cover object-center"
/>
<AuthProvider> <AuthProvider>
<Header /> <Header />
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>

View File

@@ -13,7 +13,11 @@ import { UsersCount } from "@/components/users-count";
import { PaginationNav } from "@/components/pagination-nav"; import { PaginationNav } from "@/components/pagination-nav";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Suspense } from "react"; import { Suspense } from "react";
import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton"; import {
ProfileSkeleton,
TagsSkeleton,
CountSkeleton,
} from "@/components/loading-skeleton";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Home", title: "Home",
@@ -86,9 +90,7 @@ async function FeedPage({
</header> </header>
<ThoughtForm /> <ThoughtForm />
<div className="block lg:hidden space-y-6"> <div className="block lg:hidden space-y-6">{sidebar}</div>
{sidebar}
</div>
<div className="space-y-6"> <div className="space-y-6">
{thoughtThreads.map((thought) => ( {thoughtThreads.map((thought) => (
@@ -99,7 +101,13 @@ async function FeedPage({
/> />
))} ))}
{thoughtThreads.length === 0 && ( {thoughtThreads.length === 0 && (
<EmptyState message="Your feed is empty. Follow some users to see their thoughts!" /> <EmptyState
emoji="💭"
title="Your feed is quiet"
message="Your feed is empty. Follow some users to see their thoughts!"
ctaLabel="Discover people ✨"
ctaHref="/users/all"
/>
)} )}
</div> </div>
<PaginationNav <PaginationNav
@@ -110,9 +118,7 @@ async function FeedPage({
</main> </main>
<aside className="hidden lg:block lg:col-span-1"> <aside className="hidden lg:block lg:col-span-1">
<div className="sticky top-20 space-y-6"> <div className="sticky top-20 space-y-6">{sidebar}</div>
{sidebar}
</div>
</aside> </aside>
</div> </div>
</div> </div>
@@ -121,28 +127,112 @@ async function FeedPage({
function LandingPage() { function LandingPage() {
return ( return (
<> <div className="font-sans min-h-screen flex items-center justify-center relative overflow-hidden">
<div className="font-sans min-h-screen text-gray-800 flex items-center justify-center"> {/* Ambient orbs */}
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center glass-effect glossy-effect bottom rounded-md shadow-fa-lg"> <div
className="orb"
style={{
width: 280,
height: 280,
background:
"radial-gradient(circle, #ffffff 0%, #87ceeb 60%, transparent 100%)",
top: "-80px",
left: "-60px",
}}
/>
<div
className="orb"
style={{
width: 220,
height: 220,
background:
"radial-gradient(circle, #b2f5ea 0%, #48bb78 60%, transparent 100%)",
bottom: "-40px",
right: "5%",
}}
/>
<div
className="orb"
style={{
width: 160,
height: 160,
background:
"radial-gradient(circle, #e0f2fe 0%, #38bdf8 60%, transparent 100%)",
top: "35%",
left: "65%",
}}
/>
{/* Hero card */}
<div
className="container mx-auto max-w-lg p-4 sm:p-6 text-center relative z-10"
style={{
background: "rgba(255,255,255,0.28)",
backdropFilter: "blur(20px)",
WebkitBackdropFilter: "blur(20px)",
border: "1px solid rgba(255,255,255,0.55)",
borderRadius: "20px",
boxShadow:
"0 8px 32px rgba(0,0,0,0.10), inset 0 1px 0 rgba(255,255,255,0.6)",
}}
>
{/* Gloss sweep */}
<div
aria-hidden
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "55%",
background:
"linear-gradient(180deg, rgba(255,255,255,0.38) 0%, transparent 100%)",
borderRadius: "20px 20px 0 0",
pointerEvents: "none",
}}
/>
<h1 <h1
className="text-5xl font-bold" className="text-5xl font-bold relative"
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }} style={{
textShadow:
"0 2px 4px rgba(255,255,255,0.6), 0 1px 2px rgba(0,0,0,0.1)",
}}
> >
Welcome to Thoughts Welcome to Thoughts
</h1> </h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-3 relative">
Throwback to the golden age of microblogging. A federated social network for short-form thoughts.
<br />
Connect with the Fediverse.
</p> </p>
<div className="mt-8 flex justify-center gap-4">
<Button asChild> <div className="mt-8 flex justify-center gap-4 relative">
<Button asChild className="px-7">
<Link href="/login">Login</Link> <Link href="/login">Login</Link>
</Button> </Button>
<Button variant="secondary" asChild> <Button asChild variant="secondary" className="px-7">
<Link href="/register">Register</Link> <Link href="/register">Register</Link>
</Button> </Button>
</div> </div>
{/* Fediverse badge */}
<div className="mt-5 relative flex justify-center">
<span
className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full text-xs text-muted-foreground"
style={{
background: "rgba(255,255,255,0.3)",
border: "1px solid rgba(255,255,255,0.5)",
}}
>
<span
className="w-2 h-2 rounded-full bg-emerald-400 inline-block"
style={{ boxShadow: "0 0 4px #34d399" }}
/>
Works with Mastodon, Pixelfed &amp; more
</span>
</div>
</div> </div>
</div> </div>
</>
); );
} }

View File

@@ -68,7 +68,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
<RemoteUserCard actor={remoteActor} /> <RemoteUserCard actor={remoteActor} />
</div> </div>
) : ( ) : (
<EmptyState message={`No user found at ${query}`} /> <EmptyState emoji="🔍" title="No results" message={`No user found at ${query}`} />
) )
) : results ? ( ) : results ? (
<Tabs defaultValue="thoughts" className="w-full"> <Tabs defaultValue="thoughts" className="w-full">
@@ -91,7 +91,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
) : ( ) : (
<EmptyState message="No results found or an error occurred." /> <EmptyState emoji="🔍" title="No results" message="No results found or an error occurred." />
)} )}
</main> </main>
</div> </div>

View File

@@ -67,7 +67,7 @@ export default async function TagPage({ params }: TagPageProps) {
/> />
))} ))}
{thoughtThreads.length === 0 && ( {thoughtThreads.length === 0 && (
<EmptyState message="No thoughts found for this tag." /> <EmptyState emoji="🏷" title="No thoughts here yet" message="No thoughts found for this tag." />
)} )}
</main> </main>
</div> </div>

View File

@@ -270,7 +270,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
/> />
))} ))}
{thoughtThreads.length === 0 && ( {thoughtThreads.length === 0 && (
<EmptyState message="This user hasn't posted any public thoughts yet." /> <EmptyState emoji="💭" title="Nothing here yet" message="This user hasn't posted any public thoughts yet." />
)} )}
</TabsContent> </TabsContent>
{isOwnProfile && ( {isOwnProfile && (

View File

@@ -1,12 +1,39 @@
import Link from "next/link";
interface EmptyStateProps { interface EmptyStateProps {
message: string emoji?: string;
className?: 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 ( return (
<p className={`text-center text-muted-foreground pt-8 ${className ?? ""}`}> <div className={`flex flex-col items-center text-center py-10 gap-2 ${className}`}>
{message} <span className="text-4xl animate-float-bob select-none" aria-hidden="true">
</p> {emoji}
) </span>
{title && (
<p className="font-bold text-base text-foreground text-shadow-sm">{title}</p>
)}
<p className="text-sm text-muted-foreground max-w-xs leading-relaxed">{message}</p>
{ctaLabel && ctaHref && (
<Link
href={ctaHref}
className="mt-2 inline-flex items-center gap-1.5 px-5 py-2 rounded-full text-sm font-bold text-white fa-gradient-blue shadow-fa-md glossy-effect relative overflow-hidden"
>
{ctaLabel}
</Link>
)}
</div>
);
} }

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useOptimistic } from "react" import { useOptimistic, useRef } from "react"
import { followUser, unfollowUser } from "@/app/actions/social" import { followUser, unfollowUser } from "@/app/actions/social"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { toast } from "sonner" import { toast } from "sonner"
@@ -11,24 +11,93 @@ interface FollowButtonProps {
isInitiallyFollowing: boolean 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) { export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing) const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
const canvasRef = useRef<HTMLCanvasElement>(null)
async function handleClick() { async function handleClick() {
const next = !optimisticFollowing const next = !optimisticFollowing
setOptimisticFollowing(next) setOptimisticFollowing(next)
if (next && canvasRef.current) {
burstParticles(canvasRef.current)
}
try { try {
await (next ? followUser(username) : unfollowUser(username)) await (next ? followUser(username) : unfollowUser(username))
} catch { } catch {
setOptimisticFollowing(!next) // revert setOptimisticFollowing(!next)
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`) toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
} }
} }
return ( return (
<div className="relative inline-block">
<canvas
ref={canvasRef}
width={160}
height={80}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
aria-hidden
/>
<Button <Button
onClick={handleClick} onClick={handleClick}
variant={optimisticFollowing ? "secondary" : "default"} variant={optimisticFollowing ? "secondary" : "default"}
className="relative rounded-full"
data-following={optimisticFollowing} data-following={optimisticFollowing}
> >
{optimisticFollowing ? ( {optimisticFollowing ? (
@@ -37,5 +106,6 @@ export function FollowButton({ username, isInitiallyFollowing }: FollowButtonPro
<><UserPlus className="mr-2 h-4 w-4" /> Follow</> <><UserPlus className="mr-2 h-4 w-4" /> Follow</>
)} )}
</Button> </Button>
</div>
) )
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { UserNav } from "./user-nav"; import { UserNav } from "./user-nav";
@@ -10,25 +11,33 @@ export function Header() {
const { token } = useAuth(); const { token } = useAuth();
return ( return (
<header className="sticky top-0 z-50 flex justify-center w-full border-b border-primary/20 bg-background/80 glass-effect glossy-effect bottom rounded-none"> <header className="sticky top-0 z-50 flex justify-center w-full border-b border-white/20 bg-background/80 glass-effect glossy-effect bottom rounded-none shadow-fa-md">
<div className="container flex h-14 items-center px-2"> <div className="container flex h-14 items-center px-2">
<div className="flex gap-2"> {/* Logo */}
<Link href="/" className="flex items-center gap-1"> <Link href="/" className="flex items-center gap-2 mr-4 shrink-0">
<span className="hidden font-bold text-primary sm:inline-block"> <Image
src="/icon.avif"
alt="Thoughts"
width={32}
height={32}
className="rounded-lg shadow-fa-sm"
/>
<span className="hidden sm:inline-block font-bold text-primary text-shadow-sm">
Thoughts Thoughts
</span> </span>
</Link> </Link>
<MainNav /> <MainNav />
</div>
<div className="flex flex-1 items-center justify-end space-x-2"> <div className="flex flex-1 items-center justify-end space-x-2">
{token ? ( {token ? (
<UserNav /> <UserNav />
) : ( ) : (
<> <>
<Button asChild size="sm"> <Button asChild size="sm" variant="outline" className="rounded-full">
<Link href="/login">Login</Link> <Link href="/login">Login</Link>
</Button> </Button>
<Button asChild size="sm"> <Button asChild size="sm" className="rounded-full">
<Link href="/register">Register</Link> <Link href="/register">Register</Link>
</Button> </Button>
</> </>

View File

@@ -2,21 +2,21 @@ import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { getPopularTags } from "@/lib/api"; import { getPopularTags } from "@/lib/api";
import { Hash } from "lucide-react";
export async function PopularTags() { export async function PopularTags() {
const tags = await getPopularTags().catch(() => []); const tags = await getPopularTags().catch(() => []);
if (tags.length === 0) { if (tags.length === 0) {
return ( return (
<Card> <Card className="p-4">
<CardHeader> <CardHeader className="p-0 pb-2">
<CardTitle>Popular Tags</CardTitle> <CardTitle className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-blue">🏷</span>
Popular Tags
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<p className="text-center text-muted-foreground"> <p className="text-center text-sm text-muted-foreground py-4">No tags yet.</p>
No popular tags to display.
</p>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -24,24 +24,20 @@ export async function PopularTags() {
return ( return (
<Card className="p-4"> <Card className="p-4">
<CardHeader className="p-0 pb-2"> <CardHeader className="p-0 pb-3">
<CardTitle className="text-lg">Popular Tags</CardTitle> <CardTitle className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-blue">🏷</span>
Popular Tags
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-wrap gap-2 p-0"> <CardContent className="flex flex-wrap gap-2 p-0">
{tags.map((tag) => ( {tags.map((tag, i) => (
<Link href={`/tags/${tag}`} key={tag}> <Link href={`/tags/${tag}`} key={tag}>
<Badge <Badge variant={i < 2 ? "trending" : "branded"}>
variant="secondary" {i < 2 ? "🔥 " : "#"}{tag}
className="hover:shadow-lg transition-shadow text-shadow-sm cursor-pointer"
>
<Hash className="mr-1 h-3 w-3" />
{tag}
</Badge> </Badge>
</Link> </Link>
))} ))}
{tags.length === 0 && (
<p className="text-sm text-muted-foreground">No popular tags yet.</p>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -46,6 +46,18 @@ interface ThoughtCardProps {
isReply?: boolean; isReply?: boolean;
} }
function renderWithHashtags(content: string) {
return content.split(/(#\w+)/g).map((part, i) =>
/^#\w+$/.test(part) ? (
<span key={i} className="text-primary font-medium">
{part}
</span>
) : (
part
)
);
}
export function ThoughtCard({ export function ThoughtCard({
thought, thought,
currentUser, currentUser,
@@ -54,6 +66,7 @@ export function ThoughtCard({
const { author } = thought; const { author } = thought;
const [isAlertOpen, setIsAlertOpen] = useState(false); const [isAlertOpen, setIsAlertOpen] = useState(false);
const [isReplyOpen, setIsReplyOpen] = useState(false); const [isReplyOpen, setIsReplyOpen] = useState(false);
const [deletingState, setDeletingState] = useState<"idle" | "shaking" | "fading">("idle");
const { token } = useAuth(); const { token } = useAuth();
const timeAgo = formatDistanceToNow(new Date(thought.createdAt), { const timeAgo = formatDistanceToNow(new Date(thought.createdAt), {
addSuffix: true, addSuffix: true,
@@ -62,14 +75,18 @@ export function ThoughtCard({
const isAuthor = currentUser?.username === thought.author.username; const isAuthor = currentUser?.username === thought.author.username;
const handleDelete = async () => { const handleDelete = async () => {
setIsAlertOpen(false);
setDeletingState("shaking");
await new Promise((r) => setTimeout(r, 450));
setDeletingState("fading");
await new Promise((r) => setTimeout(r, 300));
try { try {
await deleteThought(thought.id); await deleteThought(thought.id);
toast.success("Thought deleted successfully."); toast.success("Thought deleted.");
} catch (error) { } catch (error) {
console.error("Failed to delete thought:", error); console.error("Failed to delete thought:", error);
setDeletingState("idle");
toast.error("Failed to delete thought."); toast.error("Failed to delete thought.");
} finally {
setIsAlertOpen(false);
} }
}; };
@@ -115,7 +132,13 @@ export function ThoughtCard({
</div> </div>
)} )}
</div> </div>
<Card className="mt-2"> <Card
className={cn(
"mt-2 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg",
deletingState === "shaking" && "animate-shake",
deletingState === "fading" && "animate-fade-out pointer-events-none"
)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0"> <CardHeader className="flex flex-row items-center justify-between space-y-0">
<Link <Link
href={`/users/${author.username}`} href={`/users/${author.username}`}
@@ -166,7 +189,7 @@ export function ThoughtCard({
<CardContent> <CardContent>
{thought.author.local ? ( {thought.author.local ? (
<p className="whitespace-pre-wrap break-words text-shadow-sm"> <p className="whitespace-pre-wrap break-words text-shadow-sm">
{thought.content} {renderWithHashtags(thought.content)}
</p> </p>
) : ( ) : (
<div <div
@@ -185,6 +208,7 @@ export function ThoughtCard({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="rounded-full bg-primary/8 border border-primary/15 text-primary hover:bg-primary/15"
onClick={() => setIsReplyOpen(!isReplyOpen)} onClick={() => setIsReplyOpen(!isReplyOpen)}
> >
<MessageSquare className="mr-2 h-4 w-4" /> <MessageSquare className="mr-2 h-4 w-4" />
@@ -194,7 +218,7 @@ export function ThoughtCard({
)} )}
{isReplyOpen && ( {isReplyOpen && (
<div className="border-t m-4 rounded-2xl border-border/50 bg-secondary/20 "> <div className="animate-slide-down border-t m-4 rounded-2xl border-border/50 bg-secondary/20">
<ThoughtForm <ThoughtForm
replyToId={thought.id} replyToId={thought.id}
onSuccess={() => setIsReplyOpen(false)} onSuccess={() => setIsReplyOpen(false)}

View File

@@ -17,12 +17,13 @@ export async function TopFriends({ username }: TopFriendsProps) {
return ( return (
<Card id="top-friends" className="p-4"> <Card id="top-friends" className="p-4">
<CardHeader id="top-friends__header" className="p-0 pb-2"> <CardHeader id="top-friends__header" className="p-0 pb-3">
<CardTitle id="top-friends__title" className="text-lg text-shadow-md"> <CardTitle id="top-friends__title" className="text-lg flex items-center gap-2">
<span className="widget-icon widget-icon-green">👥</span>
Top Friends Top Friends
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent id="top-friends__content" className="p-0"> <CardContent id="top-friends__content" className="p-0 space-y-1">
{friends.map((friend) => ( {friends.map((friend) => (
<Link <Link
id={`top-friends__link-${friend.id}`} id={`top-friends__link-${friend.id}`}
@@ -30,13 +31,18 @@ export async function TopFriends({ username }: TopFriendsProps) {
key={friend.id} key={friend.id}
className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-lg hover:bg-accent/50 transition-colors" className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-lg hover:bg-accent/50 transition-colors"
> >
<UserAvatar src={friend.avatarUrl} alt={friend.username} /> <UserAvatar src={friend.avatarUrl} alt={friend.displayName || friend.username} />
<span <div className="flex flex-col min-w-0">
id={`top-friends__name-${friend.id}`} <span className="text-xs font-semibold truncate text-shadow-sm">
className="text-xs truncate w-full font-medium text-shadow-sm"
>
{friend.displayName || friend.username} {friend.displayName || friend.username}
</span> </span>
<span className="text-[10px] text-muted-foreground truncate">
@{friend.username}
</span>
</div>
<span className="ml-auto shrink-0 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-600">
following
</span>
</Link> </Link>
))} ))}
</CardContent> </CardContent>

View File

@@ -12,10 +12,14 @@ const badgeVariants = cva(
default: default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80 glossy-effect bottom text-shadow-sm", "border-transparent bg-primary text-primary-foreground hover:bg-primary/80 glossy-effect bottom text-shadow-sm",
secondary: 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: destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 glossy-effect bottom text-shadow-sm", "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", 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: { defaultVariants: {

View File

@@ -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">) { function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="skeleton" className={cn("rounded-md shimmer-aero", className)}
className={cn("bg-muted/50 animate-pulse rounded-md", className)}
{...props} {...props}
/> />
); )
} }
export { Skeleton }; export { Skeleton }

View File

@@ -1,6 +1,5 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { User } from "lucide-react";
interface UserAvatarProps { interface UserAvatarProps {
src?: string | null; src?: string | null;
@@ -9,8 +8,10 @@ interface UserAvatarProps {
} }
export function UserAvatar({ src, alt, className }: UserAvatarProps) { export function UserAvatar({ src, alt, className }: UserAvatarProps) {
const initial = alt?.trim()[0]?.toUpperCase() ?? "?";
return ( return (
<Avatar className={cn("border-2 border-primary/50 shadow-md", className)}> <Avatar className={cn("avatar-gradient", className)}>
{src && ( {src && (
<AvatarImage <AvatarImage
className="object-cover object-center" className="object-cover object-center"
@@ -18,8 +19,8 @@ export function UserAvatar({ src, alt, className }: UserAvatarProps) {
alt={alt ?? "User avatar"} alt={alt ?? "User avatar"}
/> />
)} )}
<AvatarFallback> <AvatarFallback className="avatar-gradient text-white font-bold text-sm">
<User className="h-5 w-5" /> {initial}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
); );

View File

@@ -1,68 +1,57 @@
import { Link } from "lucide-react";
import { import {
Card, Card,
CardHeader, CardHeader,
CardTitle, CardTitle,
CardContent, CardContent,
CardDescription,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { getAllUsersCount } from "@/lib/api"; import { getAllUsersCount } from "@/lib/api";
export async function UsersCount() { export async function UsersCount() {
const usersCount = await getAllUsersCount().catch(() => null); const usersCount = await getAllUsersCount().catch(() => null);
if (usersCount === null) { const count = usersCount?.count ?? null;
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
<CardDescription>
Total number of registered users on Thoughts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm text-center py-4">
Could not load users count.
</div>
</CardContent>
</Card>
);
}
if (usersCount.count === 0) {
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
<CardDescription>
Total number of registered users on Thoughts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-muted-foreground text-sm text-center py-4">
No registered users yet. Be the first to{" "}
<Link href="/signup" className="text-primary hover:underline">
sign up
</Link>
!
</div>
</CardContent>
</Card>
);
}
return ( return (
<Card className="p-4"> <Card className="p-4">
<CardHeader className="p-0 pb-2"> <CardHeader className="p-0 pb-3">
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle> <CardTitle className="text-lg flex items-center gap-2">
<CardDescription> <span className="widget-icon widget-icon-purple"></span>
Total number of registered users on Thoughts. Community
</CardDescription> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
<div className="text-muted-foreground text-sm text-center py-4"> {count === null ? (
{usersCount.count} registered users. <p className="text-sm text-muted-foreground text-center py-2">
Could not load member count.
</p>
) : count === 0 ? (
<p className="text-sm text-muted-foreground text-center py-2">
Be the first to join!
</p>
) : (
<div
className="rounded-xl p-3 text-center glossy-effect relative overflow-hidden"
style={{
background: "rgba(255,255,255,0.4)",
border: "1px solid rgba(255,255,255,0.6)",
}}
>
<div
className="text-3xl font-extrabold leading-none"
style={{
background: "linear-gradient(135deg, #2563eb, #06b6d4)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
{count}
</div> </div>
<div className="text-[10px] uppercase tracking-widest text-muted-foreground mt-1 font-semibold">
members
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB