init studio admin frontend

This commit is contained in:
2026-07-01 17:34:15 +09:00
commit c50bd2b941
64 changed files with 22452 additions and 0 deletions
+176
View File
@@ -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>
);
}
+138
View File
@@ -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;
}
+143
View File
@@ -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>
);
}