diff --git a/docs/superpowers/plans/2026-05-16-frutiger-aero-redesign.md b/docs/superpowers/plans/2026-05-16-frutiger-aero-redesign.md deleted file mode 100644 index cb56b36..0000000 --- a/docs/superpowers/plans/2026-05-16-frutiger-aero-redesign.md +++ /dev/null @@ -1,1198 +0,0 @@ -# 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 ( -
- ) -} - -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 `` 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 ( - - {src && ( - - )} - - {initial} - - - ); -} -``` - -- [ ] **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 `

` 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 ( -

- - {emoji} - - {title && ( -

{title}

- )} -

{message}

- {ctaLabel && ctaHref && ( - - {ctaLabel} - - )} -
- ); -} -``` - -- [ ] **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 - -``` - -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 ( -
-
- {/* Logo */} - -
- 💭 -
- - Thoughts - - - - - -
- {token ? ( - - ) : ( - <> - - - - )} -
-
-
- ); -} -``` - -- [ ] **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 ( -
- {/* Ambient orbs */} -
-
-
- - {/* Hero card */} -
- {/* Gloss sweep */} -
- -

- Welcome to Thoughts ✨ -

-

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

- -
- - -
- - {/* Fediverse badge */} -
- - - Works with Mastodon, Pixelfed & more - -
-
-
- ); -} -``` - -- [ ] **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) ? ( - - {part} - - ) : ( - part - ) - ); -} -``` - -- [ ] **Step 2: Add hover lift to the `` element** - -Find the `` line and change it to: - -```tsx - -``` - -- [ ] **Step 3: Replace the Reply ` -
- ) -} -``` - -- [ ] **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 diff --git a/docs/superpowers/specs/2026-05-16-thoughts-frutiger-aero-redesign-design.md b/docs/superpowers/specs/2026-05-16-thoughts-frutiger-aero-redesign-design.md deleted file mode 100644 index 245338c..0000000 --- a/docs/superpowers/specs/2026-05-16-thoughts-frutiger-aero-redesign-design.md +++ /dev/null @@ -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 (150–450ms), 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 `` — 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 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