1199 lines
32 KiB
Markdown
1199 lines
32 KiB
Markdown
# Thoughts Frontend — Frutiger Aero Redesign Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Apply the Frutiger Aero aesthetic throughout `thoughts-frontend` — glass panels, gloss sweeps, Aero shimmer, gradient avatars, and delightful interaction moments (particle bursts, shake+fade, slide-in forms).
|
||
|
||
**Architecture:** Component-by-component update. CSS keyframes and utility classes go in `globals.css` first (foundation). UI primitives (badge, skeleton) are updated next. Then page-level components (header, landing). Then feed components (cards, widgets). Then interaction moments (follow burst, delete animation). Every task ends with a build check and a commit.
|
||
|
||
**Tech Stack:** Next.js 15 (App Router), React 19, TypeScript, Tailwind CSS v4, shadcn/ui, Bun
|
||
|
||
**Build check command (run after every task):**
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-16-thoughts-frutiger-aero-redesign-design.md`
|
||
|
||
---
|
||
|
||
## Pre-flight: verify current build passes
|
||
|
||
- [ ] Run `cd thoughts-frontend && bun run build` — must be green before starting
|
||
|
||
---
|
||
|
||
## Task 1: CSS keyframes and utility classes
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/app/globals.css`
|
||
|
||
Add after the last `@layer components { }` block.
|
||
|
||
- [ ] **Step 1: Append keyframes and utilities to `globals.css`**
|
||
|
||
Add this block at the end of the file:
|
||
|
||
```css
|
||
/* ── Frutiger Aero interaction keyframes ── */
|
||
@keyframes slideDown {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(-8px);
|
||
max-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
max-height: 300px;
|
||
overflow: hidden;
|
||
}
|
||
}
|
||
|
||
@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 {
|
||
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.30) 50%,
|
||
rgba(96, 165, 250, 0.12) 75%
|
||
);
|
||
background-size: 800px 100%;
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
Expected: build succeeds with no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/app/globals.css
|
||
git commit -m "feat: add FA keyframes and utility classes to globals.css"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Badge `branded` and `trending` variants
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/ui/badge.tsx`
|
||
|
||
- [ ] **Step 1: Add variants to `badgeVariants` in `badge.tsx`**
|
||
|
||
Replace the `variants` object inside `cva(...)`:
|
||
|
||
```tsx
|
||
variants: {
|
||
variant: {
|
||
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",
|
||
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",
|
||
},
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
Expected: build succeeds.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/ui/badge.tsx
|
||
git commit -m "feat: add branded and trending badge variants"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Skeleton Aero shimmer
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/ui/skeleton.tsx`
|
||
|
||
- [ ] **Step 1: Read current `skeleton.tsx`**
|
||
|
||
```bash
|
||
cat thoughts-frontend/components/ui/skeleton.tsx
|
||
```
|
||
|
||
- [ ] **Step 2: Replace the file with Aero shimmer version**
|
||
|
||
```tsx
|
||
import * as React from "react"
|
||
import { cn } from "@/lib/utils"
|
||
|
||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||
return (
|
||
<div
|
||
className={cn("rounded-md shimmer-aero", className)}
|
||
{...props}
|
||
/>
|
||
)
|
||
}
|
||
|
||
export { Skeleton }
|
||
```
|
||
|
||
- [ ] **Step 3: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/ui/skeleton.tsx
|
||
git commit -m "feat: apply Aero shimmer to skeleton loader"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: UserAvatar — gradient fallback + glow ring
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/user-avatar.tsx`
|
||
|
||
Current: fallback is a generic `<User>` icon, border is `border-primary/50`.
|
||
|
||
- [ ] **Step 1: Update `user-avatar.tsx`**
|
||
|
||
```tsx
|
||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
interface UserAvatarProps {
|
||
src?: string | null;
|
||
alt?: string | null;
|
||
className?: string;
|
||
}
|
||
|
||
export function UserAvatar({ src, alt, className }: UserAvatarProps) {
|
||
const initial = alt?.trim()[0]?.toUpperCase() ?? "?";
|
||
|
||
return (
|
||
<Avatar className={cn("avatar-gradient", className)}>
|
||
{src && (
|
||
<AvatarImage
|
||
className="object-cover object-center"
|
||
src={src}
|
||
alt={alt ?? "User avatar"}
|
||
/>
|
||
)}
|
||
<AvatarFallback className="avatar-gradient text-white font-bold text-sm">
|
||
{initial}
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/user-avatar.tsx
|
||
git commit -m "feat: gradient avatar fallback with initials and glow ring"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: EmptyState redesign
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/empty-state.tsx`
|
||
|
||
Current: renders a single `<p>` with the message string.
|
||
|
||
- [ ] **Step 1: Rewrite `empty-state.tsx`**
|
||
|
||
```tsx
|
||
import Link from "next/link";
|
||
|
||
interface EmptyStateProps {
|
||
emoji?: string;
|
||
title?: string;
|
||
message: string;
|
||
ctaLabel?: string;
|
||
ctaHref?: string;
|
||
className?: string;
|
||
}
|
||
|
||
export function EmptyState({
|
||
emoji = "💭",
|
||
title,
|
||
message,
|
||
ctaLabel,
|
||
ctaHref,
|
||
className = "",
|
||
}: EmptyStateProps) {
|
||
return (
|
||
<div className={`flex flex-col items-center text-center py-10 gap-2 ${className}`}>
|
||
<span className="text-4xl animate-float-bob select-none" role="img" aria-hidden>
|
||
{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>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update all call sites to pass the new props**
|
||
|
||
Search for existing usages:
|
||
```bash
|
||
grep -rn "EmptyState" thoughts-frontend/app --include="*.tsx"
|
||
```
|
||
|
||
For each usage, add an `emoji` and `title` appropriate to the context. For example in `app/page.tsx`:
|
||
|
||
```tsx
|
||
<EmptyState
|
||
emoji="💭"
|
||
title="Your feed is quiet"
|
||
message="Follow some people to fill your feed with thoughts."
|
||
ctaLabel="Discover people ✨"
|
||
ctaHref="/users/all"
|
||
/>
|
||
```
|
||
|
||
For search (`app/search/page.tsx`) — check the file and use `emoji="🔍" title="No results"`.
|
||
|
||
For tags — use `emoji="🏷" title="No thoughts here yet"`.
|
||
|
||
- [ ] **Step 3: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
Expected: no TypeScript errors — `message` is still required, other props are optional.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/empty-state.tsx thoughts-frontend/app
|
||
git commit -m "feat: redesign EmptyState with floating emoji and optional CTA"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Header — logo bubble + pill buttons
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/header.tsx`
|
||
|
||
Current: plain text "Thoughts", flat Login/Register buttons.
|
||
|
||
- [ ] **Step 1: Rewrite `header.tsx`**
|
||
|
||
```tsx
|
||
"use client";
|
||
|
||
import { useAuth } from "@/hooks/use-auth";
|
||
import Link from "next/link";
|
||
import { Button } from "./ui/button";
|
||
import { UserNav } from "./user-nav";
|
||
import { MainNav } from "./main-nav";
|
||
|
||
export function Header() {
|
||
const { token } = useAuth();
|
||
|
||
return (
|
||
<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">
|
||
{/* Logo */}
|
||
<Link href="/" className="flex items-center gap-2 mr-4 shrink-0">
|
||
<div
|
||
className="w-8 h-8 flex items-center justify-center fa-gradient-blue shadow-fa-sm glossy-effect relative overflow-hidden"
|
||
style={{ borderRadius: "50% 50% 50% 10px" }}
|
||
>
|
||
<span className="text-base relative z-10 select-none">💭</span>
|
||
</div>
|
||
<span className="hidden sm:inline-block font-bold text-primary text-shadow-sm">
|
||
Thoughts
|
||
</span>
|
||
</Link>
|
||
|
||
<MainNav />
|
||
|
||
<div className="flex flex-1 items-center justify-end space-x-2">
|
||
{token ? (
|
||
<UserNav />
|
||
) : (
|
||
<>
|
||
<Button asChild size="sm" variant="outline" className="rounded-full">
|
||
<Link href="/login">Login</Link>
|
||
</Button>
|
||
<Button asChild size="sm" className="rounded-full">
|
||
<Link href="/register">Register</Link>
|
||
</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/header.tsx
|
||
git commit -m "feat: add logo bubble and pill buttons to header"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Landing page — orbs, deeper glass, fediverse badge
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/app/page.tsx` (the `LandingPage` function only)
|
||
|
||
- [ ] **Step 1: Replace the `LandingPage` function**
|
||
|
||
Find and replace the entire `LandingPage` function (lines 122–148):
|
||
|
||
```tsx
|
||
function LandingPage() {
|
||
return (
|
||
<div className="font-sans min-h-screen flex items-center justify-center relative overflow-hidden">
|
||
{/* Ambient orbs */}
|
||
<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
|
||
className="text-5xl font-bold relative"
|
||
style={{ textShadow: "0 2px 4px rgba(255,255,255,0.6), 0 1px 2px rgba(0,0,0,0.1)" }}
|
||
>
|
||
Welcome to Thoughts ✨
|
||
</h1>
|
||
<p className="text-muted-foreground mt-3 relative">
|
||
A federated social network for short-form thoughts.<br />
|
||
Connect with the Fediverse.
|
||
</p>
|
||
|
||
<div className="mt-8 flex justify-center gap-4 relative">
|
||
<Button asChild className="rounded-full px-7">
|
||
<Link href="/login">Login</Link>
|
||
</Button>
|
||
<Button asChild variant="secondary" className="rounded-full px-7">
|
||
<Link href="/register">Register</Link>
|
||
</Button>
|
||
</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 & more
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/app/page.tsx
|
||
git commit -m "feat: redesign landing page with ambient orbs and fediverse badge"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Thought card — hover lift, reply button pill, hashtag coloring
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/thought-card.tsx`
|
||
|
||
- [ ] **Step 1: Add `renderWithHashtags` helper before the component**
|
||
|
||
Add this function just above the `ThoughtCard` function declaration:
|
||
|
||
```tsx
|
||
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
|
||
)
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add hover lift to the `<Card>` element**
|
||
|
||
Find the `<Card className="mt-2">` line and change it to:
|
||
|
||
```tsx
|
||
<Card className="mt-2 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg">
|
||
```
|
||
|
||
- [ ] **Step 3: Replace the Reply `<Button>` with a pill variant**
|
||
|
||
Find the CardFooter Button:
|
||
```tsx
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => setIsReplyOpen(!isReplyOpen)}
|
||
>
|
||
```
|
||
|
||
Replace with:
|
||
```tsx
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="rounded-full bg-primary/8 border border-primary/15 text-primary hover:bg-primary/15"
|
||
onClick={() => setIsReplyOpen(!isReplyOpen)}
|
||
>
|
||
```
|
||
|
||
- [ ] **Step 4: Apply hashtag coloring to local thought content**
|
||
|
||
Find the local thought content `<p>`:
|
||
```tsx
|
||
{thought.author.local ? (
|
||
<p className="whitespace-pre-wrap break-words text-shadow-sm">
|
||
{thought.content}
|
||
</p>
|
||
```
|
||
|
||
Replace with:
|
||
```tsx
|
||
{thought.author.local ? (
|
||
<p className="whitespace-pre-wrap break-words text-shadow-sm">
|
||
{renderWithHashtags(thought.content)}
|
||
</p>
|
||
```
|
||
|
||
- [ ] **Step 5: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/thought-card.tsx
|
||
git commit -m "feat: card hover lift, pill reply button, hashtag coloring"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Thought card — reply form slide-in animation
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/thought-card.tsx`
|
||
|
||
- [ ] **Step 1: Wrap the reply form with the slide-in animation div**
|
||
|
||
Find the `{isReplyOpen && (` block:
|
||
```tsx
|
||
{isReplyOpen && (
|
||
<div className="border-t m-4 rounded-2xl border-border/50 bg-secondary/20 ">
|
||
<ThoughtForm
|
||
replyToId={thought.id}
|
||
onSuccess={() => setIsReplyOpen(false)}
|
||
/>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
Replace with:
|
||
```tsx
|
||
{isReplyOpen && (
|
||
<div className="animate-slide-down border-t m-4 rounded-2xl border-border/50 bg-secondary/20">
|
||
<ThoughtForm
|
||
replyToId={thought.id}
|
||
onSuccess={() => setIsReplyOpen(false)}
|
||
/>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 2: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/thought-card.tsx
|
||
git commit -m "feat: reply form slide-in animation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Thought card — delete shake and fade animation
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/thought-card.tsx`
|
||
|
||
- [ ] **Step 1: Add `deletingState` to component state**
|
||
|
||
Find the existing state declarations near the top of `ThoughtCard`:
|
||
```tsx
|
||
const [isAlertOpen, setIsAlertOpen] = useState(false);
|
||
const [isReplyOpen, setIsReplyOpen] = useState(false);
|
||
```
|
||
|
||
Add after them:
|
||
```tsx
|
||
const [deletingState, setDeletingState] = useState<"idle" | "shaking" | "fading">("idle");
|
||
```
|
||
|
||
- [ ] **Step 2: Rewrite `handleDelete` to animate before deleting**
|
||
|
||
Find the existing `handleDelete`:
|
||
```tsx
|
||
const handleDelete = async () => {
|
||
try {
|
||
await deleteThought(thought.id);
|
||
toast.success("Thought deleted successfully.");
|
||
} catch (error) {
|
||
console.error("Failed to delete thought:", error);
|
||
toast.error("Failed to delete thought.");
|
||
} finally {
|
||
setIsAlertOpen(false);
|
||
}
|
||
};
|
||
```
|
||
|
||
Replace with:
|
||
```tsx
|
||
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.");
|
||
} catch (error) {
|
||
console.error("Failed to delete thought:", error);
|
||
setDeletingState("idle");
|
||
toast.error("Failed to delete thought.");
|
||
}
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 3: Apply animation classes to the Card**
|
||
|
||
Find the Card element (already modified in Task 8):
|
||
```tsx
|
||
<Card className="mt-2 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg">
|
||
```
|
||
|
||
Replace with:
|
||
```tsx
|
||
<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"
|
||
)}
|
||
>
|
||
```
|
||
|
||
- [ ] **Step 4: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/thought-card.tsx
|
||
git commit -m "feat: delete thought shake and fade animation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Popular Tags widget — icon badge + branded/trending pills
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/popular-tags.tsx`
|
||
|
||
- [ ] **Step 1: Rewrite `popular-tags.tsx`**
|
||
|
||
```tsx
|
||
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";
|
||
|
||
export async function PopularTags() {
|
||
const tags = await getPopularTags().catch(() => []);
|
||
|
||
if (tags.length === 0) {
|
||
return (
|
||
<Card className="p-4">
|
||
<CardHeader className="p-0 pb-2">
|
||
<CardTitle className="text-lg flex items-center gap-2">
|
||
<span className="widget-icon widget-icon-blue">🏷</span>
|
||
Popular Tags
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<p className="text-center text-sm text-muted-foreground py-4">No tags yet.</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card className="p-4">
|
||
<CardHeader className="p-0 pb-3">
|
||
<CardTitle className="text-lg flex items-center gap-2">
|
||
<span className="widget-icon widget-icon-blue">🏷</span>
|
||
Popular Tags
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-wrap gap-2 p-0">
|
||
{tags.map((tag, i) => (
|
||
<Link href={`/tags/${tag}`} key={tag}>
|
||
<Badge variant={i < 2 ? "trending" : "branded"}>
|
||
{i < 2 ? "🔥 " : "#"}{tag}
|
||
</Badge>
|
||
</Link>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/popular-tags.tsx
|
||
git commit -m "feat: Popular Tags widget with icon badge and branded/trending pills"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Top Friends widget — icon badge, gradient avatars, handle
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/top-friends.tsx`
|
||
|
||
- [ ] **Step 1: Rewrite `top-friends.tsx`**
|
||
|
||
```tsx
|
||
import Link from "next/link";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { UserAvatar } from "./user-avatar";
|
||
import { getTopFriends } from "@/lib/api";
|
||
import { cookies } from "next/headers";
|
||
|
||
interface TopFriendsProps {
|
||
username: string;
|
||
}
|
||
|
||
export async function TopFriends({ username }: TopFriendsProps) {
|
||
const token = (await cookies()).get("auth_token")?.value ?? null;
|
||
const data = await getTopFriends(username, token).catch(() => ({ topFriends: [] }));
|
||
const friends = data.topFriends;
|
||
|
||
if (friends.length === 0) return null;
|
||
|
||
return (
|
||
<Card id="top-friends" className="p-4">
|
||
<CardHeader id="top-friends__header" className="p-0 pb-3">
|
||
<CardTitle id="top-friends__title" className="text-lg flex items-center gap-2">
|
||
<span className="widget-icon widget-icon-green">👥</span>
|
||
Top Friends
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent id="top-friends__content" className="p-0 space-y-1">
|
||
{friends.map((friend) => (
|
||
<Link
|
||
id={`top-friends__link-${friend.id}`}
|
||
href={`/users/${friend.username}`}
|
||
key={friend.id}
|
||
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.displayName || friend.username} />
|
||
<div className="flex flex-col min-w-0">
|
||
<span className="text-xs font-semibold truncate text-shadow-sm">
|
||
{friend.displayName || friend.username}
|
||
</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>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/top-friends.tsx
|
||
git commit -m "feat: Top Friends widget with icon badge and gradient avatars"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Community widget (UsersCount)
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/users-count.tsx`
|
||
|
||
Note: current file has a bug — `import { Link } from "lucide-react"` should be `import Link from "next/link"`. Fix this too.
|
||
|
||
- [ ] **Step 1: Rewrite `users-count.tsx`**
|
||
|
||
```tsx
|
||
import {
|
||
Card,
|
||
CardHeader,
|
||
CardTitle,
|
||
CardContent,
|
||
} from "@/components/ui/card";
|
||
import { getAllUsersCount } from "@/lib/api";
|
||
|
||
export async function UsersCount() {
|
||
const usersCount = await getAllUsersCount().catch(() => null);
|
||
|
||
const count = usersCount?.count ?? null;
|
||
|
||
return (
|
||
<Card className="p-4">
|
||
<CardHeader className="p-0 pb-3">
|
||
<CardTitle className="text-lg flex items-center gap-2">
|
||
<span className="widget-icon widget-icon-purple">✨</span>
|
||
Community
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
{count === null ? (
|
||
<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 className="text-[10px] uppercase tracking-widest text-muted-foreground mt-1 font-semibold">
|
||
members
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/users-count.tsx
|
||
git commit -m "feat: Community widget with gradient stat cell, fix Link import bug"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Follow button — canvas particle burst
|
||
|
||
**Files:**
|
||
- Modify: `thoughts-frontend/components/follow-button.tsx`
|
||
|
||
- [ ] **Step 1: Rewrite `follow-button.tsx`**
|
||
|
||
```tsx
|
||
"use client"
|
||
|
||
import { useOptimistic, useRef } from "react"
|
||
import { followUser, unfollowUser } from "@/app/actions/social"
|
||
import { Button } from "@/components/ui/button"
|
||
import { toast } from "sonner"
|
||
import { UserPlus, UserMinus } from "lucide-react"
|
||
|
||
interface FollowButtonProps {
|
||
username: string
|
||
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,
|
||
}
|
||
})
|
||
|
||
function frame() {
|
||
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) requestAnimationFrame(frame)
|
||
}
|
||
|
||
requestAnimationFrame(frame)
|
||
}
|
||
|
||
export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
|
||
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
|
||
const canvasRef = useRef<HTMLCanvasElement>(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)
|
||
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
|
||
}
|
||
}
|
||
|
||
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
|
||
onClick={handleClick}
|
||
variant={optimisticFollowing ? "secondary" : "default"}
|
||
className="relative rounded-full"
|
||
data-following={optimisticFollowing}
|
||
>
|
||
{optimisticFollowing ? (
|
||
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
|
||
) : (
|
||
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Build check**
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run build
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add thoughts-frontend/components/follow-button.tsx
|
||
git commit -m "feat: canvas particle burst on follow"
|
||
```
|
||
|
||
---
|
||
|
||
## Final: visual verification checklist
|
||
|
||
Start the dev server and walk through each surface:
|
||
|
||
```bash
|
||
cd thoughts-frontend && bun run dev
|
||
```
|
||
|
||
- [ ] **Landing page** — background image visible through orbs, hero card is glassy, buttons are pill-shaped, fediverse badge shows
|
||
- [ ] **Header** — 💭 logo bubble, glass blur on scroll, pill Login/Register
|
||
- [ ] **Feed** — cards have glass treatment, hover lifts card, hashtags are blue
|
||
- [ ] **Reply** — clicking Reply slides form in smoothly, clicking Cancel hides it
|
||
- [ ] **Delete** — confirm in alert dialog → card shakes → card fades → gone
|
||
- [ ] **Follow button** — particles burst on follow, button turns green
|
||
- [ ] **Popular Tags** — 🔥 on top 2 tags, branded blue pills for rest, icon badge
|
||
- [ ] **Top Friends** — gradient avatars, username handle, following badge
|
||
- [ ] **Community widget** — gradient number text, purple icon badge
|
||
- [ ] **Loading skeletons** — blue/teal shimmer instead of grey
|
||
- [ ] **Empty states** — floating emoji, friendly copy, glossy CTA where applicable
|
||
- [ ] **Mobile** — at 390px width: no sidebar visible, cards full-width, header collapses correctly
|