feat: Frutiger Aero redesign — glass panels, Aero shimmer, interaction moments
This commit is contained in:
@@ -48,10 +48,10 @@
|
|||||||
/* Frutiger Aero Gradients */
|
/* Frutiger Aero Gradients */
|
||||||
--gradient-fa-blue: 135deg, hsl(217 91% 60%) 0%, hsl(200 90% 70%) 100%;
|
--gradient-fa-blue: 135deg, hsl(217 91% 60%) 0%, hsl(200 90% 70%) 100%;
|
||||||
--gradient-fa-green: 135deg, hsl(155 70% 55%) 0%, hsl(170 80% 65%) 100%;
|
--gradient-fa-green: 135deg, hsl(155 70% 55%) 0%, hsl(170 80% 65%) 100%;
|
||||||
--gradient-fa-card: 180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%,
|
--gradient-fa-card:
|
||||||
hsl(var(--card)) 100%;
|
180deg, hsl(var(--card)) 0%, hsl(var(--card)) 90%, hsl(var(--card)) 100%;
|
||||||
--gradient-fa-gloss: 135deg, rgba(255, 255, 255, 0.2) 0%,
|
--gradient-fa-gloss:
|
||||||
rgba(255, 255, 255, 0) 100%;
|
135deg, rgba(255, 255, 255, 0.2) 0%, rgba(255, 255, 255, 0) 100%;
|
||||||
|
|
||||||
--shadow-fa-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
--shadow-fa-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
--shadow-fa-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
|
--shadow-fa-md: 0 4px 6px rgba(0, 0, 0, 0.1), 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||||
@@ -183,11 +183,6 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
background-image: url("/background.avif");
|
|
||||||
background-size: cover;
|
|
||||||
background-position: center;
|
|
||||||
background-attachment: fixed;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.glossy-effect::before {
|
.glossy-effect::before {
|
||||||
@@ -312,3 +307,165 @@
|
|||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Frutiger Aero interaction keyframes ── */
|
||||||
|
@keyframes slideDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
max-height: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateX(0) rotate(0deg);
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
transform: translateX(-4px) rotate(-1.5deg);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateX(4px) rotate(1.5deg);
|
||||||
|
}
|
||||||
|
45% {
|
||||||
|
transform: translateX(-3px) rotate(-1deg);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateX(3px) rotate(1deg);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateX(-1px) rotate(-0.5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(8px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floatBob {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmerAero {
|
||||||
|
0% {
|
||||||
|
background-position: -400px 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 400px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.animate-slide-down {
|
||||||
|
overflow: hidden;
|
||||||
|
animation: slideDown 0.22s ease-out forwards;
|
||||||
|
}
|
||||||
|
.animate-shake {
|
||||||
|
animation: shake 0.45s ease-out;
|
||||||
|
}
|
||||||
|
.animate-fade-out {
|
||||||
|
animation: fadeOut 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
.animate-float-bob {
|
||||||
|
animation: floatBob 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aero-tinted shimmer for skeleton loaders */
|
||||||
|
.shimmer-aero {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(96, 165, 250, 0.12) 25%,
|
||||||
|
rgba(96, 165, 250, 0.3) 50%,
|
||||||
|
rgba(96, 165, 250, 0.12) 75%
|
||||||
|
);
|
||||||
|
background-size: 800px 100%;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
animation: shimmerAero 1.5s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Widget title icon badges */
|
||||||
|
.widget-icon {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.widget-icon-blue {
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #2563eb);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(37, 99, 235, 0.3),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.widget-icon-green {
|
||||||
|
background: linear-gradient(135deg, #6ee7b7, #10b981);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(16, 185, 129, 0.3),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.widget-icon-purple {
|
||||||
|
background: linear-gradient(135deg, #c4b5fd, #7c3aed);
|
||||||
|
box-shadow:
|
||||||
|
0 2px 4px rgba(124, 58, 237, 0.3),
|
||||||
|
inset 0 1px 1px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Landing page ambient orbs */
|
||||||
|
.orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(40px);
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient avatar fallback */
|
||||||
|
.avatar-gradient {
|
||||||
|
background: linear-gradient(135deg, #60a5fa, #34d399);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px white,
|
||||||
|
0 0 0 3.5px rgba(59, 130, 246, 0.45);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Respect reduced motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-slide-down {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.animate-shake {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.animate-fade-out {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.animate-float-bob {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
.shimmer-aero {
|
||||||
|
animation: none;
|
||||||
|
background: rgba(96, 165, 250, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { AuthProvider } from "@/hooks/use-auth";
|
|||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Header } from "@/components/header";
|
import { Header } from "@/components/header";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
import Image from "next/image";
|
||||||
import InstallPrompt from "@/components/install-prompt";
|
import InstallPrompt from "@/components/install-prompt";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -52,6 +53,14 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${frutiger.className} antialiased`}>
|
<body className={`${frutiger.className} antialiased`}>
|
||||||
|
<Image
|
||||||
|
src="/bg1.avif"
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
quality={85}
|
||||||
|
className="fixed inset-0 -z-10 object-cover object-center"
|
||||||
|
/>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import { UsersCount } from "@/components/users-count";
|
|||||||
import { PaginationNav } from "@/components/pagination-nav";
|
import { PaginationNav } from "@/components/pagination-nav";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { ProfileSkeleton, TagsSkeleton, CountSkeleton } from "@/components/loading-skeleton";
|
import {
|
||||||
|
ProfileSkeleton,
|
||||||
|
TagsSkeleton,
|
||||||
|
CountSkeleton,
|
||||||
|
} from "@/components/loading-skeleton";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Home",
|
title: "Home",
|
||||||
@@ -86,9 +90,7 @@ async function FeedPage({
|
|||||||
</header>
|
</header>
|
||||||
<ThoughtForm />
|
<ThoughtForm />
|
||||||
|
|
||||||
<div className="block lg:hidden space-y-6">
|
<div className="block lg:hidden space-y-6">{sidebar}</div>
|
||||||
{sidebar}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{thoughtThreads.map((thought) => (
|
{thoughtThreads.map((thought) => (
|
||||||
@@ -99,7 +101,13 @@ async function FeedPage({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{thoughtThreads.length === 0 && (
|
{thoughtThreads.length === 0 && (
|
||||||
<EmptyState message="Your feed is empty. Follow some users to see their thoughts!" />
|
<EmptyState
|
||||||
|
emoji="💭"
|
||||||
|
title="Your feed is quiet"
|
||||||
|
message="Your feed is empty. Follow some users to see their thoughts!"
|
||||||
|
ctaLabel="Discover people ✨"
|
||||||
|
ctaHref="/users/all"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<PaginationNav
|
<PaginationNav
|
||||||
@@ -110,9 +118,7 @@ async function FeedPage({
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside className="hidden lg:block lg:col-span-1">
|
<aside className="hidden lg:block lg:col-span-1">
|
||||||
<div className="sticky top-20 space-y-6">
|
<div className="sticky top-20 space-y-6">{sidebar}</div>
|
||||||
{sidebar}
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,28 +127,112 @@ async function FeedPage({
|
|||||||
|
|
||||||
function LandingPage() {
|
function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="font-sans min-h-screen flex items-center justify-center relative overflow-hidden">
|
||||||
<div className="font-sans min-h-screen text-gray-800 flex items-center justify-center">
|
{/* Ambient orbs */}
|
||||||
<div className="container mx-auto max-w-2xl p-4 sm:p-6 text-center glass-effect glossy-effect bottom rounded-md shadow-fa-lg">
|
<div
|
||||||
<h1
|
className="orb"
|
||||||
className="text-5xl font-bold"
|
style={{
|
||||||
style={{ textShadow: "2px 2px 4px rgba(0,0,0,0.1)" }}
|
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="px-7">
|
||||||
|
<Link href="/login">Login</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild variant="secondary" className="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)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Welcome to Thoughts
|
<span
|
||||||
</h1>
|
className="w-2 h-2 rounded-full bg-emerald-400 inline-block"
|
||||||
<p className="text-muted-foreground mt-2">
|
style={{ boxShadow: "0 0 4px #34d399" }}
|
||||||
Throwback to the golden age of microblogging.
|
/>
|
||||||
</p>
|
Works with Mastodon, Pixelfed & more
|
||||||
<div className="mt-8 flex justify-center gap-4">
|
</span>
|
||||||
<Button asChild>
|
|
||||||
<Link href="/login">Login</Link>
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" asChild>
|
|
||||||
<Link href="/register">Register</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
|||||||
<RemoteUserCard actor={remoteActor} />
|
<RemoteUserCard actor={remoteActor} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message={`No user found at ${query}`} />
|
<EmptyState emoji="🔍" title="No results" message={`No user found at ${query}`} />
|
||||||
)
|
)
|
||||||
) : results ? (
|
) : results ? (
|
||||||
<Tabs defaultValue="thoughts" className="w-full">
|
<Tabs defaultValue="thoughts" className="w-full">
|
||||||
@@ -91,7 +91,7 @@ export default async function SearchPage({ searchParams }: SearchPageProps) {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState message="No results found or an error occurred." />
|
<EmptyState emoji="🔍" title="No results" message="No results found or an error occurred." />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export default async function TagPage({ params }: TagPageProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{thoughtThreads.length === 0 && (
|
{thoughtThreads.length === 0 && (
|
||||||
<EmptyState message="No thoughts found for this tag." />
|
<EmptyState emoji="🏷" title="No thoughts here yet" message="No thoughts found for this tag." />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ export default async function ProfilePage({ params }: ProfilePageProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{thoughtThreads.length === 0 && (
|
{thoughtThreads.length === 0 && (
|
||||||
<EmptyState message="This user hasn't posted any public thoughts yet." />
|
<EmptyState emoji="💭" title="Nothing here yet" message="This user hasn't posted any public thoughts yet." />
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{isOwnProfile && (
|
{isOwnProfile && (
|
||||||
|
|||||||
@@ -1,12 +1,39 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
interface EmptyStateProps {
|
interface EmptyStateProps {
|
||||||
message: string
|
emoji?: string;
|
||||||
className?: string
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
ctaLabel?: string;
|
||||||
|
ctaHref?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyState({ message, className }: EmptyStateProps) {
|
export function EmptyState({
|
||||||
|
emoji = "💭",
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
ctaLabel,
|
||||||
|
ctaHref,
|
||||||
|
className = "",
|
||||||
|
}: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<p className={`text-center text-muted-foreground pt-8 ${className ?? ""}`}>
|
<div className={`flex flex-col items-center text-center py-10 gap-2 ${className}`}>
|
||||||
{message}
|
<span className="text-4xl animate-float-bob select-none" aria-hidden="true">
|
||||||
</p>
|
{emoji}
|
||||||
)
|
</span>
|
||||||
|
{title && (
|
||||||
|
<p className="font-bold text-base text-foreground text-shadow-sm">{title}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground max-w-xs leading-relaxed">{message}</p>
|
||||||
|
{ctaLabel && ctaHref && (
|
||||||
|
<Link
|
||||||
|
href={ctaHref}
|
||||||
|
className="mt-2 inline-flex items-center gap-1.5 px-5 py-2 rounded-full text-sm font-bold text-white fa-gradient-blue shadow-fa-md glossy-effect relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useOptimistic } from "react"
|
import { useOptimistic, useRef } from "react"
|
||||||
import { followUser, unfollowUser } from "@/app/actions/social"
|
import { followUser, unfollowUser } from "@/app/actions/social"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -11,31 +11,101 @@ interface FollowButtonProps {
|
|||||||
isInitiallyFollowing: boolean
|
isInitiallyFollowing: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BURST_COLORS = ["#2563eb", "#06b6d4", "#10b981", "#f59e0b", "#a855f7", "#ef4444"]
|
||||||
|
|
||||||
|
function burstParticles(canvas: HTMLCanvasElement) {
|
||||||
|
const ctx = canvas.getContext("2d")
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const cx = canvas.width / 2
|
||||||
|
const cy = canvas.height / 2
|
||||||
|
const particles = Array.from({ length: 14 }, (_, i) => {
|
||||||
|
const angle = (i / 14) * Math.PI * 2
|
||||||
|
const speed = 2.5 + Math.random() * 2
|
||||||
|
return {
|
||||||
|
x: cx,
|
||||||
|
y: cy,
|
||||||
|
vx: Math.cos(angle) * speed,
|
||||||
|
vy: Math.sin(angle) * speed,
|
||||||
|
r: 3 + Math.random() * 3,
|
||||||
|
color: BURST_COLORS[i % BURST_COLORS.length],
|
||||||
|
life: 1,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let rafId: number
|
||||||
|
|
||||||
|
function frame() {
|
||||||
|
if (!canvas.isConnected) {
|
||||||
|
cancelAnimationFrame(rafId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx!.clearRect(0, 0, canvas.width, canvas.height)
|
||||||
|
let alive = false
|
||||||
|
for (const p of particles) {
|
||||||
|
p.x += p.vx
|
||||||
|
p.y += p.vy
|
||||||
|
p.vy += 0.08
|
||||||
|
p.life -= 0.03
|
||||||
|
if (p.life > 0) {
|
||||||
|
alive = true
|
||||||
|
ctx!.globalAlpha = p.life
|
||||||
|
ctx!.fillStyle = p.color
|
||||||
|
ctx!.beginPath()
|
||||||
|
ctx!.arc(p.x, p.y, p.r, 0, Math.PI * 2)
|
||||||
|
ctx!.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx!.globalAlpha = 1
|
||||||
|
if (alive) {
|
||||||
|
rafId = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
|
||||||
export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
|
export function FollowButton({ username, isInitiallyFollowing }: FollowButtonProps) {
|
||||||
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
|
const [optimisticFollowing, setOptimisticFollowing] = useOptimistic(isInitiallyFollowing)
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||||
|
|
||||||
async function handleClick() {
|
async function handleClick() {
|
||||||
const next = !optimisticFollowing
|
const next = !optimisticFollowing
|
||||||
setOptimisticFollowing(next)
|
setOptimisticFollowing(next)
|
||||||
|
|
||||||
|
if (next && canvasRef.current) {
|
||||||
|
burstParticles(canvasRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await (next ? followUser(username) : unfollowUser(username))
|
await (next ? followUser(username) : unfollowUser(username))
|
||||||
} catch {
|
} catch {
|
||||||
setOptimisticFollowing(!next) // revert
|
setOptimisticFollowing(!next)
|
||||||
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
|
toast.error(`Failed to ${next ? "follow" : "unfollow"} user.`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div className="relative inline-block">
|
||||||
onClick={handleClick}
|
<canvas
|
||||||
variant={optimisticFollowing ? "secondary" : "default"}
|
ref={canvasRef}
|
||||||
data-following={optimisticFollowing}
|
width={160}
|
||||||
>
|
height={80}
|
||||||
{optimisticFollowing ? (
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||||
<><UserMinus className="mr-2 h-4 w-4" /> Unfollow</>
|
aria-hidden
|
||||||
) : (
|
/>
|
||||||
<><UserPlus className="mr-2 h-4 w-4" /> Follow</>
|
<Button
|
||||||
)}
|
onClick={handleClick}
|
||||||
</Button>
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { UserNav } from "./user-nav";
|
import { UserNav } from "./user-nav";
|
||||||
@@ -10,25 +11,33 @@ export function Header() {
|
|||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 flex justify-center w-full border-b border-primary/20 bg-background/80 glass-effect glossy-effect bottom rounded-none">
|
<header className="sticky top-0 z-50 flex justify-center w-full border-b border-white/20 bg-background/80 glass-effect glossy-effect bottom rounded-none shadow-fa-md">
|
||||||
<div className="container flex h-14 items-center px-2">
|
<div className="container flex h-14 items-center px-2">
|
||||||
<div className="flex gap-2">
|
{/* Logo */}
|
||||||
<Link href="/" className="flex items-center gap-1">
|
<Link href="/" className="flex items-center gap-2 mr-4 shrink-0">
|
||||||
<span className="hidden font-bold text-primary sm:inline-block">
|
<Image
|
||||||
Thoughts
|
src="/icon.avif"
|
||||||
</span>
|
alt="Thoughts"
|
||||||
</Link>
|
width={32}
|
||||||
<MainNav />
|
height={32}
|
||||||
</div>
|
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">
|
<div className="flex flex-1 items-center justify-end space-x-2">
|
||||||
{token ? (
|
{token ? (
|
||||||
<UserNav />
|
<UserNav />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm" variant="outline" className="rounded-full">
|
||||||
<Link href="/login">Login</Link>
|
<Link href="/login">Login</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm">
|
<Button asChild size="sm" className="rounded-full">
|
||||||
<Link href="/register">Register</Link>
|
<Link href="/register">Register</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,21 +2,21 @@ import Link from "next/link";
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { getPopularTags } from "@/lib/api";
|
import { getPopularTags } from "@/lib/api";
|
||||||
import { Hash } from "lucide-react";
|
|
||||||
|
|
||||||
export async function PopularTags() {
|
export async function PopularTags() {
|
||||||
const tags = await getPopularTags().catch(() => []);
|
const tags = await getPopularTags().catch(() => []);
|
||||||
|
|
||||||
if (tags.length === 0) {
|
if (tags.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="p-4">
|
||||||
<CardHeader>
|
<CardHeader className="p-0 pb-2">
|
||||||
<CardTitle>Popular Tags</CardTitle>
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<span className="widget-icon widget-icon-blue">🏷</span>
|
||||||
|
Popular Tags
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<p className="text-center text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground py-4">No tags yet.</p>
|
||||||
No popular tags to display.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -24,24 +24,20 @@ export async function PopularTags() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<CardHeader className="p-0 pb-2">
|
<CardHeader className="p-0 pb-3">
|
||||||
<CardTitle className="text-lg">Popular Tags</CardTitle>
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<span className="widget-icon widget-icon-blue">🏷</span>
|
||||||
|
Popular Tags
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap gap-2 p-0">
|
<CardContent className="flex flex-wrap gap-2 p-0">
|
||||||
{tags.map((tag) => (
|
{tags.map((tag, i) => (
|
||||||
<Link href={`/tags/${tag}`} key={tag}>
|
<Link href={`/tags/${tag}`} key={tag}>
|
||||||
<Badge
|
<Badge variant={i < 2 ? "trending" : "branded"}>
|
||||||
variant="secondary"
|
{i < 2 ? "🔥 " : "#"}{tag}
|
||||||
className="hover:shadow-lg transition-shadow text-shadow-sm cursor-pointer"
|
|
||||||
>
|
|
||||||
<Hash className="mr-1 h-3 w-3" />
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{tags.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">No popular tags yet.</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ interface ThoughtCardProps {
|
|||||||
isReply?: boolean;
|
isReply?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderWithHashtags(content: string) {
|
||||||
|
return content.split(/(#\w+)/g).map((part, i) =>
|
||||||
|
/^#\w+$/.test(part) ? (
|
||||||
|
<span key={i} className="text-primary font-medium">
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
part
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ThoughtCard({
|
export function ThoughtCard({
|
||||||
thought,
|
thought,
|
||||||
currentUser,
|
currentUser,
|
||||||
@@ -54,6 +66,7 @@ export function ThoughtCard({
|
|||||||
const { author } = thought;
|
const { author } = thought;
|
||||||
const [isAlertOpen, setIsAlertOpen] = useState(false);
|
const [isAlertOpen, setIsAlertOpen] = useState(false);
|
||||||
const [isReplyOpen, setIsReplyOpen] = useState(false);
|
const [isReplyOpen, setIsReplyOpen] = useState(false);
|
||||||
|
const [deletingState, setDeletingState] = useState<"idle" | "shaking" | "fading">("idle");
|
||||||
const { token } = useAuth();
|
const { token } = useAuth();
|
||||||
const timeAgo = formatDistanceToNow(new Date(thought.createdAt), {
|
const timeAgo = formatDistanceToNow(new Date(thought.createdAt), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
@@ -62,14 +75,18 @@ export function ThoughtCard({
|
|||||||
const isAuthor = currentUser?.username === thought.author.username;
|
const isAuthor = currentUser?.username === thought.author.username;
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
setIsAlertOpen(false);
|
||||||
|
setDeletingState("shaking");
|
||||||
|
await new Promise((r) => setTimeout(r, 450));
|
||||||
|
setDeletingState("fading");
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
try {
|
try {
|
||||||
await deleteThought(thought.id);
|
await deleteThought(thought.id);
|
||||||
toast.success("Thought deleted successfully.");
|
toast.success("Thought deleted.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete thought:", error);
|
console.error("Failed to delete thought:", error);
|
||||||
|
setDeletingState("idle");
|
||||||
toast.error("Failed to delete thought.");
|
toast.error("Failed to delete thought.");
|
||||||
} finally {
|
|
||||||
setIsAlertOpen(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,7 +132,13 @@ export function ThoughtCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Card className="mt-2">
|
<Card
|
||||||
|
className={cn(
|
||||||
|
"mt-2 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-fa-lg",
|
||||||
|
deletingState === "shaking" && "animate-shake",
|
||||||
|
deletingState === "fading" && "animate-fade-out pointer-events-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
<Link
|
<Link
|
||||||
href={`/users/${author.username}`}
|
href={`/users/${author.username}`}
|
||||||
@@ -166,7 +189,7 @@ export function ThoughtCard({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
{thought.author.local ? (
|
{thought.author.local ? (
|
||||||
<p className="whitespace-pre-wrap break-words text-shadow-sm">
|
<p className="whitespace-pre-wrap break-words text-shadow-sm">
|
||||||
{thought.content}
|
{renderWithHashtags(thought.content)}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
@@ -185,6 +208,7 @@ export function ThoughtCard({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="rounded-full bg-primary/8 border border-primary/15 text-primary hover:bg-primary/15"
|
||||||
onClick={() => setIsReplyOpen(!isReplyOpen)}
|
onClick={() => setIsReplyOpen(!isReplyOpen)}
|
||||||
>
|
>
|
||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
@@ -194,7 +218,7 @@ export function ThoughtCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isReplyOpen && (
|
{isReplyOpen && (
|
||||||
<div className="border-t m-4 rounded-2xl border-border/50 bg-secondary/20 ">
|
<div className="animate-slide-down border-t m-4 rounded-2xl border-border/50 bg-secondary/20">
|
||||||
<ThoughtForm
|
<ThoughtForm
|
||||||
replyToId={thought.id}
|
replyToId={thought.id}
|
||||||
onSuccess={() => setIsReplyOpen(false)}
|
onSuccess={() => setIsReplyOpen(false)}
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ export async function TopFriends({ username }: TopFriendsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card id="top-friends" className="p-4">
|
<Card id="top-friends" className="p-4">
|
||||||
<CardHeader id="top-friends__header" className="p-0 pb-2">
|
<CardHeader id="top-friends__header" className="p-0 pb-3">
|
||||||
<CardTitle id="top-friends__title" className="text-lg text-shadow-md">
|
<CardTitle id="top-friends__title" className="text-lg flex items-center gap-2">
|
||||||
|
<span className="widget-icon widget-icon-green">👥</span>
|
||||||
Top Friends
|
Top Friends
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent id="top-friends__content" className="p-0">
|
<CardContent id="top-friends__content" className="p-0 space-y-1">
|
||||||
{friends.map((friend) => (
|
{friends.map((friend) => (
|
||||||
<Link
|
<Link
|
||||||
id={`top-friends__link-${friend.id}`}
|
id={`top-friends__link-${friend.id}`}
|
||||||
@@ -30,12 +31,17 @@ export async function TopFriends({ username }: TopFriendsProps) {
|
|||||||
key={friend.id}
|
key={friend.id}
|
||||||
className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-lg hover:bg-accent/50 transition-colors"
|
className="flex items-center gap-3 py-2 px-2 -mx-2 rounded-lg hover:bg-accent/50 transition-colors"
|
||||||
>
|
>
|
||||||
<UserAvatar src={friend.avatarUrl} alt={friend.username} />
|
<UserAvatar src={friend.avatarUrl} alt={friend.displayName || friend.username} />
|
||||||
<span
|
<div className="flex flex-col min-w-0">
|
||||||
id={`top-friends__name-${friend.id}`}
|
<span className="text-xs font-semibold truncate text-shadow-sm">
|
||||||
className="text-xs truncate w-full font-medium text-shadow-sm"
|
{friend.displayName || friend.username}
|
||||||
>
|
</span>
|
||||||
{friend.displayName || friend.username}
|
<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>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ const badgeVariants = cva(
|
|||||||
default:
|
default:
|
||||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80 glossy-effect bottom text-shadow-sm",
|
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80 glossy-effect bottom text-shadow-sm",
|
||||||
secondary:
|
secondary:
|
||||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 glossy-effect bottom text-shadow-sm", // Use green for secondary
|
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80 glossy-effect bottom text-shadow-sm",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 glossy-effect bottom text-shadow-sm",
|
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80 glossy-effect bottom text-shadow-sm",
|
||||||
outline: "text-foreground glossy-effect bottom text-shadow-sm",
|
outline: "text-foreground glossy-effect bottom text-shadow-sm",
|
||||||
|
branded:
|
||||||
|
"border border-primary/20 bg-primary/8 text-primary font-semibold hover:bg-primary/15 hover:scale-105 transition-transform cursor-pointer",
|
||||||
|
trending:
|
||||||
|
"border border-red-300/30 bg-gradient-to-r from-orange-500/10 to-red-500/8 text-red-600 font-semibold hover:from-orange-500/18 hover:to-red-500/14 hover:scale-105 transition-transform cursor-pointer",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { cn } from "@/lib/utils";
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="skeleton"
|
className={cn("rounded-md shimmer-aero", className)}
|
||||||
className={cn("bg-muted/50 animate-pulse rounded-md", className)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton };
|
export { Skeleton }
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { User } from "lucide-react";
|
|
||||||
|
|
||||||
interface UserAvatarProps {
|
interface UserAvatarProps {
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
@@ -9,8 +8,10 @@ interface UserAvatarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UserAvatar({ src, alt, className }: UserAvatarProps) {
|
export function UserAvatar({ src, alt, className }: UserAvatarProps) {
|
||||||
|
const initial = alt?.trim()[0]?.toUpperCase() ?? "?";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatar className={cn("border-2 border-primary/50 shadow-md", className)}>
|
<Avatar className={cn("avatar-gradient", className)}>
|
||||||
{src && (
|
{src && (
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
className="object-cover object-center"
|
className="object-cover object-center"
|
||||||
@@ -18,8 +19,8 @@ export function UserAvatar({ src, alt, className }: UserAvatarProps) {
|
|||||||
alt={alt ?? "User avatar"}
|
alt={alt ?? "User avatar"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<AvatarFallback>
|
<AvatarFallback className="avatar-gradient text-white font-bold text-sm">
|
||||||
<User className="h-5 w-5" />
|
{initial}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,68 +1,57 @@
|
|||||||
import { Link } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { getAllUsersCount } from "@/lib/api";
|
import { getAllUsersCount } from "@/lib/api";
|
||||||
|
|
||||||
export async function UsersCount() {
|
export async function UsersCount() {
|
||||||
const usersCount = await getAllUsersCount().catch(() => null);
|
const usersCount = await getAllUsersCount().catch(() => null);
|
||||||
|
|
||||||
if (usersCount === null) {
|
const count = usersCount?.count ?? null;
|
||||||
return (
|
|
||||||
<Card className="p-4">
|
|
||||||
<CardHeader className="p-0 pb-2">
|
|
||||||
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Total number of registered users on Thoughts.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-muted-foreground text-sm text-center py-4">
|
|
||||||
Could not load users count.
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (usersCount.count === 0) {
|
|
||||||
return (
|
|
||||||
<Card className="p-4">
|
|
||||||
<CardHeader className="p-0 pb-2">
|
|
||||||
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Total number of registered users on Thoughts.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-muted-foreground text-sm text-center py-4">
|
|
||||||
No registered users yet. Be the first to{" "}
|
|
||||||
<Link href="/signup" className="text-primary hover:underline">
|
|
||||||
sign up
|
|
||||||
</Link>
|
|
||||||
!
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<CardHeader className="p-0 pb-2">
|
<CardHeader className="p-0 pb-3">
|
||||||
<CardTitle className="text-lg text-shadow-md">Users Count</CardTitle>
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<CardDescription>
|
<span className="widget-icon widget-icon-purple">✨</span>
|
||||||
Total number of registered users on Thoughts.
|
Community
|
||||||
</CardDescription>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<div className="text-muted-foreground text-sm text-center py-4">
|
{count === null ? (
|
||||||
{usersCount.count} registered users.
|
<p className="text-sm text-muted-foreground text-center py-2">
|
||||||
</div>
|
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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
BIN
thoughts-frontend/public/bg1.avif
Normal file
BIN
thoughts-frontend/public/bg1.avif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
Reference in New Issue
Block a user