feat: Frutiger Aero redesign — glass panels, Aero shimmer, interaction moments

This commit is contained in:
2026-05-16 14:55:51 +02:00
parent a0aa3f381e
commit b02f3c73e3
17 changed files with 549 additions and 167 deletions

View File

@@ -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 (
<p className={`text-center text-muted-foreground pt-8 ${className ?? ""}`}>
{message}
</p>
)
<div className={`flex flex-col items-center text-center py-10 gap-2 ${className}`}>
<span className="text-4xl animate-float-bob select-none" aria-hidden="true">
{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"
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<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) // revert
setOptimisticFollowing(!next)
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
}
}
return (
<Button
onClick={handleClick}
variant={optimisticFollowing ? "secondary" : "default"}
data-following={optimisticFollowing}
>
{optimisticFollowing ? (
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
) : (
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
)}
</Button>
<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>
)
}

View File

@@ -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 (
<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="flex gap-2">
<Link href="/" className="flex items-center gap-1">
<span className="hidden font-bold text-primary sm:inline-block">
Thoughts
</span>
</Link>
<MainNav />
</div>
{/* Logo */}
<Link href="/" className="flex items-center gap-2 mr-4 shrink-0">
<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
</span>
</Link>
<MainNav />
<div className="flex flex-1 items-center justify-end space-x-2">
{token ? (
<UserNav />
) : (
<>
<Button asChild size="sm">
<Button asChild size="sm" variant="outline" className="rounded-full">
<Link href="/login">Login</Link>
</Button>
<Button asChild size="sm">
<Button asChild size="sm" className="rounded-full">
<Link href="/register">Register</Link>
</Button>
</>

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Popular Tags</CardTitle>
<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>
<p className="text-center text-muted-foreground">
No popular tags to display.
</p>
<CardContent className="p-0">
<p className="text-center text-sm text-muted-foreground py-4">No tags yet.</p>
</CardContent>
</Card>
);
@@ -24,24 +24,20 @@ export async function PopularTags() {
return (
<Card className="p-4">
<CardHeader className="p-0 pb-2">
<CardTitle className="text-lg">Popular Tags</CardTitle>
<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) => (
{tags.map((tag, i) => (
<Link href={`/tags/${tag}`} key={tag}>
<Badge
variant="secondary"
className="hover:shadow-lg transition-shadow text-shadow-sm cursor-pointer"
>
<Hash className="mr-1 h-3 w-3" />
{tag}
<Badge variant={i < 2 ? "trending" : "branded"}>
{i < 2 ? "🔥 " : "#"}{tag}
</Badge>
</Link>
))}
{tags.length === 0 && (
<p className="text-sm text-muted-foreground">No popular tags yet.</p>
)}
</CardContent>
</Card>
);

View File

@@ -46,6 +46,18 @@ interface ThoughtCardProps {
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({
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({
</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">
<Link
href={`/users/${author.username}`}
@@ -166,7 +189,7 @@ export function ThoughtCard({
<CardContent>
{thought.author.local ? (
<p className="whitespace-pre-wrap break-words text-shadow-sm">
{thought.content}
{renderWithHashtags(thought.content)}
</p>
) : (
<div
@@ -185,6 +208,7 @@ export function ThoughtCard({
<Button
variant="ghost"
size="sm"
className="rounded-full bg-primary/8 border border-primary/15 text-primary hover:bg-primary/15"
onClick={() => setIsReplyOpen(!isReplyOpen)}
>
<MessageSquare className="mr-2 h-4 w-4" />
@@ -194,7 +218,7 @@ export function ThoughtCard({
)}
{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
replyToId={thought.id}
onSuccess={() => setIsReplyOpen(false)}

View File

@@ -17,12 +17,13 @@ export async function TopFriends({ username }: TopFriendsProps) {
return (
<Card id="top-friends" className="p-4">
<CardHeader id="top-friends__header" className="p-0 pb-2">
<CardTitle id="top-friends__title" className="text-lg text-shadow-md">
<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">
<CardContent id="top-friends__content" className="p-0 space-y-1">
{friends.map((friend) => (
<Link
id={`top-friends__link-${friend.id}`}
@@ -30,12 +31,17 @@ export async function TopFriends({ username }: TopFriendsProps) {
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.username} />
<span
id={`top-friends__name-${friend.id}`}
className="text-xs truncate w-full font-medium text-shadow-sm"
>
{friend.displayName || friend.username}
<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>
))}

View File

@@ -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: {

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

View File

@@ -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 (
<Avatar className={cn("border-2 border-primary/50 shadow-md", className)}>
<Avatar className={cn("avatar-gradient", className)}>
{src && (
<AvatarImage
className="object-cover object-center"
@@ -18,8 +19,8 @@ export function UserAvatar({ src, alt, className }: UserAvatarProps) {
alt={alt ?? "User avatar"}
/>
)}
<AvatarFallback>
<User className="h-5 w-5" />
<AvatarFallback className="avatar-gradient text-white font-bold text-sm">
{initial}
</AvatarFallback>
</Avatar>
);

View File

@@ -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 (
<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>
);
}
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 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>
<div className="text-muted-foreground text-sm text-center py-4">
{usersCount.count} registered users.
</div>
<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>
);