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}
-
- )
+
+
+ {emoji}
+
+ {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
+
+
+
+
+
{token ? (
) : (
<>
-
)}
-
+
{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 (
-