first commit
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
const TONES = {
|
||||
gold: "badge-gold",
|
||||
blue: "badge-blue",
|
||||
purple: "badge-purple",
|
||||
green: "badge-green",
|
||||
red: "badge-red",
|
||||
gray: "badge-gray",
|
||||
} as const;
|
||||
|
||||
export type BadgeTone = keyof typeof TONES;
|
||||
|
||||
export default function Badge({
|
||||
tone = "gray",
|
||||
children,
|
||||
}: {
|
||||
tone?: BadgeTone;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <span className={`badge ${TONES[tone]}`}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Save, Trash2, Plus, Loader2 } from "lucide-react";
|
||||
import NumberInput from "./NumberInput";
|
||||
import { toastError, toastSuccess } from "@/lib/toast";
|
||||
|
||||
export type Col = {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: "text" | "number" | "bool";
|
||||
readonly?: boolean;
|
||||
};
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
// سلولِ ورودی — در سطحِ ماژول تعریف شده تا با هر کلید دوباره mount نشود (وگرنه
|
||||
// اینپوت پس از یک حرف فوکوس را از دست میدهد).
|
||||
function Cell({
|
||||
row,
|
||||
col,
|
||||
onChange,
|
||||
}: {
|
||||
row: Row;
|
||||
col: Col;
|
||||
onChange: (v: unknown) => void;
|
||||
}) {
|
||||
const v = row[col.key];
|
||||
if (col.type === "bool") {
|
||||
return (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!v && v !== 0}
|
||||
disabled={col.readonly}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (col.type === "number") {
|
||||
// ورودیِ عددیِ گروهبندیشده (جداکنندهی هزارگان) و قابلِ تایپ با کیبورد.
|
||||
return (
|
||||
<NumberInput
|
||||
style={{ minWidth: 90 }}
|
||||
value={Number(v ?? 0)}
|
||||
disabled={col.readonly}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
className="inp"
|
||||
style={{ minWidth: 110 }}
|
||||
type="text"
|
||||
value={v === null || v === undefined ? "" : String(v)}
|
||||
readOnly={col.readonly}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EditableTable({
|
||||
cols,
|
||||
rows,
|
||||
onSave,
|
||||
onDelete,
|
||||
newTemplate,
|
||||
extra,
|
||||
preview,
|
||||
}: {
|
||||
cols: Col[];
|
||||
rows: Row[];
|
||||
onSave: (row: Row) => Promise<void>;
|
||||
onDelete: (id: string) => Promise<void>;
|
||||
newTemplate: Row;
|
||||
/** ستون/دکمهی اضافی برای هر ردیف (مثلاً آپلود تصویر). */
|
||||
extra?: (row: Row) => React.ReactNode;
|
||||
/** پیشنمایشِ تصویرِ ابتدای ردیف (مثلاً فرش یا کارت). */
|
||||
preview?: (row: Row) => React.ReactNode;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Row>({ ...newTemplate });
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
// نسخهی محلیِ قابلِویرایش هر ردیف.
|
||||
const [edit, setEdit] = useState<Record<string, Row>>({});
|
||||
const rowState = (r: Row) => edit[String(r.id)] ?? r;
|
||||
const patch = (id: string, key: string, val: unknown) =>
|
||||
setEdit((e) => ({ ...e, [id]: { ...(e[id] ?? rows.find((r) => String(r.id) === id)!), [key]: val } }));
|
||||
|
||||
// پیش از ذخیره، هر ستون را به نوعِ اعلامشده تبدیل کن تا سرور عددِ رشتهای نگیرد
|
||||
// (وگرنه 400: bad request). اعداد → number، بولینها → boolean.
|
||||
const coerce = (row: Row): Row => {
|
||||
const out: Row = { ...row };
|
||||
for (const c of cols) {
|
||||
if (c.type === "number") out[c.key] = Number(out[c.key] ?? 0) || 0;
|
||||
else if (c.type === "bool") out[c.key] = !!out[c.key] && out[c.key] !== 0;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card overflow-x-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{preview && <th>پیشنمایش</th>}
|
||||
{cols.map((c) => (
|
||||
<th key={c.key}>{c.label}</th>
|
||||
))}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const id = String(r.id);
|
||||
const rs = rowState(r);
|
||||
return (
|
||||
<tr key={id}>
|
||||
{preview && <td>{preview(r)}</td>}
|
||||
{cols.map((c) => (
|
||||
<td key={c.key}>
|
||||
<Cell row={rs} col={c} onChange={(v) => patch(id, c.key, v)} />
|
||||
</td>
|
||||
))}
|
||||
<td className="flex items-center gap-1">
|
||||
<button
|
||||
className="btn btn-gold"
|
||||
disabled={busy === id}
|
||||
onClick={async () => {
|
||||
setBusy(id);
|
||||
try {
|
||||
await onSave(coerce(rs));
|
||||
toastSuccess("ذخیره شد");
|
||||
} catch (e) {
|
||||
toastError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy === id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Save size={14} />
|
||||
)}
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => onDelete(id)}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
{extra?.(r)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{/* ردیفِ افزودن */}
|
||||
<tr style={{ background: "rgba(233,185,73,.06)" }}>
|
||||
{preview && <td />}
|
||||
{cols.map((c) => (
|
||||
<td key={c.key}>
|
||||
<Cell
|
||||
row={draft}
|
||||
col={{ ...c, readonly: false }}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, [c.key]: v }))}
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
<button
|
||||
className="btn btn-ghost flex items-center gap-1"
|
||||
disabled={busy === "__new__"}
|
||||
onClick={async () => {
|
||||
setBusy("__new__");
|
||||
try {
|
||||
await onSave(coerce(draft));
|
||||
setDraft({ ...newTemplate });
|
||||
toastSuccess("افزوده شد");
|
||||
} catch (e) {
|
||||
toastError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy === "__new__" ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Plus size={14} />
|
||||
)}{" "}
|
||||
افزودن
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { Inbox } from "lucide-react";
|
||||
|
||||
export default function Empty({
|
||||
title = "چیزی پیدا نشد",
|
||||
hint,
|
||||
}: {
|
||||
title?: string;
|
||||
hint?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="card flex flex-col items-center justify-center gap-3 py-14 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl grid place-items-center bg-[rgba(255,255,255,.05)]">
|
||||
<Inbox size={26} className="text-[#5a7aa8]" />
|
||||
</div>
|
||||
<div className="font-bold text-[#a9c2e4]">{title}</div>
|
||||
{hint && <div className="text-xs text-[#6f90bd]">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** عددِ گروهبندیشده با جداکنندهی هزارگان (مثلِ 12,500). */
|
||||
export function fmtNum(n: number | null | undefined): string {
|
||||
const v = Number(n ?? 0);
|
||||
return Number.isFinite(v) ? v.toLocaleString("en-US") : "0";
|
||||
}
|
||||
|
||||
/**
|
||||
* ورودیِ عددی با جداکنندهی هزارگان که با کیبورد هم قابلِ تایپ است.
|
||||
* برخلافِ <input type="number"> که جداکننده را نمیپذیرد، این یک ورودیِ متنی است
|
||||
* که هنگامِ تایپ زنده گروهبندی میکند و مقدارِ عددی را به onChange میدهد.
|
||||
*/
|
||||
export default function NumberInput({
|
||||
value,
|
||||
onChange,
|
||||
allowNegative = false,
|
||||
className = "inp",
|
||||
style,
|
||||
disabled,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (n: number) => void;
|
||||
allowNegative?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const show = (n: number) => n.toLocaleString("en-US");
|
||||
const [text, setText] = useState(show(value));
|
||||
|
||||
// اگر مقدار از بیرون عوض شد (مثلاً ریست فرم)، متن را همگام کن.
|
||||
useEffect(() => {
|
||||
const cur = parseInt(text.replace(/[^\d-]/g, "") || "0", 10) || 0;
|
||||
if (cur !== value) setText(show(value));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<input
|
||||
className={className}
|
||||
style={style}
|
||||
type="text"
|
||||
inputMode={allowNegative ? "text" : "numeric"}
|
||||
dir="ltr"
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
let raw = e.target.value.replace(/[^\d-]/g, "");
|
||||
if (!allowNegative) raw = raw.replace(/-/g, "");
|
||||
raw = raw.replace(/(?!^)-/g, ""); // فقط منفیِ ابتدایی
|
||||
const n = raw === "" || raw === "-" ? 0 : parseInt(raw, 10);
|
||||
const grouped =
|
||||
raw === "" ? "" : raw === "-" ? "-" : (n || 0).toLocaleString("en-US");
|
||||
setText(grouped);
|
||||
onChange(Number.isNaN(n) ? 0 : n);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { fmtNum } from "@/components/NumberInput";
|
||||
|
||||
// نمودارِ درآمدِ ۱۴ روزِ گذشته — فقط CSS (بدون کتابخانه). میلههای طلایی با
|
||||
// نسبتِ به بیشترین مقدار؛ برچسبِ روز زیرِ هر میله و tooltipِ بومی روی هرکدام.
|
||||
export default function RevenueChart({
|
||||
days,
|
||||
}: {
|
||||
days: { date: string; revenue: number; count: number }[];
|
||||
}) {
|
||||
if (!days.length) return null;
|
||||
const max = Math.max(...days.map((d) => d.revenue), 1);
|
||||
return (
|
||||
<div dir="ltr" role="img" aria-label="نمودار درآمدِ ۱۴ روز گذشته">
|
||||
<div className="flex items-end gap-1.5 h-28">
|
||||
{days.map((d) => {
|
||||
const pct = Math.round((d.revenue / max) * 100);
|
||||
return (
|
||||
<div
|
||||
key={d.date}
|
||||
title={`${d.date} · ${fmtNum(d.revenue)} تومان · ${d.count} خرید`}
|
||||
className="flex-1 rounded-t bg-gradient-to-t from-[var(--gold-dark)] to-[var(--gold)] transition hover:brightness-110"
|
||||
style={{ height: `${Math.max(pct, d.revenue > 0 ? 3 : 0)}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
{days.map((d) => (
|
||||
<span
|
||||
key={d.date}
|
||||
className="flex-1 text-center text-[9px] text-[#6f90bd] leading-none"
|
||||
>
|
||||
{d.date.slice(5)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Trophy,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
ReceiptText,
|
||||
Image as ImageIcon,
|
||||
Frame as FrameIcon,
|
||||
Smile,
|
||||
Medal,
|
||||
MessageSquare,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { clearAuth, isAuthed } from "@/lib/api";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "داشبورد", icon: LayoutDashboard },
|
||||
{ href: "/tournaments", label: "تورنومنتها", icon: Trophy },
|
||||
{ href: "/shop", label: "فروشگاه", icon: ShoppingBag },
|
||||
{ href: "/users", label: "کاربران", icon: Users },
|
||||
{ href: "/transactions", label: "تراکنشها", icon: ReceiptText },
|
||||
{ href: "/carpets", label: "فرشها", icon: ImageIcon },
|
||||
{ href: "/frames", label: "قابها", icon: FrameIcon },
|
||||
{ href: "/avatars", label: "شخصیتها", icon: Smile },
|
||||
{ href: "/rank-tiers", label: "رتبهها", icon: Medal },
|
||||
{ href: "/chat", label: "چت", icon: MessageSquare },
|
||||
];
|
||||
|
||||
export default function Shell({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [ready, setReady] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthed()) {
|
||||
router.replace("/login");
|
||||
} else {
|
||||
setReady(true);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// با تغییرِ مسیر، منوی موبایل را ببند.
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center">
|
||||
<div className="flex flex-col items-center gap-3 text-[var(--gold)]">
|
||||
<Loader2 size={28} className="animate-spin" />
|
||||
<span className="text-sm">در حال بارگذاری…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// basePath را حذف و اسلشِ انتهایی را نرمال میکنیم تا مسیرِ فعال (مثلِ
|
||||
// /transactions و /transactions/ برابر) درست تشخیص داده شود.
|
||||
const cur = (pathname.replace(/^\/panel/, "") || "/").replace(/\/+$/, "") || "/";
|
||||
|
||||
const aside = (
|
||||
<aside className="w-56 shrink-0 border-l border-[#16305a] bg-[var(--bg)] flex flex-col h-full">
|
||||
<div className="px-5 py-5 flex items-center justify-between border-b border-[#16305a]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="text-[var(--gold)]" size={22} />
|
||||
<span className="font-bold text-[var(--gold)]">حکمشو</span>
|
||||
</div>
|
||||
<button
|
||||
className="lg:hidden text-[#a9c2e4] hover:text-[var(--gold)]"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="بستن منو"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex-1 p-3 space-y-1 overflow-y-auto">
|
||||
{NAV.map((n) => {
|
||||
const active = cur === n.href;
|
||||
const Icon = n.icon;
|
||||
return (
|
||||
<Link
|
||||
key={n.href}
|
||||
href={n.href}
|
||||
className={`relative flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm transition ${
|
||||
active
|
||||
? "bg-[var(--panel)] text-[var(--gold)] font-bold"
|
||||
: "text-[#a9c2e4] hover:bg-[var(--panel-2)]"
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<span className="absolute right-0 top-1/2 -translate-y-1/2 w-1 h-6 rounded-full bg-[var(--gold)]" />
|
||||
)}
|
||||
<Icon size={18} />
|
||||
{n.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearAuth();
|
||||
router.replace("/login");
|
||||
}}
|
||||
className="m-3 flex items-center gap-2 px-3 py-2.5 rounded-xl text-sm text-[#f0a0a0] hover:bg-[var(--panel-2)]"
|
||||
>
|
||||
<LogOut size={18} /> خروج
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* دسکتاپ: سایدبارِ ثابت کنارِ محتوا */}
|
||||
<div className="hidden lg:block shrink-0">{aside}</div>
|
||||
|
||||
{/* موبایل: منوی کشویی با پسزمینهی تیره */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`fixed inset-y-0 right-0 z-40 w-56 transition-transform duration-200 lg:hidden ${
|
||||
open ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
>
|
||||
{aside}
|
||||
</div>
|
||||
|
||||
<main className="flex-1 min-w-0">
|
||||
<header className="px-6 py-4 border-b border-[#16305a] bg-[var(--bg)]/60 backdrop-blur sticky top-0 z-10 flex items-center gap-3">
|
||||
<button
|
||||
className="lg:hidden text-[#a9c2e4] hover:text-[var(--gold)]"
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="باز کردن منو"
|
||||
>
|
||||
<Menu size={22} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">{title}</h1>
|
||||
{subtitle && <p className="text-xs text-[#8ab4e8] mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
export function Skeleton({ className = "" }: { className?: string }) {
|
||||
return <div className={`skeleton ${className}`} />;
|
||||
}
|
||||
|
||||
// اسکلتِ جدولِ در حالِ لود — با سطر/ستونِ دلخواه.
|
||||
export function TableSkeleton({
|
||||
rows = 5,
|
||||
cols = 5,
|
||||
}: {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="card p-2 overflow-hidden">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
{Array.from({ length: cols }).map((_, i) => (
|
||||
<th key={i}>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, r) => (
|
||||
<tr key={r}>
|
||||
{Array.from({ length: cols }).map((_, c) => (
|
||||
<td key={c}>
|
||||
<Skeleton className="h-3.5 w-24" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
// کارتِ آماریِ سربرگ — مقدارِ عددی LTR، واحد (مثلاً «تومان») جدا و RTL.
|
||||
export default function StatCard({
|
||||
label,
|
||||
value,
|
||||
suffix,
|
||||
icon,
|
||||
color = "var(--gold)",
|
||||
sub,
|
||||
loading,
|
||||
}: {
|
||||
label: string;
|
||||
value?: React.ReactNode;
|
||||
suffix?: string;
|
||||
icon?: React.ReactNode;
|
||||
color?: string;
|
||||
sub?: React.ReactNode;
|
||||
loading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="card p-4 flex flex-col gap-2 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-[#8ab4e8]">{label}</span>
|
||||
{icon && <span style={{ color }}>{icon}</span>}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="skeleton h-7 w-28" />
|
||||
) : (
|
||||
<div className="flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-xl font-bold" style={{ color }}>
|
||||
<span dir="ltr">{value ?? "—"}</span>
|
||||
</span>
|
||||
{suffix && <span className="text-[11px] text-[#8ab4e8]">{suffix}</span>}
|
||||
</div>
|
||||
)}
|
||||
{sub && <div className="text-[11px] text-[#6f90bd]">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ImageOff } from "lucide-react";
|
||||
|
||||
/** پیشنمایشِ تصویر با جایگزینِ خطا. اگر src بارگذاری نشود، آیکنِ «بدون تصویر». */
|
||||
export default function Thumb({
|
||||
src,
|
||||
alt = "",
|
||||
size = 44,
|
||||
rounded = 8,
|
||||
}: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
rounded?: number;
|
||||
}) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
if (failed) {
|
||||
return (
|
||||
<div
|
||||
className="grid place-items-center text-[#4a6ea5] bg-[var(--panel-2)]"
|
||||
style={{ width: size, height: size, borderRadius: rounded }}
|
||||
>
|
||||
<ImageOff size={size * 0.45} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onError={() => setFailed(true)}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: rounded,
|
||||
objectFit: "cover",
|
||||
border: "1px solid #1e3a63",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AlertCircle, CheckCircle2, Info } from "lucide-react";
|
||||
import { subscribe, ToastItem } from "@/lib/toast";
|
||||
|
||||
const STYLE: Record<
|
||||
ToastItem["type"],
|
||||
{ border: string; icon: React.ReactNode }
|
||||
> = {
|
||||
error: { border: "#e5484d", icon: <AlertCircle size={18} color="#ff8a8d" /> },
|
||||
success: { border: "#3fa34d", icon: <CheckCircle2 size={18} color="#7be58a" /> },
|
||||
info: { border: "#e9b949", icon: <Info size={18} color="#e9b949" /> },
|
||||
};
|
||||
|
||||
export default function Toaster() {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
useEffect(() => subscribe(setItems), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 20,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 9999,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
alignItems: "center",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{items.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{
|
||||
pointerEvents: "auto",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
maxWidth: "min(90vw, 460px)",
|
||||
padding: "12px 16px",
|
||||
borderRadius: 12,
|
||||
background: "linear-gradient(180deg, #17345c, #0c2848)",
|
||||
border: `1px solid ${STYLE[t.type].border}`,
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,.5)",
|
||||
color: "#eaf0f8",
|
||||
fontSize: 14,
|
||||
animation: "toastIn .18s ease-out",
|
||||
}}
|
||||
>
|
||||
{STYLE[t.type].icon}
|
||||
<span style={{ flex: 1 }}>{t.msg}</span>
|
||||
</div>
|
||||
))}
|
||||
<style>{`@keyframes toastIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user