feat(frontend): add shared client libraries and UI primitives

Introduce API helpers, types, hooks, design-system components, and the authenticated app shell.
This commit is contained in:
ahp
2026-10-07 19:40:26 +03:30
parent 0751ee5588
commit 680db7dce0
39 changed files with 2806 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Navbar } from "@/components/navbar";
import { ChatWidget } from "@/components/mentor-chat";
import { BrandMark } from "@/components/brand-mark";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth, useAuthHydrated } from "@/lib/auth-store";
import { http } from "@/lib/api";
import type { User } from "@/lib/types";
import { hasInfiniteHearts } from "@/lib/hearts";
function isPublic(p: string) {
return p === "/" || p === "/login";
}
const PROTECTED_PREFIXES = [
"/cap",
"/unwrap",
"/leaderboard",
"/admin",
"/player",
"/quiz",
"/notifications",
"/lockout",
];
function needsAuth(p: string) {
if (isPublic(p)) return false;
return PROTECTED_PREFIXES.some((prefix) => p === prefix || p.startsWith(prefix + "/"));
}
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const hydrated = useAuthHydrated();
const { user, setUser, accessToken, clear } = useAuth();
const [bootDone, setBootDone] = useState(false);
// Wait for persist hydration, then optionally refresh /me
useEffect(() => {
if (!hydrated) return;
let cancelled = false;
const boot = async () => {
if (!accessToken) {
if (!cancelled) setBootDone(true);
return;
}
try {
const { user: me } = await http.get<{ user: User }>("/api/auth/me");
if (!cancelled) setUser(me);
} catch {
if (!cancelled) clear();
} finally {
if (!cancelled) setBootDone(true);
}
};
void boot();
return () => {
cancelled = true;
};
}, [hydrated, accessToken, clear, setUser]);
const ready = hydrated && bootDone;
const requiresAuth = needsAuth(pathname);
// Redirects only in effects — never during render
useEffect(() => {
if (!ready) return;
if (!user && requiresAuth) {
router.replace("/login");
return;
}
if (user && pathname === "/login") {
router.replace("/cap");
return;
}
if (
user?.isLocked &&
!hasInfiniteHearts(user.role) &&
pathname !== "/lockout" &&
pathname !== "/unwrap" &&
requiresAuth
) {
router.replace("/lockout");
}
}, [ready, user, requiresAuth, pathname, router]);
if (!ready) {
return (
<div className="flex min-h-screen flex-col">
<div aria-hidden className="h-1 w-full bg-gradient-to-l from-primary via-accent to-gold" />
<div className="sticky top-0 z-40 border-b border-border/60 bg-background/80 backdrop-blur-xl">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-lg" />
<Skeleton className="h-3.5 w-28" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-9 w-24 rounded-xl" />
</div>
</div>
</div>
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6">
<div className="space-y-6">
<Skeleton className="h-8 w-56" />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-36 rounded-2xl" />
))}
</div>
</div>
</main>
</div>
);
}
// Protected routes: never mount children when logged out. Unknown URLs (404) stay visible.
const allowChildren = !requiresAuth || !!user;
const showLockBanner = !!user?.isLocked && !hasInfiniteHearts(user.role) && pathname !== "/lockout";
return (
<div className="flex min-h-screen flex-col">
<div aria-hidden className="h-1 w-full bg-gradient-to-l from-primary via-accent to-gold" />
<Navbar />
{showLockBanner && (
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 border-b border-destructive/30 bg-destructive/10 px-4 py-2 text-center text-sm font-medium text-destructive">
حساب شما قفل شده است — رفع محدودیت فقط با بررسی مدیریت انجام می‌شود.
<button
type="button"
className="underline underline-offset-2 hover:text-destructive/80"
onClick={() => router.push("/lockout")}
>
صفحه قفل
</button>
</div>
)}
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6">
{allowChildren ? children : null}
</main>
<footer className="border-t border-border/70 bg-muted/20 backdrop-blur">
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-3 px-4 py-8 sm:flex-row sm:px-6">
<BrandMark size="sm" logoOnly />
<p className="text-xs text-muted-foreground">
بوت‌کمپ ۱۲ هفته‌ای · توسعه‌یافته توسط امیرحسین پناهی‌فر
</p>
</div>
</footer>
{user && <ChatWidget />}
</div>
);
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useMemo, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* Apple Color Emoji PNGs served from /emoji/apple/64 (vendored from
* emoji-datasource-apple). External CDNs are blocked in many networks.
*/
const APPLE_BASE = "/emoji/apple/64";
/** Convert an emoji grapheme to Apple datasource filename stem (e.g. ❤️ → 2764-fe0f). */
export function emojiToAppleCode(emoji: string): string | null {
const cps: string[] = [];
for (const ch of emoji) {
const cp = ch.codePointAt(0);
if (cp == null) continue;
// Text-presentation selector only — keep FE0F (required for ❤️ etc.)
if (cp === 0xfe0e) continue;
cps.push(cp.toString(16));
}
if (cps.length === 0) return null;
return cps.join("-");
}
/** Candidate filenames: with FE0F as typed, then without FE0F segments. */
export function appleEmojiCandidates(emoji: string): string[] {
const primary = emojiToAppleCode(emoji);
if (!primary) return [];
const out = [`${APPLE_BASE}/${primary}.png`];
if (primary.includes("fe0f")) {
const stripped = primary
.split("-")
.filter((p) => p !== "fe0f")
.join("-");
if (stripped && stripped !== primary) {
out.push(`${APPLE_BASE}/${stripped}.png`);
}
} else {
// Some keycaps / symbols are stored with -fe0f even when the string lacks it
out.push(`${APPLE_BASE}/${primary}-fe0f.png`);
}
return out;
}
export function appleEmojiSrc(emoji: string): string | null {
return appleEmojiCandidates(emoji)[0] ?? null;
}
export function AppleEmoji({
emoji,
size = 20,
className,
title,
}: {
emoji: string;
size?: number;
className?: string;
title?: string;
}) {
const candidates = useMemo(() => appleEmojiCandidates(emoji), [emoji]);
const [idx, setIdx] = useState(0);
const [failed, setFailed] = useState(false);
const src = candidates[idx];
if (!src || failed) {
return (
<span
className={cn("inline-block leading-none", className)}
style={{ fontSize: size, fontFamily: "'Apple Color Emoji', 'Segoe UI Emoji', sans-serif" }}
title={title}
role="img"
aria-label={emoji}
>
{emoji}
</span>
);
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt={emoji}
title={title ?? emoji}
width={size}
height={size}
draggable={false}
className={cn("inline-block align-[-0.15em] object-contain", className)}
style={{ width: size, height: size }}
loading="lazy"
onError={() => {
if (idx + 1 < candidates.length) {
setIdx((i) => i + 1);
} else {
setFailed(true);
}
}}
/>
);
}
const EMOJI_RE =
/(?:\p{Extended_Pictographic}(?:\uFE0F)?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F)?)*)|\p{Regional_Indicator}{2}/gu;
/** Render plain text with Apple emoji images inline. */
export function AppleEmojiText({
text,
size = 18,
className,
}: {
text: string;
size?: number;
className?: string;
}) {
if (!text) return null;
const parts: ReactNode[] = [];
let last = 0;
let i = 0;
for (const match of text.matchAll(EMOJI_RE)) {
const idx = match.index ?? 0;
if (idx > last) {
parts.push(<span key={`t${i++}`}>{text.slice(last, idx)}</span>);
}
parts.push(<AppleEmoji key={`e${i++}`} emoji={match[0]} size={size} />);
last = idx + match[0].length;
}
if (last < text.length) {
parts.push(<span key={`t${i++}`}>{text.slice(last)}</span>);
}
return <span className={className}>{parts}</span>;
}
+69
View File
@@ -0,0 +1,69 @@
import Image from "next/image";
import { cn } from "@/lib/utils";
import logoSrc from "../../public/logo.png";
type BrandMarkProps = {
className?: string;
size?: "sm" | "md" | "lg";
/** Persian brand by default; pass "en" for Latin "Pargar" */
locale?: "en" | "fa";
/** Show the Pargar logo mark (default true) */
showLogo?: boolean;
/** Logo image only, no wordmark */
logoOnly?: boolean;
};
const sizeClass = {
sm: "text-base",
md: "text-lg",
lg: "text-3xl sm:text-4xl",
};
const logoPx = {
sm: 28,
md: 36,
lg: 56,
};
/** پرگار / Pargar */
export function BrandMark({
className,
size = "md",
locale = "fa",
showLogo = true,
logoOnly = false,
}: BrandMarkProps) {
const px = logoPx[size];
const label = locale === "en" ? "Pargar" : "پرگار";
const wordmark =
locale === "en" ? (
<span dir="ltr" className={cn("font-extrabold tracking-tight", sizeClass[size], className)}>
Par<span className="text-primary">gar</span>
</span>
) : (
<span className={cn("font-extrabold tracking-tight", sizeClass[size], className)}>
پر<span className="text-primary">گار</span>
</span>
);
return (
<span className="inline-flex items-center gap-2" aria-label={logoOnly ? label : undefined}>
{showLogo && (
<Image
src={logoSrc}
alt={logoOnly ? label : ""}
width={px}
height={px}
className={cn(
"shrink-0 rounded-xl bg-white object-contain shadow-offset-sm",
size === "sm" && "rounded-lg",
size === "lg" && "rounded-2xl"
)}
priority={size === "lg"}
/>
)}
{!logoOnly && wordmark}
</span>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function EmptyState({
icon,
title,
description,
action,
className,
}: {
icon?: ReactNode;
title: ReactNode;
description?: ReactNode;
action?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-border py-16 text-center",
className
)}
>
{icon && (
<span className="flex h-12 w-12 items-center justify-center rounded-xl bg-muted text-muted-foreground">
{icon}
</span>
)}
<div className="space-y-1 px-6">
<p className="text-sm font-semibold">{title}</p>
{description && <p className="text-xs leading-relaxed text-muted-foreground sm:text-sm">{description}</p>}
</div>
{action && <div className="mt-1">{action}</div>}
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { Button } from "@/components/ui/button";
type Props = {
children: ReactNode;
/** Optional label for support / logs */
area?: string;
};
type State = {
error: Error | null;
};
/**
* Catches render/runtime errors in the subtree and shows a recoverable Persian UI
* instead of a blank screen.
*/
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
// Keep console for operators; avoid leaking stack into the UI.
console.error(`[ErrorBoundary${this.props.area ? `:${this.props.area}` : ""}]`, error, info.componentStack);
}
private reset = () => {
this.setState({ error: null });
};
render() {
if (!this.state.error) {
return this.props.children;
}
return (
<div className="mx-auto flex min-h-[50vh] max-w-lg flex-col items-center justify-center gap-4 px-4 py-16 text-center">
<p className="text-lg font-bold text-foreground">مشکلی در نمایش این بخش پیش آمد</p>
<p className="text-sm leading-relaxed text-muted-foreground">
صفحه را یک‌بار تازه کنید. اگر ادامه داشت، کمی بعد دوباره تلاش کنید.
</p>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button type="button" onClick={this.reset}>
تلاش دوباره
</Button>
<Button type="button" variant="outline" onClick={() => window.location.assign("/")}>
بازگشت به خانه
</Button>
</div>
</div>
);
}
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { Flame, Heart, LogOut, Sparkles, User, UserCog } from "lucide-react";
import { NotificationBell } from "@/components/notification-bell";
import { ThemeToggle } from "@/components/theme-toggle";
import { BrandMark } from "@/components/brand-mark";
import { useRealtimeGateway } from "@/hooks/use-realtime";
import { useLiveUser } from "@/hooks/use-live-user";
import { useAuth } from "@/lib/auth-store";
import { http } from "@/lib/api";
import { heartsOf, hasInfiniteHearts, MAX_HEARTS, INFINITE_HEARTS_LABEL } from "@/lib/hearts";
import { useState } from "react";
import { AvatarStudio } from "@/components/avatar-studio";
import { UserAvatar, avatarPaletteOf } from "@/components/ui/user-avatar";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { cn, formatFaNumber } from "@/lib/utils";
const navLinks = [
{ href: "/cap", label: "درخت مهارت" },
{ href: "/unwrap", label: "مرکز" },
{ href: "/leaderboard", label: "لیگ‌ها" },
];
export function Navbar() {
const router = useRouter();
const pathname = usePathname();
const user = useAuth((s) => s.user);
const liveUser = useLiveUser();
useRealtimeGateway();
const [studioOpen, setStudioOpen] = useState(false);
const locked = !!liveUser?.isLocked && !hasInfiniteHearts(liveUser.role);
const infiniteHearts = hasInfiniteHearts(liveUser?.role);
const effHearts = heartsOf(liveUser);
const logout = () => {
const { refreshToken, clear: clearAuth } = useAuth.getState();
void http.post("/api/auth/logout", refreshToken ? { refreshToken } : {}).catch(() => undefined);
clearAuth();
router.replace("/");
};
// Guest chrome: home + login only
if (!user) {
return (
<header className="nav-glass sticky top-0 z-40 w-full border-b-2 border-border/60">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4 sm:px-6">
<Link href="/" className="group flex items-center gap-2.5 transition-transform duration-200 group-hover:-translate-y-0.5">
<BrandMark />
</Link>
<div className="flex items-center gap-2">
<ThemeToggle />
{pathname !== "/login" && (
<Button asChild size="sm" className="font-bold">
<Link href="/login">ورود</Link>
</Button>
)}
</div>
</div>
</header>
);
}
return (
<header className="nav-glass sticky top-0 z-40 w-full border-b-2 border-border/60">
<div className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-4 sm:px-6">
<div className="flex items-center gap-3">
<Link href="/cap" className="group flex items-center gap-2.5 transition-transform duration-200 group-hover:-translate-y-0.5">
<BrandMark />
</Link>
{!locked && (
<nav className="ms-4 hidden items-center gap-1 md:flex">
{navLinks.map((l) => {
const active = pathname === l.href || pathname.startsWith(l.href + "/");
return (
<Link
key={l.href}
href={l.href}
className={cn(
"press rounded-lg px-3.5 py-2 text-sm font-bold transition-colors",
active
? "bg-primary text-primary-foreground shadow-offset-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
{l.label}
</Link>
);
})}
</nav>
)}
</div>
<div className="flex items-center gap-1.5">
{liveUser && (
<>
<div className="hidden items-center gap-1.5 sm:flex">
<span className="inline-flex items-center gap-1 rounded-full bg-accent/10 px-2.5 py-1 text-xs font-bold text-accent transition-transform hover:-translate-y-0.5">
<Flame className="h-3.5 w-3.5" />
{formatFaNumber(liveUser.streakCurrent)}
</span>
<span className="inline-flex items-center gap-1 rounded-full bg-gold/10 px-2.5 py-1 text-xs font-bold text-gold transition-transform hover:-translate-y-0.5">
<Sparkles className="h-3.5 w-3.5" />
{formatFaNumber(liveUser.xp)}
</span>
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-bold leading-none transition-transform hover:-translate-y-0.5",
locked
? "bg-destructive/10 text-destructive"
: infiniteHearts
? "bg-primary/10 text-primary"
: effHearts === 1
? "bg-destructive/10 text-destructive animate-pulse-soft"
: "bg-primary/10 text-primary"
)}
title={
locked
? "حساب محدود است — منتور باید قفل را باز کند"
: infiniteHearts
? "جان نامحدود"
: `${formatFaNumber(effHearts)} از ${formatFaNumber(MAX_HEARTS)} جان`
}
>
<Heart className={cn("h-3.5 w-3.5", (locked || (!infiniteHearts && effHearts <= 1)) && "fill-current")} />
{infiniteHearts ? (
<span className="text-sm leading-none">{INFINITE_HEARTS_LABEL}</span>
) : (
<>
{formatFaNumber(effHearts)}/{formatFaNumber(MAX_HEARTS)}
</>
)}
</span>
</div>
<NotificationBell />
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="cursor-pointer rounded-full ring-2 ring-border ring-offset-2 ring-offset-background focus-visible:outline-none focus-visible:ring-ring"
>
<UserAvatar
name={liveUser.name ?? "؟"}
src={liveUser.avatarPhoto}
variant={liveUser.avatarVariant ?? "beam"}
palette={liveUser.avatarPalette ? avatarPaletteOf(liveUser.avatarPalette) : undefined}
className="h-9 w-9"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 shadow-offset-sm">
<div className="flex items-center gap-3 px-2.5 py-2.5">
<UserAvatar
name={liveUser.name ?? "؟"}
src={liveUser.avatarPhoto}
variant={liveUser.avatarVariant ?? "beam"}
palette={liveUser.avatarPalette ? avatarPaletteOf(liveUser.avatarPalette) : undefined}
className="h-11 w-11"
/>
<div className="min-w-0">
<p className="truncate text-sm font-extrabold">{liveUser.name}</p>
<p className="truncate text-[11px] text-muted-foreground">{liveUser.email}</p>
<p className="text-[11px] text-primary">{roleLabel(liveUser.role)}</p>
</div>
</div>
<DropdownMenuItem onClick={() => router.push("/profile")}>
<User /> پروفایل من
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setStudioOpen(true)}>
<User /> ویرایش آواتار
</DropdownMenuItem>
{liveUser.role === "admin" && (
<DropdownMenuItem onClick={() => router.push("/admin")}>
<UserCog /> پنل مدیریت
</DropdownMenuItem>
)}
{liveUser.role === "mentor" && (
<DropdownMenuItem onClick={() => router.push("/admin")}>
<UserCog /> پنل منتور
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={logout} className="text-destructive focus:text-destructive">
<LogOut /> خروج
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={studioOpen} onOpenChange={setStudioOpen}>
<DialogContent className="max-h-[85vh] max-w-sm gap-3 overflow-y-auto chat-scroll rounded-2xl">
<DialogHeader>
<DialogTitle className="text-center">ویرایش آواتار</DialogTitle>
</DialogHeader>
<AvatarStudio user={liveUser} onClose={() => setStudioOpen(false)} />
</DialogContent>
</Dialog>
</>
)}
</div>
</div>
</header>
);
}
function roleLabel(role: string): string {
if (role === "admin") return "ادمین";
if (role === "mentor") return "منتور";
return "هنرجو";
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Reveal } from "@/components/reveal";
export function PageHeader({
kicker,
title,
description,
actions,
align = "start",
className,
}: {
kicker?: string;
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
align?: "start" | "center";
className?: string;
}) {
return (
<Reveal
className={cn(
"mb-8 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between",
align === "center" && "sm:flex-col sm:items-center",
className
)}
>
<div className={cn("max-w-2xl space-y-2", align === "center" && "text-center")}>
{kicker && (
<p className={cn("section-kicker", align === "center" && "justify-center")}>{kicker}</p>
)}
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">{title}</h1>
{description && (
<p className="text-sm leading-relaxed text-muted-foreground sm:text-base">{description}</p>
)}
</div>
{actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
</Reveal>
);
}
@@ -0,0 +1,27 @@
"use client";
import { motion } from "framer-motion";
import { usePathname } from "next/navigation";
import { useRef } from "react";
import { softPage } from "@/lib/motion";
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const first = useRef(true);
if (first.current) {
first.current = false;
return <>{children}</>;
}
return (
<motion.div
key={pathname}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={softPage}
>
{children}
</motion.div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import { Reveal } from "@/components/reveal";
export function Panel({
title,
description,
icon,
tint = "bg-primary/10 text-primary",
actions,
children,
className,
bodyClassName,
delay = 0,
}: {
title?: ReactNode;
description?: ReactNode;
icon?: ReactNode;
tint?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
bodyClassName?: string;
delay?: number;
}) {
return (
<Reveal className={className} delay={delay}>
<section className="card-hover overflow-hidden rounded-2xl border-2 border-border bg-card">
{(title || actions) && (
<header className="flex flex-wrap items-start gap-3 border-b border-border/70 px-5 py-4 sm:px-6">
{icon && (
<span className={cn("mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl", tint)}>
{icon}
</span>
)}
<div className="min-w-0 flex-1">
{title && <h2 className="text-base font-bold leading-snug">{title}</h2>}
{description && (
<p className="mt-1 text-xs leading-relaxed text-muted-foreground sm:text-sm">{description}</p>
)}
</div>
{actions && (
<div className="flex w-full shrink-0 flex-wrap items-center gap-2 sm:ms-auto sm:w-auto sm:justify-end">
{actions}
</div>
)}
</header>
)}
<div className={cn("p-5 sm:p-6", bodyClassName)}>{children}</div>
</section>
</Reveal>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import React from "react";
import { MotionConfig } from "framer-motion";
import { QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
import { Toaster, toast } from "sonner";
import { ErrorBoundary } from "@/components/error-boundary";
import { queryClient } from "@/lib/query-client";
import { softTween } from "@/lib/motion";
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem={false}>
<MotionConfig reducedMotion="user" transition={softTween}>
<ErrorBoundary area="app">{children}</ErrorBoundary>
</MotionConfig>
<Toaster position="top-center" richColors closeButton />
</ThemeProvider>
</QueryClientProvider>
);
}
export { toast };
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
import { softReveal } from "@/lib/motion";
export function Reveal({
children,
delay = 0,
y = 14,
className,
}: {
children: ReactNode;
delay?: number;
y?: number;
className?: string;
}) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y, scale: 0.992 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ ...softReveal, delay }}
>
{children}
</motion.div>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useTheme } from "next-themes";
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
export function ThemeToggle() {
const { resolvedTheme, setTheme } = useTheme();
const dark = resolvedTheme === "dark";
return (
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(dark ? "light" : "dark")}
aria-label="تغییر تم"
className="rounded-md"
>
{dark ? <Sun className="h-[18px] w-[18px]" /> : <Moon className="h-[18px] w-[18px]" />}
</Button>
);
}
+42
View File
@@ -0,0 +1,42 @@
"use client";
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = "Avatar";
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = "AvatarImage";
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground",
className
)}
{...props}
/>
));
AvatarFallback.displayName = "AvatarFallback";
export { Avatar, AvatarImage, AvatarFallback };
+32
View File
@@ -0,0 +1,32 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
variant: {
default: "border-transparent bg-primary/15 text-primary",
secondary: "border-transparent bg-muted text-muted-foreground",
success: "border-transparent bg-success/15 text-success",
destructive: "border-transparent bg-destructive/15 text-destructive",
gold: "border-transparent bg-gold/15 text-gold",
accent: "border-transparent bg-accent/15 text-accent",
cyan: "border-transparent bg-secondary/15 text-secondary",
outline: "border-border text-foreground",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border font-sans text-sm font-bold transition-all duration-300 ease-[var(--ease-soft)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 cursor-pointer select-none active:translate-y-px active:shadow-none",
{
variants: {
variant: {
default: "border-primary bg-primary text-primary-foreground shadow-offset hover:-translate-y-0.5",
gradient: "border-primary bg-primary text-primary-foreground shadow-offset hover:-translate-y-0.5",
secondary: "border-secondary bg-secondary text-secondary-foreground hover:bg-secondary/90",
outline: "border-2 border-input bg-card text-foreground shadow-offset-sm hover:bg-muted",
ghost: "border-transparent hover:bg-muted text-foreground",
destructive: "border-destructive bg-destructive text-destructive-foreground shadow-offset-sm hover:-translate-y-0.5",
success: "border-success bg-success text-success-foreground shadow-offset-sm hover:-translate-y-0.5",
link: "border-transparent text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 rounded-lg px-4 py-2",
sm: "h-8 rounded-lg px-3 text-xs",
lg: "h-[3.25rem] rounded-2xl px-7 text-base",
icon: "h-10 w-10 rounded-xl",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
+53
View File
@@ -0,0 +1,53 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border bg-card text-card-foreground shadow-soft",
className,
)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-xl font-semibold leading-none", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+97
View File
@@ -0,0 +1,97 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-300",
className
)}
{...props}
/>
));
DialogOverlay.displayName = "DialogOverlay";
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-popover p-6 shadow-md rounded-md duration-300 sm:max-w-lg data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute end-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none">
<X className="h-4 w-4" />
<span className="sr-only">بستن</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = "DialogContent";
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-start", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = "DialogTitle";
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = "DialogDescription";
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
@@ -0,0 +1,43 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 8, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-44 overflow-hidden rounded-lg border border-border bg-popover p-1.5 text-popover-foreground shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = "DropdownMenuContent";
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none transition-colors focus:bg-muted data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = "DropdownMenuItem";
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem };
+48
View File
@@ -0,0 +1,48 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const LATIN_INPUT_TYPES = new Set(["password", "email", "url", "tel"]);
function wantsLatinDigits(props: React.InputHTMLAttributes<HTMLInputElement>): boolean {
const type = props.type ?? "text";
if (LATIN_INPUT_TYPES.has(type)) return true;
if (props.dir === "ltr") return true;
const ac = (props.autoComplete ?? "").toLowerCase();
if (
ac === "username" ||
ac === "email" ||
ac.includes("password") ||
ac === "one-time-code" ||
ac === "tel" ||
ac === "url"
) {
return true;
}
const id = `${props.id ?? ""} ${props.name ?? ""}`.toLowerCase();
if (id.includes("captcha") || id.includes("password") || id.includes("username") || id.includes("email")) {
return true;
}
return false;
}
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
const latin = wantsLatinDigits({ type, ...props });
return (
<input
type={type}
className={cn(
// Fixed height + symmetric vertical padding; avoid clipping Vazirmatn descenders.
"box-border h-11 w-full rounded-lg border border-input bg-card px-3.5 py-0 font-sans text-sm font-medium leading-none outline-none transition-colors placeholder:font-sans placeholder:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
latin && "font-latin placeholder:font-latin",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
@@ -0,0 +1,73 @@
"use client";
import { useMemo } from "react";
import DatePicker, { DateObject } from "react-multi-date-picker";
import TimePicker from "react-multi-date-picker/plugins/time_picker";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import { cn } from "@/lib/utils";
type Props = {
value?: string;
onChange: (iso: string) => void;
disabled?: boolean;
className?: string;
id?: string;
/** Accessible label for the input */
"aria-label"?: string;
};
function toDateObject(iso: string | undefined): DateObject | undefined {
if (!iso) return undefined;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return undefined;
return new DateObject({ date: d, calendar: persian, locale: persian_fa });
}
function toIso(obj: DateObject | null | undefined): string {
if (!obj) return "";
const js = obj.toDate();
if (Number.isNaN(js.getTime())) return "";
return js.toISOString();
}
/** Jalali calendar + time; value/onChange use ISO UTC strings for the API. */
export function JalaliDateTimePicker({ value, onChange, disabled, className, id, "aria-label": ariaLabel }: Props) {
const selected = useMemo(() => toDateObject(value), [value]);
return (
<DatePicker
id={id}
value={selected}
onChange={(date) => {
if (Array.isArray(date)) {
onChange(toIso(date[0] ?? null));
return;
}
onChange(toIso(date));
}}
calendar={persian}
locale={persian_fa}
format="YYYY/MM/DD HH:mm"
plugins={[<TimePicker key="time" position="bottom" hideSeconds />]}
calendarPosition="bottom-right"
disabled={disabled}
editable={false}
inputClass={cn(
"box-border h-11 w-full rounded-lg border border-input bg-card px-3.5 py-0 font-sans text-sm font-medium leading-none outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
containerClassName="w-full"
aria-label={ariaLabel}
/>
);
}
/** True when both ISO strings parse and end >= start. */
export function isValidIsoRange(startsAt: string, endsAt: string): boolean {
if (!startsAt || !endsAt) return false;
const a = new Date(startsAt).getTime();
const b = new Date(endsAt).getTime();
if (Number.isNaN(a) || Number.isNaN(b)) return false;
return b >= a;
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };
@@ -0,0 +1,46 @@
"use client";
import * as React from "react";
import { Eye, EyeOff } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
const PasswordInput = React.forwardRef<
HTMLInputElement,
Omit<React.InputHTMLAttributes<HTMLInputElement>, "type">
>(({ className, dir, autoComplete, ...props }, ref) => {
const [visible, setVisible] = React.useState(false);
// Default LTR for real passwords; callers can pass dir="rtl" for Persian secrets (e.g. security answer).
const resolvedDir = dir ?? "ltr";
const isRtl = resolvedDir === "rtl";
return (
<div className="relative">
<Input
ref={ref}
{...props}
type={visible ? "text" : "password"}
dir={resolvedDir}
autoComplete={autoComplete ?? "current-password"}
className={cn(
// Physical padding/toggle side so RTL page chrome cannot flip the eye away from the gutter.
"font-latin placeholder:font-latin pr-11",
isRtl ? "text-right" : "text-left",
className,
)}
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
aria-label={visible ? "مخفی کردن رمز" : "نمایش رمز"}
aria-pressed={visible}
className="absolute right-1.5 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{visible ? <EyeOff className="h-4 w-4" aria-hidden /> : <Eye className="h-4 w-4" aria-hidden />}
</button>
</div>
);
});
PasswordInput.displayName = "PasswordInput";
export { PasswordInput };
+38
View File
@@ -0,0 +1,38 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value?: number;
gradient?: boolean;
/** Softer fill motion for verified-watch / long jumps. */
smooth?: boolean;
}
const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
({ className, value = 0, gradient, smooth, ...props }, ref) => {
const pct = Math.min(100, Math.max(0, value));
return (
<div
ref={ref}
className={cn("relative h-2.5 w-full overflow-hidden rounded-full bg-muted", className)}
{...props}
>
<div
className={cn(
"h-full rounded-full will-change-[width] rtl:me-auto",
gradient ? "bg-primary" : "bg-primary",
smooth
? "transition-[width] duration-1000 ease-[cubic-bezier(0.22,1,0.36,1)]"
: "transition-[width] duration-300 ease-out"
)}
style={{ width: `${pct}%` }}
/>
</div>
);
}
);
Progress.displayName = "Progress";
export { Progress };
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
export function Skeleton({ className }: { className?: string }) {
return <div aria-hidden className={cn("skeleton", className)} />;
}
export function SkeletonText({ className }: { className?: string }) {
return <Skeleton className={cn("h-4 rounded-md", className)} />;
}
export function SkeletonAvatar({ className }: { className?: string }) {
return <Skeleton className={cn("h-10 w-10 shrink-0 rounded-xl", className)} />;
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
dir="ltr"
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = "Switch";
export { Switch };
+51
View File
@@ -0,0 +1,51 @@
"use client";
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
));
TabsList.displayName = "TabsList";
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:border data-[state=active]:border-border",
className
)}
{...props}
/>
));
TabsTrigger.displayName = "TabsTrigger";
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn("mt-4 focus-visible:outline-none", className)}
{...props}
/>
));
TabsContent.displayName = "TabsContent";
export { Tabs, TabsList, TabsTrigger, TabsContent };
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
className={cn(
"flex min-h-[120px] w-full rounded-lg border border-input bg-card px-3.5 py-2.5 font-sans text-sm font-medium leading-normal outline-none transition-colors placeholder:font-sans placeholder:font-medium placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
));
Textarea.displayName = "Textarea";
export { Textarea };
@@ -0,0 +1,75 @@
"use client";
import BoringAvatar from "boring-avatars";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
const AVATAR_COLORS = ["#7d6df2", "#0f9b8e", "#16a34a", "#d97706", "#9333ea", "#0891b2", "#65a30d", "#f59e0b"];
export { AVATAR_COLORS };
export type AvatarVariantName = "marble" | "beam" | "pixel" | "sunset" | "ring" | "bauhaus";
export const AVATAR_VARIANTS: { name: AvatarVariantName; label: string }[] = [
{ name: "marble", label: "مرمر" },
{ name: "beam", label: "بیم" },
{ name: "pixel", label: "پیکسلی" },
{ name: "sunset", label: "غروب" },
{ name: "ring", label: "حلقه" },
{ name: "bauhaus", label: "باهاوس" },
];
export function avatarPaletteOf(raw?: string): string[] {
if (!raw || !raw.startsWith("[")) return AVATAR_COLORS;
try {
const v = JSON.parse(raw);
if (Array.isArray(v) && v.length >= 2 && v.every((c) => typeof c === "string")) return v.slice(0, 6);
} catch {}
return AVATAR_COLORS;
}
type AvatarOwner = {
avatarVariant?: AvatarVariantName;
avatarPalette?: string;
avatarPhoto?: string;
} | null | undefined;
export function avatarPropsOf(u: AvatarOwner): { src: string | null; variant: AvatarVariantName; palette: string[] } {
return {
src: u?.avatarPhoto ?? null,
variant: u?.avatarVariant ?? "beam",
palette: avatarPaletteOf(u?.avatarPalette),
};
}
export function UserAvatar({
name,
src,
variant = "beam",
palette,
className,
}: {
name: string;
src?: string | null;
variant?: AvatarVariantName;
palette?: string[];
className?: string;
}) {
if (src) {
return (
<Avatar className={cn("ring-1 ring-black/5 dark:ring-white/10", className)}>
<AvatarImage src={src} alt={name} />
<AvatarFallback />
</Avatar>
);
}
return (
<span
className={cn(
"inline-grid shrink-0 place-items-center overflow-hidden rounded-full bg-background ring-1 ring-black/5 dark:ring-white/10",
className
)}
>
<BoringAvatar name={name} variant={variant} colors={palette ?? AVATAR_COLORS} size="100%" title={false} />
</span>
);
}