feat: initial
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, EmptyState, Spinner } from "./ui";
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (row: T) => ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DataTable<T extends { id?: number | string }>({
|
||||
columns,
|
||||
rows,
|
||||
loading,
|
||||
error,
|
||||
emptyTitle = "موردی ثبت نشده است",
|
||||
emptyMessage = "هنوز دادهای برای نمایش وجود ندارد.",
|
||||
emptyIcon,
|
||||
emptyAction,
|
||||
onRetry,
|
||||
actions,
|
||||
}: {
|
||||
columns: Column<T>[];
|
||||
rows: T[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
emptyTitle?: string;
|
||||
emptyMessage?: string;
|
||||
emptyIcon?: ReactNode;
|
||||
emptyAction?: ReactNode;
|
||||
onRetry?: () => void;
|
||||
actions?: (row: T) => ReactNode;
|
||||
}) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spinner className="h-8 w-8" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="⚠️"
|
||||
title="خطا در دریافت اطلاعات"
|
||||
message={error}
|
||||
action={
|
||||
onRetry ? (
|
||||
<Button variant="secondary" onClick={onRetry}>
|
||||
تلاش دوباره
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!rows.length)
|
||||
return (
|
||||
<EmptyState
|
||||
title={emptyTitle}
|
||||
message={emptyMessage}
|
||||
icon={emptyIcon}
|
||||
action={emptyAction}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-2xl border border-border bg-surface">
|
||||
<table className="w-full text-right text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-muted text-muted">
|
||||
{columns.map((c) => (
|
||||
<th key={c.key} className={cn("px-4 py-3 font-medium", c.className)}>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
{actions && <th className="px-4 py-3 font-medium">عملیات</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr
|
||||
key={row.id ?? i}
|
||||
className="border-b border-border last:border-0 hover:bg-surface-muted/50"
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td key={c.key} className={cn("px-4 py-3", c.className)}>
|
||||
{c.render
|
||||
? c.render(row)
|
||||
: ((row as Record<string, unknown>)[c.key] as ReactNode) ??
|
||||
"—"}
|
||||
</td>
|
||||
))}
|
||||
{actions && (
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">{actions(row)}</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
// Press-to-play / press-to-view preview for uploaded assets.
|
||||
// - audio: a play button that toggles a compact inline <audio> player.
|
||||
// - video: a button that opens the video in a modal player.
|
||||
// - image: a thumbnail that opens the full image in a modal.
|
||||
// Renders a muted dash when no source is available.
|
||||
|
||||
import { useState } from "react";
|
||||
import { Modal } from "./ui";
|
||||
import { PlayIcon, PauseIcon, EyeIcon } from "./icons";
|
||||
|
||||
type Kind = "audio" | "video" | "image";
|
||||
|
||||
export function MediaPreview({
|
||||
src,
|
||||
kind,
|
||||
label,
|
||||
}: {
|
||||
src?: string | null;
|
||||
kind: Kind;
|
||||
label?: string;
|
||||
}) {
|
||||
const [audioOpen, setAudioOpen] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
if (!src) return <span className="text-muted">—</span>;
|
||||
|
||||
if (kind === "audio") {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAudioOpen((v) => !v)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary-soft text-primary transition hover:bg-primary hover:text-white"
|
||||
aria-label={audioOpen ? "توقف" : "پخش"}
|
||||
>
|
||||
{audioOpen ? (
|
||||
<PauseIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{audioOpen && (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<audio
|
||||
src={src}
|
||||
controls
|
||||
autoPlay
|
||||
className="h-8 max-w-[220px]"
|
||||
onEnded={() => setAudioOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="block overflow-hidden rounded-lg border border-border transition hover:opacity-80"
|
||||
aria-label="نمایش تصویر"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt={label ?? ""} className="h-12 w-12 object-cover" />
|
||||
</button>
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={label ?? "تصویر"}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={src} alt={label ?? ""} className="mx-auto max-h-[70vh] rounded-lg" />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// video
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-primary-soft text-primary transition hover:bg-primary hover:text-white"
|
||||
aria-label="نمایش ویدیو"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
title={label ?? "ویدیو"}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video src={src} controls autoPlay className="w-full rounded-lg" />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { AuthProvider } from "@/lib/auth";
|
||||
import { ToastProvider } from "./toast";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { NAV } from "@/lib/nav";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="flex h-full flex-col gap-1 overflow-y-auto p-4">
|
||||
<div className="mb-4 flex items-center gap-2 px-2">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-soft text-xl">
|
||||
🧘
|
||||
</span>
|
||||
<span className="font-bold text-foreground">مدیریت مدیتیشن</span>
|
||||
</div>
|
||||
|
||||
{NAV.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<div key={section.label} className="mb-1">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-xs font-semibold text-muted">
|
||||
<Icon className="h-4 w-4" />
|
||||
{section.label}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{section.items.map((item) => {
|
||||
const active =
|
||||
pathname === item.href ||
|
||||
(item.href !== "/dashboard" &&
|
||||
pathname?.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={onNavigate}
|
||||
className={cn(
|
||||
"mr-6 rounded-lg px-3 py-2 text-sm transition",
|
||||
active
|
||||
? "bg-primary text-white"
|
||||
: "text-foreground hover:bg-surface-muted",
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Minimal inline icon set (no external dependency). Each icon inherits color
|
||||
// via `currentColor` and accepts standard svg props.
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement>;
|
||||
|
||||
function base(props: IconProps) {
|
||||
return {
|
||||
width: 20,
|
||||
height: 20,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
||||
export const PlusIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const EditIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 20h9" />
|
||||
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TrashIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2m2 0v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CloseIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PlayIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M7 4.5v15l12-7.5-12-7.5Z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const PauseIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M8 5v14M16 5v14" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const EyeIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SearchIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const LogoutIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="M16 17l5-5-5-5M21 12H9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const MenuIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M3 12h18M3 6h18M3 18h18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const SpinnerIcon = (p: IconProps) => (
|
||||
<svg {...base(p)} className={`animate-spin ${p.className ?? ""}`}>
|
||||
<path d="M21 12a9 9 0 1 1-6.2-8.6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Section icons (single-path, decorative).
|
||||
export const HomeIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M3 10.5 12 3l9 7.5" />
|
||||
<path d="M5 9.5V21h14V9.5" />
|
||||
</svg>
|
||||
);
|
||||
export const MediaIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="m10 8 6 4-6 4V8Z" />
|
||||
</svg>
|
||||
);
|
||||
export const MusicIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M9 18V5l12-2v13" />
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
);
|
||||
export const TimerIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="12" cy="13" r="8" />
|
||||
<path d="M12 9v4l2 2M9 2h6" />
|
||||
</svg>
|
||||
);
|
||||
export const ImageIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-5-5L5 21" />
|
||||
</svg>
|
||||
);
|
||||
export const SliderIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<rect x="2" y="6" width="20" height="12" rx="2" />
|
||||
<path d="M6 2v2M18 2v2M6 20v2M18 20v2" />
|
||||
</svg>
|
||||
);
|
||||
export const QuestionIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9.5 9a2.5 2.5 0 1 1 3.5 2.3c-.8.4-1 .9-1 1.7M12 17h.01" />
|
||||
</svg>
|
||||
);
|
||||
export const SurveyIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M9 11l3 3 8-8" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
);
|
||||
export const BreathIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
);
|
||||
export const MoodIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01" />
|
||||
</svg>
|
||||
);
|
||||
export const WorryIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M3 7h18l-2 13H5L3 7Z" />
|
||||
<path d="M8 7V5a4 4 0 0 1 8 0v2" />
|
||||
</svg>
|
||||
);
|
||||
export const SceneIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="m3 17 5-6 4 4 3-4 6 6" />
|
||||
<circle cx="8" cy="7" r="2" />
|
||||
</svg>
|
||||
);
|
||||
export const TrophyIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M8 21h8M12 17v4M7 4h10v5a5 5 0 0 1-10 0V4Z" />
|
||||
<path d="M17 5h3v2a3 3 0 0 1-3 3M7 5H4v2a3 3 0 0 0 3 3" />
|
||||
</svg>
|
||||
);
|
||||
export const TagIcon = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M3 12V5a2 2 0 0 1 2-2h7l9 9-9 9-9-9Z" />
|
||||
<circle cx="7.5" cy="7.5" r="1.2" />
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ToastKind = "success" | "error" | "info";
|
||||
interface Toast {
|
||||
id: number;
|
||||
kind: ToastKind;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ToastApi {
|
||||
push: (message: string, kind?: ToastKind) => void;
|
||||
success: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastApi | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const idRef = useRef(0);
|
||||
|
||||
const push = useCallback((message: string, kind: ToastKind = "info") => {
|
||||
const id = ++idRef.current;
|
||||
setToasts((prev) => [...prev, { id, kind, message }]);
|
||||
setTimeout(() => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, 4000);
|
||||
}, []);
|
||||
|
||||
const api: ToastApi = {
|
||||
push,
|
||||
success: (m) => push(m, "success"),
|
||||
error: (m) => push(m, "error"),
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={api}>
|
||||
{children}
|
||||
<div className="fixed bottom-4 left-4 z-[100] flex flex-col gap-2">
|
||||
{toasts.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"min-w-64 rounded-xl px-4 py-3 text-sm text-white shadow-lg",
|
||||
t.kind === "success" && "bg-success",
|
||||
t.kind === "error" && "bg-danger",
|
||||
t.kind === "info" && "bg-foreground",
|
||||
)}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error("useToast must be used within <ToastProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
type InputHTMLAttributes,
|
||||
type ReactNode,
|
||||
type SelectHTMLAttributes,
|
||||
type TextareaHTMLAttributes,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CloseIcon, SpinnerIcon } from "./icons";
|
||||
|
||||
/* ------------------------------- Button -------------------------------- */
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
const buttonVariants: Record<ButtonVariant, string> = {
|
||||
primary: "bg-primary text-white hover:bg-primary-hover",
|
||||
secondary:
|
||||
"bg-surface-muted text-foreground hover:bg-[#e2e6f2] border border-border",
|
||||
danger: "bg-danger text-white hover:opacity-90",
|
||||
ghost: "text-muted hover:bg-surface-muted",
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
loading,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60",
|
||||
buttonVariants[variant],
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <SpinnerIcon className="h-4 w-4" /> : icon}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------- Fields -------------------------------- */
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{label}
|
||||
{required && <span className="text-danger"> *</span>}
|
||||
</span>
|
||||
{children}
|
||||
{hint && <span className="text-xs text-muted">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const fieldClass =
|
||||
"w-full rounded-xl border border-border bg-surface px-3 py-2 text-sm outline-none transition focus:border-primary focus:ring-4 focus:ring-[var(--ring)] disabled:opacity-60";
|
||||
|
||||
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...props} className={cn(fieldClass, props.className)} />;
|
||||
}
|
||||
|
||||
export function Textarea(props: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return (
|
||||
<textarea
|
||||
rows={3}
|
||||
{...props}
|
||||
className={cn(fieldClass, "resize-y", props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return <select {...props} className={cn(fieldClass, props.className)} />;
|
||||
}
|
||||
|
||||
export function Switch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className="inline-flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"relative h-6 w-11 rounded-full transition",
|
||||
checked ? "bg-primary" : "bg-[#cdd3e6]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all",
|
||||
checked ? "left-0.5" : "right-0.5",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ----------------------------- Containers ------------------------------ */
|
||||
export function Card({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-surface p-5 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-foreground">{title}</h1>
|
||||
{subtitle && <p className="mt-1 text-sm text-muted">{subtitle}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "neutral" | "success" | "danger" | "primary";
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
|
||||
tone === "neutral" && "bg-surface-muted text-muted",
|
||||
tone === "success" && "bg-[#e3f6ee] text-success",
|
||||
tone === "danger" && "bg-[#fbe7ea] text-danger",
|
||||
tone === "primary" && "bg-primary-soft text-primary",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
message,
|
||||
icon,
|
||||
action,
|
||||
}: {
|
||||
title?: string;
|
||||
message: string;
|
||||
icon?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
||||
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-surface-muted text-3xl">
|
||||
{icon ?? "🌙"}
|
||||
</span>
|
||||
{title && <p className="font-semibold text-foreground">{title}</p>}
|
||||
<p className="max-w-sm text-sm text-muted">{message}</p>
|
||||
{action && <div className="mt-1">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner({ className }: { className?: string }) {
|
||||
return <SpinnerIcon className={cn("h-6 w-6 text-primary", className)} />;
|
||||
}
|
||||
|
||||
/* ------------------------------- Modal --------------------------------- */
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div className="relative z-10 flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-2xl bg-surface shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="font-bold text-foreground">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1 text-muted hover:bg-surface-muted"
|
||||
aria-label="بستن"
|
||||
>
|
||||
<CloseIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-5 py-4">{children}</div>
|
||||
{footer && (
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title = "حذف مورد",
|
||||
message,
|
||||
confirmText = "حذف",
|
||||
loading,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
loading?: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button variant="danger" loading={loading} onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-foreground">{message}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user