505 lines
22 KiB
TypeScript
505 lines
22 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Copy, ExternalLink, Link2, Plus } from 'lucide-react';
|
|
import { Badge, Button, Card, CardHeader } from '../../components/ui';
|
|
import { useToast } from '../../components/toast';
|
|
import { PageTitle } from '../../layout/AppLayout';
|
|
import type { GiteaRepo } from '../../types/application';
|
|
import type { ApiApp, ApiAppComponent } from '../../types/runtime';
|
|
import { LoadingModal } from '../operations/operation-utils';
|
|
import { createGiteaOrgRepo, linkComponentSource, listGiteaRepos, listSources } from './application-api';
|
|
import { AppSelector, useApps } from './ApplicationControls';
|
|
|
|
const giteaBaseUrl = import.meta.env.VITE_GITEA_BASE_URL ?? 'http://localhost:3000';
|
|
const giteaSshBaseUrl = import.meta.env.VITE_GITEA_SSH_BASE_URL ?? 'ssh://git@localhost:2222';
|
|
const giteaAppOrg = import.meta.env.VITE_GITEA_APP_ORG ?? import.meta.env.GITEA_APP_ORG ?? 'platform';
|
|
|
|
type PlatformRepo = {
|
|
id: number;
|
|
owner: string;
|
|
name: string;
|
|
fullName: string;
|
|
description?: string;
|
|
private: boolean;
|
|
htmlUrl: string;
|
|
cloneUrl: string;
|
|
sshUrl: string;
|
|
defaultBranch: string;
|
|
language?: string;
|
|
};
|
|
|
|
type SourceComponent = ApiAppComponent & {
|
|
app?: ApiApp;
|
|
};
|
|
|
|
function useMinimumLoading(loading: boolean, minimumMs = 500) {
|
|
const [visible, setVisible] = useState(loading);
|
|
|
|
useEffect(() => {
|
|
if (loading) {
|
|
setVisible(true);
|
|
return;
|
|
}
|
|
|
|
const timer = window.setTimeout(() => setVisible(false), minimumMs);
|
|
return () => window.clearTimeout(timer);
|
|
}, [loading, minimumMs]);
|
|
|
|
return visible;
|
|
}
|
|
|
|
function toPlatformRepo(repo: GiteaRepo): PlatformRepo {
|
|
const fullName = (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? repo.name;
|
|
const owner =
|
|
typeof repo.owner === 'string'
|
|
? repo.owner
|
|
: repo.owner?.login ?? fullName.split('/')[0] ?? 'platform';
|
|
|
|
return {
|
|
id: repo.id ?? Number(`${owner.length}${repo.name.length}`),
|
|
owner,
|
|
name: repo.name,
|
|
fullName,
|
|
description: (repo as GiteaRepo & { description?: string }).description,
|
|
private: Boolean(repo.private),
|
|
htmlUrl: (repo as GiteaRepo & { htmlUrl?: string }).htmlUrl ?? repo.html_url ?? `${giteaBaseUrl}/${fullName}`,
|
|
cloneUrl: (repo as GiteaRepo & { cloneUrl?: string }).cloneUrl ?? repo.clone_url ?? `${giteaBaseUrl}/${fullName}.git`,
|
|
sshUrl: (repo as GiteaRepo & { sshUrl?: string }).sshUrl ?? repo.ssh_url ?? `${giteaSshBaseUrl.replace(/\/$/, '')}/${fullName}.git`,
|
|
defaultBranch: (repo as GiteaRepo & { defaultBranch?: string }).defaultBranch ?? 'main',
|
|
language: (repo as GiteaRepo & { language?: string }).language
|
|
};
|
|
}
|
|
|
|
function normalizeRepositoryName(repository: string) {
|
|
const trimmed = repository.trim().replace(/^\/+/, '').replace(/\.git$/, '');
|
|
return trimmed.includes('/') ? trimmed : `${giteaAppOrg}/${trimmed}`;
|
|
}
|
|
|
|
function getHttpCloneUrl(repository: string) {
|
|
return `${giteaBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`;
|
|
}
|
|
|
|
function getSshCloneUrl(repository: string) {
|
|
return `${giteaSshBaseUrl.replace(/\/$/, '')}/${normalizeRepositoryName(repository)}.git`;
|
|
}
|
|
|
|
export function SourceManagementPage() {
|
|
const { toast } = useToast();
|
|
const { apps, selectedAppId, loading: appsLoading, selectApp } = useApps();
|
|
const [giteaRepos, setGiteaRepos] = useState<PlatformRepo[]>([]);
|
|
const [apiSources, setApiSources] = useState<SourceComponent[]>([]);
|
|
const [sourcesLoading, setSourcesLoading] = useState(true);
|
|
const [giteaStatus, setGiteaStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
const sourcesLoadingVisible = useMinimumLoading(giteaStatus === 'loading' || sourcesLoading);
|
|
const [isCreateRepoOpen, setIsCreateRepoOpen] = useState(false);
|
|
const [repoName, setRepoName] = useState('');
|
|
const [repoDescription, setRepoDescription] = useState('');
|
|
const [repoPrivate, setRepoPrivate] = useState(true);
|
|
const [createDevelopBranch, setCreateDevelopBranch] = useState(true);
|
|
const [createReleaseBranch, setCreateReleaseBranch] = useState(true);
|
|
const [repoCreateStatus, setRepoCreateStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
|
const [sourceModalOpen, setSourceModalOpen] = useState(false);
|
|
const [sourceTarget, setSourceTarget] = useState<SourceComponent | null>(null);
|
|
const [sourceRepo, setSourceRepo] = useState('');
|
|
const [sourceBranch, setSourceBranch] = useState('develop');
|
|
const [sourceBuildTarget, setSourceBuildTarget] = useState('');
|
|
const [sourceSaveStatus, setSourceSaveStatus] = useState<'idle' | 'saving' | 'error'>('idle');
|
|
|
|
const refreshRepos = () => {
|
|
setGiteaStatus('loading');
|
|
listGiteaRepos(giteaAppOrg, 8)
|
|
.then((repos) => {
|
|
setGiteaRepos(repos.map(toPlatformRepo));
|
|
setGiteaStatus('ready');
|
|
})
|
|
.catch(() => setGiteaStatus('error'));
|
|
};
|
|
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
setSourcesLoading(true);
|
|
listSources()
|
|
.then((sources) => {
|
|
if (!mounted) return;
|
|
setApiSources(sources);
|
|
setSourcesLoading(false);
|
|
})
|
|
.catch(() => {
|
|
if (!mounted) return;
|
|
setApiSources([]);
|
|
setSourcesLoading(false);
|
|
});
|
|
|
|
listGiteaRepos(giteaAppOrg, 8)
|
|
.then((repos) => {
|
|
if (!mounted) return;
|
|
setGiteaRepos(repos.map(toPlatformRepo));
|
|
setGiteaStatus('ready');
|
|
})
|
|
.catch(() => {
|
|
if (!mounted) return;
|
|
setGiteaStatus('error');
|
|
});
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const createRepo = () => {
|
|
if (!repoName.trim()) return;
|
|
|
|
setRepoCreateStatus('saving');
|
|
createGiteaOrgRepo(giteaAppOrg, {
|
|
name: repoName.trim(),
|
|
description: repoDescription.trim() || undefined,
|
|
private: repoPrivate,
|
|
autoInit: true,
|
|
createDevelopBranch,
|
|
createReleaseBranch
|
|
})
|
|
.then(() => {
|
|
setRepoName('');
|
|
setRepoDescription('');
|
|
setRepoPrivate(true);
|
|
setCreateDevelopBranch(true);
|
|
setCreateReleaseBranch(true);
|
|
setRepoCreateStatus('idle');
|
|
setIsCreateRepoOpen(false);
|
|
refreshRepos();
|
|
toast({ type: 'success', message: '저장소가 생성되었습니다.' });
|
|
})
|
|
.catch(() => setRepoCreateStatus('error'));
|
|
};
|
|
|
|
const refreshSources = () => {
|
|
setSourcesLoading(true);
|
|
listSources()
|
|
.then((sources) => {
|
|
setApiSources(sources);
|
|
setSourcesLoading(false);
|
|
})
|
|
.catch(() => {
|
|
setApiSources([]);
|
|
setSourcesLoading(false);
|
|
});
|
|
};
|
|
|
|
const openSourceModal = (source: SourceComponent) => {
|
|
setSourceTarget(source);
|
|
setSourceRepo(source.repository ?? '');
|
|
setSourceBranch(source.branch ?? 'develop');
|
|
setSourceBuildTarget(source.buildTarget ?? '');
|
|
setSourceSaveStatus('idle');
|
|
setSourceModalOpen(true);
|
|
};
|
|
|
|
const closeSourceModal = () => {
|
|
setSourceModalOpen(false);
|
|
setSourceTarget(null);
|
|
setSourceRepo('');
|
|
setSourceBranch('develop');
|
|
setSourceBuildTarget('');
|
|
setSourceSaveStatus('idle');
|
|
};
|
|
|
|
const saveSourceLink = () => {
|
|
if (!sourceTarget || !sourceRepo.trim()) return;
|
|
|
|
setSourceSaveStatus('saving');
|
|
linkComponentSource(sourceTarget.id, {
|
|
repository: sourceRepo,
|
|
branch: sourceBranch || undefined,
|
|
buildTarget: sourceBuildTarget || undefined
|
|
})
|
|
.then(() => {
|
|
closeSourceModal();
|
|
refreshSources();
|
|
toast({ type: 'success', message: '소스 연결이 저장되었습니다.' });
|
|
})
|
|
.catch(() => {
|
|
setSourceSaveStatus('error');
|
|
toast({ type: 'error', message: '소스 연결 저장에 실패했습니다.' });
|
|
});
|
|
};
|
|
|
|
const copyToClipboard = (value: string) => {
|
|
void navigator.clipboard?.writeText(value);
|
|
toast({ type: 'success', message: '복사되었습니다.' });
|
|
};
|
|
|
|
const filteredSources = apiSources.filter((source) => !selectedAppId || source.appId === selectedAppId);
|
|
|
|
return (
|
|
<>
|
|
<PageTitle
|
|
eyebrow="어플리케이션"
|
|
title="소스 관리"
|
|
description="앱별 소스, 브랜치, 커밋, 빌드 대상을 관리합니다."
|
|
actions={
|
|
<Button className="h-8 px-3 text-[13px]" onClick={() => setIsCreateRepoOpen(true)}>
|
|
<Plus className="size-4" />
|
|
저장소 생성
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
<AppSelector apps={apps} value={selectedAppId} loading={appsLoading} onChange={selectApp} />
|
|
|
|
<Card className="mb-4 min-w-0 overflow-hidden">
|
|
<CardHeader
|
|
title="저장소"
|
|
description="앱과 런처 소스를 구성요소에 연결할 수 있도록 관리합니다."
|
|
/>
|
|
<div className="max-h-[300px] overflow-x-auto overflow-y-auto">
|
|
<table className="w-full min-w-[900px] 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="px-5 py-3 font-medium">저장소</th>
|
|
<th className="px-5 py-3 font-medium">Owner</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">Clone URL</th>
|
|
<th className="px-5 py-3 font-medium">상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{giteaStatus === 'error' ? (
|
|
<tr>
|
|
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>백엔드 연결 후 저장소가 표시됩니다.</td>
|
|
</tr>
|
|
) : giteaStatus !== 'loading' && giteaRepos.length === 0 ? (
|
|
<tr>
|
|
<td className="px-5 py-6 text-center text-muted-foreground" colSpan={6}>등록된 저장소가 없습니다.</td>
|
|
</tr>
|
|
) : (
|
|
giteaRepos.map((repo) => (
|
|
<tr key={repo.id} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold">{repo.fullName}</span>
|
|
<a href={repo.htmlUrl} target="_blank" rel="noreferrer" className="text-muted-foreground hover:text-foreground">
|
|
<ExternalLink className="size-3.5" />
|
|
</a>
|
|
</div>
|
|
{repo.description ? <p className="mt-1 text-xs text-muted-foreground">{repo.description}</p> : null}
|
|
</td>
|
|
<td className="px-5 py-3">{repo.owner}</td>
|
|
<td className="px-5 py-3">{repo.defaultBranch}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{repo.language || '-'}</td>
|
|
<td className="px-5 py-3 font-mono text-xs text-muted-foreground">{repo.cloneUrl}</td>
|
|
<td className="px-5 py-3">
|
|
<Badge tone={repo.private ? 'amber' : 'blue'}>{repo.private ? 'Private' : 'Public'}</Badge>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="min-w-0 overflow-hidden">
|
|
<CardHeader title="소스 연결" description="앱 구성요소별 저장소와 빌드 대상을 확인합니다." />
|
|
<div className="max-h-[620px] overflow-x-auto overflow-y-auto">
|
|
<table className="w-full min-w-[1180px] 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="px-5 py-3 font-medium">앱</th>
|
|
<th className="px-5 py-3 font-medium">구성요소</th>
|
|
<th className="w-72 px-5 py-3 font-medium">저장소</th>
|
|
<th className="px-5 py-3 font-medium">기본 브랜치</th>
|
|
<th className="px-5 py-3 font-medium">Commit</th>
|
|
<th className="px-5 py-3 font-medium">유형</th>
|
|
<th className="px-5 py-3 font-medium">빌드 대상</th>
|
|
<th className="w-[260px] px-5 py-3 font-medium">Clone URL</th>
|
|
<th className="w-24 px-5 py-3 font-medium">상태</th>
|
|
<th className="w-24 px-5 py-3 text-right font-medium">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{!sourcesLoading && filteredSources.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={10} className="px-5 py-6 text-center text-muted-foreground">등록된 앱 구성요소가 없습니다.</td>
|
|
</tr>
|
|
) : (
|
|
!sourcesLoading && filteredSources.map((source) => (
|
|
<tr key={source.id} className="hover:bg-muted/20">
|
|
<td className="px-5 py-3 font-semibold">{source.app?.name ?? '-'}</td>
|
|
<td className="px-5 py-3">{source.name}</td>
|
|
<td className="max-w-72 px-5 py-3 text-muted-foreground">
|
|
<span className="block truncate whitespace-nowrap">{source.repository || '미연결'}</span>
|
|
</td>
|
|
<td className="px-5 py-3">{source.branch || '-'}</td>
|
|
<td className="px-5 py-3 font-medium">-</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{source.type}</td>
|
|
<td className="px-5 py-3 text-muted-foreground">{source.buildTarget || '-'}</td>
|
|
<td className="w-[260px] px-5 py-3 text-muted-foreground">
|
|
{source.repository ? (
|
|
<div className="w-[220px] space-y-1">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<p className="min-w-0 flex-1 truncate whitespace-nowrap font-mono text-xs">{getHttpCloneUrl(source.repository)}</p>
|
|
<button
|
|
type="button"
|
|
title="HTTP clone URL 복사"
|
|
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
onClick={() => copyToClipboard(getHttpCloneUrl(source.repository!))}
|
|
>
|
|
<Copy className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<p className="min-w-0 flex-1 truncate whitespace-nowrap font-mono text-xs">{getSshCloneUrl(source.repository)}</p>
|
|
<button
|
|
type="button"
|
|
title="SSH clone URL 복사"
|
|
className="inline-flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
onClick={() => copyToClipboard(getSshCloneUrl(source.repository!))}
|
|
>
|
|
<Copy className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : '-'}
|
|
</td>
|
|
<td className="whitespace-nowrap px-5 py-3"><Badge tone={source.repository ? 'green' : 'neutral'}>{source.repository ? '연결됨' : '미연결'}</Badge></td>
|
|
<td className="px-5 py-3">
|
|
<div className="flex justify-end">
|
|
<Button variant="outline" className="size-8 px-0" title="소스 연결" aria-label="소스 연결" onClick={() => openSourceModal(source)}>
|
|
<Link2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
{isCreateRepoOpen ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
|
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
|
<div className="border-b border-border px-5 py-3">
|
|
<h2 className="text-sm font-semibold">저장소 생성</h2>
|
|
</div>
|
|
<div className="space-y-4 px-5 py-5">
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">저장소명</span>
|
|
<input
|
|
value={repoName}
|
|
onChange={(event) => setRepoName(event.target.value)}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">설명</span>
|
|
<textarea
|
|
value={repoDescription}
|
|
onChange={(event) => setRepoDescription(event.target.value)}
|
|
className="mt-2 min-h-20 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={repoPrivate}
|
|
onChange={(event) => setRepoPrivate(event.target.checked)}
|
|
className="size-4 accent-primary"
|
|
/>
|
|
Private 저장소로 생성
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={createDevelopBranch}
|
|
onChange={(event) => setCreateDevelopBranch(event.target.checked)}
|
|
className="size-4 accent-primary"
|
|
/>
|
|
develop 브랜치 자동 생성
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={createReleaseBranch}
|
|
onChange={(event) => setCreateReleaseBranch(event.target.checked)}
|
|
className="size-4 accent-primary"
|
|
/>
|
|
release 브랜치 자동 생성
|
|
</label>
|
|
{repoCreateStatus === 'error' ? (
|
|
<p className="text-xs text-rose-300">저장소 생성에 실패했습니다. 권한과 중복 저장소명을 확인하세요.</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
|
<Button variant="ghost" onClick={() => setIsCreateRepoOpen(false)}>취소</Button>
|
|
<Button onClick={createRepo} disabled={!repoName.trim() || repoCreateStatus === 'saving'}>
|
|
{repoCreateStatus === 'saving' ? '생성 중' : '생성'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{sourceModalOpen && sourceTarget ? (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/55 px-4 backdrop-blur-[2px]">
|
|
<div className="w-full max-w-lg rounded-lg border border-border bg-card shadow-xl">
|
|
<div className="border-b border-border px-5 py-3">
|
|
<h2 className="text-sm font-semibold">소스 연결</h2>
|
|
</div>
|
|
<div className="space-y-4 px-5 py-5">
|
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
|
<p className="text-xs text-muted-foreground">구성요소</p>
|
|
<p className="mt-1 text-sm font-semibold">{sourceTarget.app?.name ?? '-'} / {sourceTarget.name}</p>
|
|
</div>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">저장소</span>
|
|
<select
|
|
value={sourceRepo}
|
|
onChange={(event) => setSourceRepo(event.target.value)}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
>
|
|
<option value="">저장소 선택</option>
|
|
{giteaRepos.map((repo) => (
|
|
<option key={repo.id} value={repo.fullName}>{repo.fullName}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">기본 브랜치</span>
|
|
<select
|
|
value={sourceBranch}
|
|
onChange={(event) => setSourceBranch(event.target.value)}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
>
|
|
<option value="develop">develop</option>
|
|
<option value="release">release</option>
|
|
<option value="main">main</option>
|
|
</select>
|
|
</label>
|
|
<label className="block">
|
|
<span className="text-xs font-medium text-muted-foreground">빌드 대상</span>
|
|
<input
|
|
value={sourceBuildTarget}
|
|
onChange={(event) => setSourceBuildTarget(event.target.value)}
|
|
className="mt-2 h-9 w-full rounded-md border border-border bg-background px-3 text-sm outline-none focus:border-primary"
|
|
/>
|
|
</label>
|
|
{sourceSaveStatus === 'error' ? <p className="text-xs text-rose-300">소스 연결 저장에 실패했습니다.</p> : null}
|
|
</div>
|
|
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
|
<Button variant="ghost" onClick={closeSourceModal}>취소</Button>
|
|
<Button onClick={saveSourceLink} disabled={!sourceRepo.trim() || sourceSaveStatus === 'saving'}>
|
|
{sourceSaveStatus === 'saving' ? '저장 중' : '저장'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<LoadingModal open={sourcesLoadingVisible} />
|
|
</>
|
|
);
|
|
}
|