388 lines
19 KiB
TypeScript
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} />
|
|
</>
|
|
);
|
|
}
|