init studio admin frontend
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
|
||||
import { Button, Card } from './ui';
|
||||
|
||||
export type DataTableColumn<T> = {
|
||||
key: string;
|
||||
header: string;
|
||||
className?: string;
|
||||
cell: (row: T) => ReactNode;
|
||||
};
|
||||
|
||||
export type DataTableFilter<T> = {
|
||||
key: string;
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
getValue: (row: T) => string;
|
||||
};
|
||||
|
||||
export function DataTableCard<T>({
|
||||
title,
|
||||
description,
|
||||
rows,
|
||||
columns,
|
||||
filters = [],
|
||||
searchPlaceholder = '검색',
|
||||
getSearchText,
|
||||
getRowKey,
|
||||
pageSize = 8,
|
||||
minWidth = 980,
|
||||
loading = false,
|
||||
emptyMessage = '데이터가 없습니다.'
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
rows: T[];
|
||||
columns: Array<DataTableColumn<T>>;
|
||||
filters?: Array<DataTableFilter<T>>;
|
||||
searchPlaceholder?: string;
|
||||
getSearchText: (row: T) => string;
|
||||
getRowKey: (row: T) => string;
|
||||
pageSize?: number;
|
||||
minWidth?: number;
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const normalizedSearch = search.trim().toLowerCase();
|
||||
|
||||
return rows.filter((row) => {
|
||||
const matchesSearch = normalizedSearch
|
||||
? getSearchText(row).toLowerCase().includes(normalizedSearch)
|
||||
: true;
|
||||
const matchesFilters = filters.every((filter) => {
|
||||
const selected = filterValues[filter.key];
|
||||
return !selected || filter.getValue(row) === selected;
|
||||
});
|
||||
|
||||
return matchesSearch && matchesFilters;
|
||||
});
|
||||
}, [filterValues, filters, getSearchText, rows, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize));
|
||||
const currentPage = Math.min(page, totalPages);
|
||||
const pageRows = filteredRows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
|
||||
const updateFilter = (key: string, value: string) => {
|
||||
setFilterValues((prev) => ({ ...prev, [key]: value }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<div className="flex flex-col gap-3 border-b border-border px-5 py-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">{title}</h2>
|
||||
{description ? <p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="relative w-full max-w-md lg:w-80">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
className="h-9 w-full rounded-md border border-input bg-background pl-9 pr-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-2 focus:ring-ring"
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{filters.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.map((filter) => (
|
||||
<label key={filter.key} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{filter.label}
|
||||
<select
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground outline-none focus:ring-2 focus:ring-ring"
|
||||
value={filterValues[filter.key] ?? ''}
|
||||
onChange={(event) => updateFilter(filter.key, event.target.value)}
|
||||
>
|
||||
<option value="">전체</option>
|
||||
{filter.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full text-left text-sm" style={{ minWidth }}>
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th key={column.key} className={column.className ?? 'px-5 py-3 font-medium'}>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{!loading && pageRows.map((row) => (
|
||||
<tr key={getRowKey(row)} className="hover:bg-muted/20">
|
||||
{columns.map((column) => (
|
||||
<td key={column.key} className="px-5 py-3">
|
||||
{column.cell(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{!loading && pageRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="h-44 px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t border-border px-5 py-3 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loading ? '불러오는 중' : `총 ${filteredRows.length}건 · ${currentPage} / ${totalPages}`}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-3 text-[13px]"
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
다음
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode
|
||||
} from 'react';
|
||||
import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||
|
||||
type ToastInput = {
|
||||
type?: ToastType;
|
||||
title?: string;
|
||||
message: string;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
type ToastItem = Required<Omit<ToastInput, 'duration'>> & {
|
||||
id: string;
|
||||
};
|
||||
|
||||
type ToastContextValue = {
|
||||
toast: (input: ToastInput) => string;
|
||||
dismissToast: (id: string) => void;
|
||||
};
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null);
|
||||
|
||||
const toastStyles: Record<ToastType, { icon: ReactNode; className: string; iconClassName: string }> = {
|
||||
success: {
|
||||
icon: <CheckCircle2 className="size-4" />,
|
||||
className: 'border-emerald-500/25 bg-emerald-950/85',
|
||||
iconClassName: 'text-emerald-300'
|
||||
},
|
||||
error: {
|
||||
icon: <XCircle className="size-4" />,
|
||||
className: 'border-rose-500/25 bg-rose-950/85',
|
||||
iconClassName: 'text-rose-300'
|
||||
},
|
||||
info: {
|
||||
icon: <Info className="size-4" />,
|
||||
className: 'border-sky-500/25 bg-sky-950/85',
|
||||
iconClassName: 'text-sky-300'
|
||||
},
|
||||
warning: {
|
||||
icon: <AlertTriangle className="size-4" />,
|
||||
className: 'border-amber-500/25 bg-amber-950/85',
|
||||
iconClassName: 'text-amber-300'
|
||||
}
|
||||
};
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const timers = useRef<Map<string, number>>(new Map());
|
||||
|
||||
const dismissToast = useCallback((id: string) => {
|
||||
const timer = timers.current.get(id);
|
||||
if (timer) window.clearTimeout(timer);
|
||||
timers.current.delete(id);
|
||||
setToasts((prev) => prev.filter((toastItem) => toastItem.id !== id));
|
||||
}, []);
|
||||
|
||||
const toast = useCallback(
|
||||
({ type = 'info', title = '', message, duration = 3500 }: ToastInput) => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const toastItem: ToastItem = { id, type, title, message };
|
||||
|
||||
setToasts((prev) => [toastItem, ...prev].slice(0, 4));
|
||||
|
||||
if (duration > 0) {
|
||||
const timer = window.setTimeout(() => dismissToast(id), duration);
|
||||
timers.current.set(id, timer);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
[dismissToast]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
timers.current.forEach((timer) => window.clearTimeout(timer));
|
||||
timers.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(() => ({ toast, dismissToast }), [toast, dismissToast]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-[80] flex w-[min(calc(100vw-2rem),380px)] flex-col gap-2">
|
||||
{toasts.map((toastItem) => {
|
||||
const style = toastStyles[toastItem.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toastItem.id}
|
||||
className={cn(
|
||||
'toast-enter pointer-events-auto flex gap-3 rounded-lg border px-4 py-3 text-[14px] text-foreground shadow-2xl shadow-black/30 backdrop-blur-md',
|
||||
style.className
|
||||
)}
|
||||
>
|
||||
<div className={cn('mt-1 shrink-0', style.iconClassName)}>{style.icon}</div>
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{toastItem.title ? <p className="text-sm font-bold leading-5">{toastItem.title}</p> : null}
|
||||
<p className="break-words text-[13px] leading-5 text-muted-foreground">{toastItem.message}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
|
||||
aria-label="토스트 닫기"
|
||||
onClick={() => dismissToast(toastItem.id)}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useToast는 ToastProvider 내부에서 사용해야 합니다.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { X } from 'lucide-react';
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <section className={cn('rounded-lg border border-border bg-card shadow-sm', className)}>{children}</section>;
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
className
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-4 border-b border-border px-5 py-4', className)}>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">{title}</h2>
|
||||
{description ? <p className="mt-1 text-xs leading-5 text-muted-foreground">{description}</p> : null}
|
||||
</div>
|
||||
{action ? <div className="shrink-0">{action}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'default',
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
children: ReactNode;
|
||||
variant?: 'default' | 'secondary' | 'ghost' | 'outline';
|
||||
className?: string;
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const variants = {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
outline: 'border border-border bg-background hover:bg-accent hover:text-accent-foreground'
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
className={cn(
|
||||
'ui-button inline-flex h-8 items-center justify-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-45',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = 'neutral'
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: 'neutral' | 'green' | 'amber' | 'red' | 'blue' | 'violet';
|
||||
}) {
|
||||
const tones = {
|
||||
neutral: 'border-border bg-muted text-muted-foreground',
|
||||
green: 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300',
|
||||
amber: 'border-amber-500/30 bg-amber-500/10 text-amber-300',
|
||||
red: 'border-rose-500/30 bg-rose-500/10 text-rose-300',
|
||||
blue: 'border-sky-500/30 bg-sky-500/10 text-sky-300',
|
||||
violet: 'border-violet-500/30 bg-violet-500/10 text-violet-300'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium', tones[tone])}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Progress({ value, tone = 'primary' }: { value: number; tone?: 'primary' | 'green' | 'amber' | 'red' }) {
|
||||
const tones = {
|
||||
primary: 'bg-primary',
|
||||
green: 'bg-emerald-400',
|
||||
amber: 'bg-amber-400',
|
||||
red: 'bg-rose-400'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div className={cn('h-full rounded-full', tones[tone])} style={{ width: `${Math.min(value, 100)}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
onClose,
|
||||
className
|
||||
}: {
|
||||
open: boolean;
|
||||
title: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 py-8 backdrop-blur-[2px]">
|
||||
<div className={cn('w-full max-w-lg overflow-hidden rounded-lg border border-border bg-card shadow-xl', className)}>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[70vh] overflow-y-auto px-5 py-5">{children}</div>
|
||||
{footer ? <div className="flex justify-end gap-2 border-t border-border px-5 py-4">{footer}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user