Files
studio-admin-fe/src/pages/launcher/LauncherAppsPage.tsx
T
chyoo f67017623f
ci/woodpecker/push/woodpecker Pipeline was successful
improve admin release and deployment safeguards
2026-07-09 09:46:36 +09:00

388 lines
19 KiB
TypeScript

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 [unlinking, setUnlinking] = useState(false);
const [editingLink, setEditingLink] = useState<LauncherAppLink | null>(null);
const [unlinkTarget, setUnlinkTarget] = 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 = () => {
if (!unlinkTarget || unlinking) return;
setUnlinking(true);
setWorkingId(unlinkTarget.id);
deleteLauncherAppLink(unlinkTarget.id)
.then(() => {
toast({ type: 'success', message: '런처 앱 연결을 해제했습니다.' });
setUnlinkTarget(null);
refresh();
})
.catch((error: Error) => {
toast({ type: 'error', title: '연결 해제 실패', message: error.message });
})
.finally(() => {
setWorkingId('');
setUnlinking(false);
});
};
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={() => setUnlinkTarget(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>
<div className="block">
<span className="text-xs font-medium text-muted-foreground">상태</span>
<label 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={displayForm.isEnabled} onChange={(event) => setDisplayForm((prev) => prev ? { ...prev, isEnabled: event.target.checked } : prev)} />
활성
</label>
</div>
</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>
<Modal
open={Boolean(unlinkTarget)}
title="앱 연결 해제"
onClose={() => {
if (!unlinking) setUnlinkTarget(null);
}}
footer={
<>
<Button variant="outline" disabled={unlinking} onClick={() => setUnlinkTarget(null)}>취소</Button>
<Button className="bg-rose-600 text-white hover:bg-rose-500" disabled={unlinking} onClick={unlink}>
{unlinking ? '해제 중' : '해제'}
</Button>
</>
}
>
<div className="space-y-3 text-sm">
<p>
<span className="font-semibold text-foreground">{unlinkTarget?.displayName || unlinkTarget?.app.name || '-'}</span>
{' '}앱의 런처 연결을 해제합니다.
</p>
<p className="text-muted-foreground">연결을 해제하면 런처 홈에 표시되지 않습니다.</p>
</div>
</Modal>
<LoadingModal open={loadingVisible} />
</>
);
}