init studio admin frontend
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
import { ChevronDown, Link2, MoreHorizontal, RotateCcw, Unlink } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, Card, CardHeader, Modal } from '../../components/ui';
|
||||
import { useToast } from '../../components/toast';
|
||||
import { PageTitle } from '../../layout/AppLayout';
|
||||
import { LoadingModal, useMinimumLoading } from '../operations/operation-utils';
|
||||
import {
|
||||
createLauncherAppLink,
|
||||
deleteLauncherAppLink,
|
||||
listLauncherAppLinks,
|
||||
moveLauncherAppLink,
|
||||
syncLauncherAppsToAdminApps,
|
||||
updateLauncherAppLink,
|
||||
type LauncherAppLink,
|
||||
type LauncherUnlinkedApp
|
||||
} from './launcher-api';
|
||||
|
||||
type DisplayForm = {
|
||||
displayName: string;
|
||||
description: string;
|
||||
subtitle: string;
|
||||
detailUrl: string;
|
||||
videoPath: string;
|
||||
iconKey: string;
|
||||
launchUrl: string;
|
||||
devAppPath: string;
|
||||
execPath: string;
|
||||
displayOrder: string;
|
||||
isEnabled: boolean;
|
||||
};
|
||||
|
||||
function releaseLabel(link: LauncherAppLink) {
|
||||
return link.release?.version ?? '-';
|
||||
}
|
||||
|
||||
function launchUrl(link: LauncherAppLink) {
|
||||
return link.launchUrl || link.execPath || link.devAppPath || link.artifact || '-';
|
||||
}
|
||||
|
||||
function toDisplayForm(link: LauncherAppLink): DisplayForm {
|
||||
return {
|
||||
displayName: link.displayName || link.app.name || '',
|
||||
description: link.description || link.app.description || '',
|
||||
subtitle: link.subtitle || '',
|
||||
detailUrl: link.detailUrl || '',
|
||||
videoPath: link.videoPath || '',
|
||||
iconKey: link.iconKey || link.app.key.toLowerCase(),
|
||||
launchUrl: link.launchUrl || '',
|
||||
devAppPath: link.devAppPath || '',
|
||||
execPath: link.execPath || '',
|
||||
displayOrder: String(link.displayOrder || ''),
|
||||
isEnabled: link.isEnabled
|
||||
};
|
||||
}
|
||||
|
||||
export function LauncherAppsPage() {
|
||||
const { toast } = useToast();
|
||||
const [linkedApps, setLinkedApps] = useState<LauncherAppLink[]>([]);
|
||||
const [unlinkedApps, setUnlinkedApps] = useState<LauncherUnlinkedApp[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [workingId, setWorkingId] = useState('');
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [editingLink, setEditingLink] = useState<LauncherAppLink | null>(null);
|
||||
const [displayForm, setDisplayForm] = useState<DisplayForm | null>(null);
|
||||
const loadingVisible = useMinimumLoading(loading);
|
||||
|
||||
const refresh = () => {
|
||||
setLoading(true);
|
||||
listLauncherAppLinks()
|
||||
.then(({ linked, unlinked }) => {
|
||||
setLinkedApps(linked);
|
||||
setUnlinkedApps(unlinked);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '앱 연결 조회 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const connect = (app: LauncherUnlinkedApp) => {
|
||||
setWorkingId(app.id);
|
||||
createLauncherAppLink({ appId: app.id, releaseId: app.releases?.[0]?.id })
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: '런처에 앱을 연결했습니다.' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '앱 연결 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setWorkingId(''));
|
||||
};
|
||||
|
||||
const move = (link: LauncherAppLink, direction: 'up' | 'down') => {
|
||||
setWorkingId(`${link.id}-${direction}`);
|
||||
moveLauncherAppLink(link.id, direction)
|
||||
.then(({ linked, unlinked }) => {
|
||||
setLinkedApps(linked);
|
||||
setUnlinkedApps(unlinked);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '순서 변경 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setWorkingId(''));
|
||||
};
|
||||
|
||||
const unlink = (link: LauncherAppLink) => {
|
||||
setWorkingId(link.id);
|
||||
deleteLauncherAppLink(link.id)
|
||||
.then(() => {
|
||||
toast({ type: 'success', message: '런처 앱 연결을 해제했습니다.' });
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '연결 해제 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setWorkingId(''));
|
||||
};
|
||||
|
||||
const syncAdminApps = () => {
|
||||
setSyncing(true);
|
||||
syncLauncherAppsToAdminApps()
|
||||
.then(({ syncedApps, syncedReleases }) => {
|
||||
toast({ type: 'success', message: `앱 ${syncedApps}개, 릴리즈 ${syncedReleases}개를 동기화했습니다.` });
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '앱 관리 동기화 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setSyncing(false));
|
||||
};
|
||||
|
||||
const openSettings = (link: LauncherAppLink) => {
|
||||
setEditingLink(link);
|
||||
setDisplayForm(toDisplayForm(link));
|
||||
};
|
||||
|
||||
const closeSettings = () => {
|
||||
setEditingLink(null);
|
||||
setDisplayForm(null);
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
if (!editingLink || !displayForm) return;
|
||||
setWorkingId(`${editingLink.id}-settings`);
|
||||
updateLauncherAppLink(editingLink.id, {
|
||||
displayName: displayForm.displayName,
|
||||
description: displayForm.description,
|
||||
subtitle: displayForm.subtitle,
|
||||
detailUrl: displayForm.detailUrl,
|
||||
videoPath: displayForm.videoPath,
|
||||
iconKey: displayForm.iconKey,
|
||||
launchUrl: displayForm.launchUrl,
|
||||
devAppPath: displayForm.devAppPath,
|
||||
execPath: displayForm.execPath,
|
||||
displayOrder: Number(displayForm.displayOrder) || undefined,
|
||||
isEnabled: displayForm.isEnabled
|
||||
})
|
||||
.then(({ link }) => {
|
||||
setLinkedApps((prev) => prev.map((item) => (item.id === link.id ? link : item)).sort((a, b) => a.displayOrder - b.displayOrder));
|
||||
toast({ type: 'success', message: '런처 표시 설정을 저장했습니다.' });
|
||||
closeSettings();
|
||||
refresh();
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
toast({ type: 'error', title: '설정 저장 실패', message: error.message });
|
||||
})
|
||||
.finally(() => setWorkingId(''));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle
|
||||
eyebrow="런처 운영"
|
||||
title="앱 연결"
|
||||
description="등록된 앱 중 런처에 노출할 앱과 표시 순서를 관리합니다."
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={syncAdminApps} disabled={syncing || loading}>
|
||||
<Link2 className={syncing ? 'size-4 animate-spin' : 'size-4'} />
|
||||
앱 관리 동기화
|
||||
</Button>
|
||||
<Button variant="outline" className="h-8 px-3 text-[13px]" onClick={refresh} disabled={loading}>
|
||||
<RotateCcw className={loading ? 'size-4 animate-spin' : 'size-4'} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="연결된 앱" description="런처 홈에 표시할 앱과 노출 순서를 관리합니다." />
|
||||
<div className="max-h-[560px] overflow-x-auto overflow-y-auto">
|
||||
<table className="w-full min-w-[760px] text-left text-sm">
|
||||
<thead className="sticky top-0 z-10 border-b border-border bg-card text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="w-28 px-5 py-3 font-medium">노출순서</th>
|
||||
<th className="px-5 py-3 font-medium">앱</th>
|
||||
<th className="px-5 py-3 font-medium">릴리즈</th>
|
||||
<th className="px-5 py-3 font-medium">실행 경로</th>
|
||||
<th className="px-5 py-3 text-right font-medium">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{!loading && linkedApps.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-5 py-12 text-center text-sm text-muted-foreground">
|
||||
연결된 앱이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
{linkedApps.map((link, index) => (
|
||||
<tr key={link.id} className="hover:bg-muted/20">
|
||||
<td className="px-5 py-3">{link.displayOrder || index + 1}</td>
|
||||
<td className="px-5 py-3">
|
||||
<p className="inline-flex items-center gap-2">
|
||||
<span className={link.isEnabled ? 'size-2 rounded-full bg-emerald-400' : 'size-2 rounded-full bg-muted-foreground'} />
|
||||
{link.displayName || link.app.name}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{[link.app.key, link.subtitle].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge tone={link.release ? 'green' : 'neutral'}>{releaseLabel(link)}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-muted-foreground">{launchUrl(link)}</td>
|
||||
<td className="px-5 py-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="size-8 px-0" aria-label={`${link.app.name} 위로 이동`} onClick={() => move(link, 'up')} disabled={index === 0 || workingId === `${link.id}-up`}>
|
||||
<ChevronDown className="size-4 rotate-180" />
|
||||
</Button>
|
||||
<Button variant="outline" className="size-8 px-0" aria-label={`${link.app.name} 아래로 이동`} onClick={() => move(link, 'down')} disabled={index === linkedApps.length - 1 || workingId === `${link.id}-down`}>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" className="size-8 px-0" aria-label={`${link.app.name} 표시 설정`} onClick={() => openSettings(link)} disabled={workingId === `${link.id}-settings`}>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" className="size-8 border-rose-500/40 px-0 text-rose-300 hover:bg-rose-500/10" aria-label={`${link.app.name} 연결 해제`} onClick={() => unlink(link)} disabled={workingId === link.id}>
|
||||
<Unlink className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="min-w-0 overflow-hidden">
|
||||
<CardHeader title="미연결 앱" description="아직 런처에 연결하지 않은 앱입니다." />
|
||||
<div className="max-h-[420px] divide-y divide-border overflow-y-auto">
|
||||
{!loading && unlinkedApps.length === 0 ? (
|
||||
<div className="flex min-h-32 items-center justify-center text-sm text-muted-foreground">미연결 앱이 없습니다.</div>
|
||||
) : null}
|
||||
{unlinkedApps.map((app) => (
|
||||
<div key={app.id} className="flex items-center justify-between gap-4 px-5 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm">{app.name}</p>
|
||||
<p className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{app.key} · {app.releases?.[0]?.version ?? '릴리즈 없음'}
|
||||
</p>
|
||||
</div>
|
||||
<Button className="h-8 px-3 text-[13px]" onClick={() => connect(app)} disabled={workingId === app.id}>
|
||||
<Link2 className="size-4" />
|
||||
연결
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={Boolean(editingLink && displayForm)}
|
||||
title="런처 표시 설정"
|
||||
onClose={closeSettings}
|
||||
className="!max-w-3xl"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={closeSettings}>취소</Button>
|
||||
<Button onClick={saveSettings} disabled={!displayForm?.displayName.trim() || workingId.endsWith('-settings')}>
|
||||
저장
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{displayForm ? (
|
||||
<form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); saveSettings(); }}>
|
||||
<div className="grid gap-4 md:grid-cols-[1fr_120px_120px]">
|
||||
<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={displayForm.displayName} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayName: event.target.value } : prev)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">노출순서</span>
|
||||
<input type="number" 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={displayForm.displayOrder} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, displayOrder: event.target.value } : prev)} />
|
||||
</label>
|
||||
<label className="mt-6 inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-sm text-foreground">
|
||||
<input type="checkbox" checked={displayForm.isEnabled} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, isEnabled: event.target.checked } : prev)} />
|
||||
활성
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<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={displayForm.subtitle} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, subtitle: event.target.value } : prev)} />
|
||||
</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={displayForm.iconKey} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, iconKey: event.target.value } : prev)} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
||||
<textarea className="mt-2 min-h-20 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={displayForm.description} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, description: event.target.value } : prev)} />
|
||||
</label>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">실행 URL</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={displayForm.launchUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, launchUrl: event.target.value } : prev)} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs font-medium text-muted-foreground">상세 URL</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={displayForm.detailUrl} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, detailUrl: event.target.value } : prev)} />
|
||||
</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={displayForm.devAppPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, devAppPath: event.target.value } : prev)} />
|
||||
</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={displayForm.execPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, execPath: event.target.value } : prev)} />
|
||||
</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={displayForm.videoPath} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, videoPath: event.target.value } : prev)} />
|
||||
</label>
|
||||
</form>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<LoadingModal open={loadingVisible} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user