init studio admin frontend
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
import { MoreHorizontal, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DataTableCard, type DataTableColumn, type DataTableFilter } from '../../components/data-table';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { Button, Modal } from '../../components/ui';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { formatDateTime } from '../../lib/date-format';
|
||||
import { listApps } from '../applications/application-api';
|
||||
import {
|
||||
createLauncherNotice,
|
||||
deleteLauncherNotice,
|
||||
loadLauncherNotices,
|
||||
updateLauncherNotice,
|
||||
type LauncherNotice
|
||||
} from './operations-api';
|
||||
import { LoadingModal, StatusBadge, useMinimumLoading } from './operation-utils';
|
||||
import type { AppWithComponents } from '../../types/application';
|
||||
|
||||
type NoticeForm = {
|
||||
appKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
linkUrl: string;
|
||||
startsAt: string;
|
||||
endsAt: string;
|
||||
priority: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
const emptyForm: NoticeForm = {
|
||||
appKey: 'launcher',
|
||||
title: '',
|
||||
body: '',
|
||||
linkUrl: '',
|
||||
startsAt: '',
|
||||
endsAt: '',
|
||||
priority: '0',
|
||||
isActive: true
|
||||
};
|
||||
|
||||
function formatNoticeDateTime(value?: string | null) {
|
||||
return formatDateTime(value);
|
||||
}
|
||||
|
||||
function toDateTimeInputValue(value?: string | null) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
const hh = String(date.getHours()).padStart(2, '0');
|
||||
const mi = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`;
|
||||
}
|
||||
|
||||
function toApiDateTime(value: string) {
|
||||
return value ? new Date(value).toISOString() : undefined;
|
||||
}
|
||||
|
||||
function exposureText(row: LauncherNotice) {
|
||||
if (!row.startsAt && !row.endsAt) return '상시';
|
||||
if (row.startsAt && row.endsAt) return `${formatNoticeDateTime(row.startsAt)} ~ ${formatNoticeDateTime(row.endsAt)}`;
|
||||
if (row.startsAt) return `${formatNoticeDateTime(row.startsAt)}부터`;
|
||||
return `${formatNoticeDateTime(row.endsAt)}까지`;
|
||||
}
|
||||
|
||||
function toForm(notice: LauncherNotice): NoticeForm {
|
||||
return {
|
||||
appKey: notice.appKey,
|
||||
title: notice.title,
|
||||
body: notice.body,
|
||||
linkUrl: notice.linkUrl ?? '',
|
||||
startsAt: toDateTimeInputValue(notice.startsAt),
|
||||
endsAt: toDateTimeInputValue(notice.endsAt),
|
||||
priority: String(notice.priority ?? 0),
|
||||
isActive: notice.isActive
|
||||
};
|
||||
}
|
||||
|
||||
function isLauncherNoticeKey(appKey: string) {
|
||||
return appKey.toLowerCase() === 'launcher' || appKey.toLowerCase() === 'login';
|
||||
}
|
||||
|
||||
export function NoticesPage() {
|
||||
const { toast } = useToast();
|
||||
const [notices, setNotices] = useState<LauncherNotice[]>([]);
|
||||
const [apps, setApps] = useState<AppWithComponents[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<LauncherNotice | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<LauncherNotice | null>(null);
|
||||
const [menuNotice, setMenuNotice] = useState<{ id: string; x: number; y: number } | null>(null);
|
||||
const [form, setForm] = useState<NoticeForm>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
loadLauncherNotices(),
|
||||
listApps().catch(() => [])
|
||||
])
|
||||
.then(([items, appItems]) => {
|
||||
setNotices(items);
|
||||
setApps(appItems);
|
||||
setFailed(false);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
setNotices([]);
|
||||
setFailed(true);
|
||||
toast({ type: 'error', title: '공지사항 조회 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuNotice) return;
|
||||
|
||||
const close = () => setMenuNotice(null);
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') setMenuNotice(null);
|
||||
};
|
||||
|
||||
window.addEventListener('click', close);
|
||||
window.addEventListener('keydown', closeOnEscape);
|
||||
return () => {
|
||||
window.removeEventListener('click', close);
|
||||
window.removeEventListener('keydown', closeOnEscape);
|
||||
};
|
||||
}, [menuNotice]);
|
||||
|
||||
const appNameByKey = useMemo(() => {
|
||||
const map = new Map<string, string>([
|
||||
['launcher', 'Launcher'],
|
||||
['login', 'Launcher']
|
||||
]);
|
||||
apps.forEach((app) => map.set(app.key, app.name));
|
||||
return map;
|
||||
}, [apps]);
|
||||
|
||||
const appLabel = (appKey: string) => appNameByKey.get(appKey) ?? appKey;
|
||||
|
||||
const rows = useMemo(
|
||||
() => notices.map((notice, index) => ({ ...notice, no: notices.length - index, appName: appLabel(notice.appKey) })),
|
||||
[appNameByKey, notices]
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (notice: LauncherNotice) => {
|
||||
setMenuNotice(null);
|
||||
setEditing(notice);
|
||||
setForm(toForm(notice));
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
};
|
||||
|
||||
const payload = () => ({
|
||||
appKey: form.appKey.trim(),
|
||||
title: form.title.trim(),
|
||||
body: form.body.trim(),
|
||||
linkUrl: form.linkUrl.trim(),
|
||||
startsAt: toApiDateTime(form.startsAt),
|
||||
endsAt: toApiDateTime(form.endsAt),
|
||||
priority: Number(form.priority) || 0,
|
||||
isActive: form.isActive
|
||||
});
|
||||
|
||||
const submit = () => {
|
||||
if (!form.appKey.trim() || !form.title.trim() || !form.body.trim()) return;
|
||||
setSaving(true);
|
||||
const request = editing
|
||||
? updateLauncherNotice(editing.id, payload())
|
||||
: createLauncherNotice(payload());
|
||||
|
||||
request
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: editing ? '공지사항을 수정했습니다.' : '공지사항을 등록했습니다.' });
|
||||
closeModal();
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: editing ? '공지사항 수정 실패' : '공지사항 등록 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const removeNotice = (notice: LauncherNotice) => {
|
||||
setMenuNotice(null);
|
||||
setDeleteTarget(notice);
|
||||
};
|
||||
|
||||
const confirmRemoveNotice = () => {
|
||||
if (!deleteTarget) return;
|
||||
setSaving(true);
|
||||
deleteLauncherNotice(deleteTarget.id)
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: '공지사항을 삭제했습니다.' });
|
||||
setDeleteTarget(null);
|
||||
closeModal();
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '공지사항 삭제 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const columns = useMemo<Array<DataTableColumn<(typeof rows)[number]>>>(
|
||||
() => [
|
||||
{ key: 'no', header: 'No.', className: 'w-20 px-5 py-3 font-medium', cell: (row) => <span className="font-semibold">{row.no}</span> },
|
||||
{
|
||||
key: 'title',
|
||||
header: '공지',
|
||||
className: 'w-[300px] px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div>
|
||||
<p className="truncate">{row.title}</p>
|
||||
<p className="mt-1 max-w-[320px] truncate text-xs text-muted-foreground">{row.body}</p>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{ key: 'appKey', header: '대상 앱', cell: (row) => <StatusBadge status={row.appName} tone={isLauncherNoticeKey(row.appKey) ? 'violet' : 'blue'} /> },
|
||||
{ key: 'status', header: '상태', cell: (row) => <StatusBadge status={row.status ?? (row.isActive ? '게시중' : '비활성')} /> },
|
||||
{ key: 'exposure', header: '노출 일시', cell: (row) => <span className="text-muted-foreground">{exposureText(row)}</span> },
|
||||
{
|
||||
key: 'linkUrl',
|
||||
header: '링크주소',
|
||||
cell: (row) => row.linkUrl ? <span className="block max-w-56 truncate text-muted-foreground">{row.linkUrl}</span> : <span className="text-muted-foreground">-</span>
|
||||
},
|
||||
{ key: 'createdAt', header: '생성일시', cell: (row) => <span className="text-muted-foreground">{formatNoticeDateTime(row.createdAt)}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
className: 'w-20 px-5 py-3 font-medium',
|
||||
cell: (row) => (
|
||||
<div className="relative flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="size-8 px-0"
|
||||
title="메뉴"
|
||||
aria-label="메뉴"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
setMenuNotice((prev) => (prev?.id === row.id ? null : { id: row.id, x: rect.right, y: rect.bottom + 6 }));
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
[menuNotice?.id]
|
||||
);
|
||||
|
||||
const filters = useMemo<Array<DataTableFilter<(typeof rows)[number]>>>(
|
||||
() => [
|
||||
{ key: 'appKey', label: '대상 앱', options: [...new Set(rows.map((row) => row.appKey))].map((appKey) => ({ label: appLabel(appKey), value: appKey })), getValue: (row) => row.appKey },
|
||||
{ key: 'status', label: '상태', options: [...new Set(rows.map((row) => row.status ?? (row.isActive ? '게시중' : '비활성')))].map((status) => ({ label: status, value: status })), getValue: (row) => row.status ?? (row.isActive ? '게시중' : '비활성') }
|
||||
],
|
||||
[rows]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="운영"
|
||||
title="공지사항"
|
||||
description="런처와 앱 화면에 노출할 공지사항을 관리합니다."
|
||||
actions={
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={openCreate}>
|
||||
<Plus className="size-4" />
|
||||
추가
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{failed ? (
|
||||
<div className="mb-4 rounded-md border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
공지사항 데이터를 불러오지 못했습니다.
|
||||
</div>
|
||||
) : null}
|
||||
<DataTableCard
|
||||
title="목록"
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
searchPlaceholder="공지 제목, 대상 앱, 링크 검색"
|
||||
getSearchText={(row) => `${row.title} ${row.body} ${row.appKey} ${row.appName} ${row.linkUrl ?? ''} ${row.status ?? ''}`}
|
||||
getRowKey={(row) => row.id}
|
||||
pageSize={6}
|
||||
minWidth={1180}
|
||||
loading={loading}
|
||||
emptyMessage="등록된 공지사항이 없습니다."
|
||||
/>
|
||||
|
||||
{menuNotice ? (() => {
|
||||
const row = rows.find((item) => item.id === menuNotice.id);
|
||||
if (!row) return null;
|
||||
return (
|
||||
<div onClick={(event) => event.stopPropagation()} className="fixed z-[70] min-w-28 overflow-hidden rounded-md border border-border bg-card py-1 text-left shadow-xl" style={{ left: menuNotice.x, top: menuNotice.y, transform: 'translateX(-100%)' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-foreground hover:bg-muted"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs text-rose-300 hover:bg-rose-500/10"
|
||||
onClick={() => removeNotice(row)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Pencil className="size-4 text-primary" />
|
||||
공지사항
|
||||
</span>
|
||||
}
|
||||
onClose={closeModal}
|
||||
className="!max-w-2xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeModal} disabled={saving}>취소</Button>
|
||||
<Button onClick={submit} disabled={saving || !form.appKey.trim() || !form.title.trim() || !form.body.trim()}>
|
||||
{saving ? '저장 중' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_120px]">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">대상 앱</span>
|
||||
<select className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.appKey} onChange={(event) => setForm((prev) => ({ ...prev, appKey: event.target.value }))}>
|
||||
<option value="launcher">Launcher</option>
|
||||
{apps.map((app) => (
|
||||
<option key={app.id} value={app.key}>{app.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">게시 여부</span>
|
||||
<span className="mt-2 inline-flex h-9 w-full items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
||||
<input type="checkbox" checked={form.isActive} onChange={(event) => setForm((prev) => ({ ...prev, isActive: event.target.checked }))} />
|
||||
게시
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">제목</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.title} onChange={(event) => setForm((prev) => ({ ...prev, title: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">본문</span>
|
||||
<textarea className="mt-2 min-h-24 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.body} onChange={(event) => setForm((prev) => ({ ...prev, body: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">링크주소</span>
|
||||
<input className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.linkUrl} onChange={(event) => setForm((prev) => ({ ...prev, linkUrl: event.target.value }))} />
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출 시작 일시</span>
|
||||
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.startsAt} onChange={(event) => setForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출 종료 일시</span>
|
||||
<input type="datetime-local" className="mt-2 h-9 w-full rounded-md border border-input bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-ring" value={form.endsAt} onChange={(event) => setForm((prev) => ({ ...prev, endsAt: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(deleteTarget)}
|
||||
title={
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trash2 className="size-4 text-rose-300" />
|
||||
공지사항 삭제
|
||||
</span>
|
||||
}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)} disabled={saving}>취소</Button>
|
||||
<Button className="border border-rose-500/40 bg-rose-500/15 text-rose-200 hover:bg-rose-500/25" onClick={confirmRemoveNotice} disabled={saving}>
|
||||
{saving ? '삭제 중' : '삭제'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="pb-6 text-sm text-foreground">공지사항을 삭제할까요?</p>
|
||||
</Modal>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user