import { Copy, ExternalLink, Link2, RotateCcw } from 'lucide-react'; import { useEffect, useMemo, useState } from 'react'; import { Badge, Button, Card, CardHeader } from '../../components/ui'; import { useToast } from '../../components/toast'; import { PageTitle } from '../../layout/AppLayout'; import { cn } from '../../lib/utils'; import type { GiteaRepo } from '../../types/application'; import { LoadingModal, useMinimumLoading } from '../operations/operation-utils'; import { getLauncherSource, listGiteaCommits, listLauncherVersions, listPlatformRepos, updateLauncherSource, type GiteaCommit, type LauncherVersion } from './launcher-api'; 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 ?? 'platform'; type SourceTarget = 'launcher-client' | 'launcher-backend'; type SourceForm = { repository: string; image: string; }; const targetLabels: Record = { 'launcher-client': { title: '클라이언트', description: '사용자 PC에 설치되는 Electron .deb 빌드 소스입니다.', defaultOrg: 'platform' }, 'launcher-backend': { title: '백엔드', description: 'portal-api 런처 백엔드 컨테이너 이미지 빌드 소스입니다.', defaultOrg: 'platform' } }; function repoFullName(repo: GiteaRepo) { return (repo as GiteaRepo & { fullName?: string }).fullName ?? repo.full_name ?? normalizeRepository(repo.name); } function normalizeRepository(repository?: string | null, fallbackOrg = giteaAppOrg) { if (!repository) return ''; const value = repository.replace(/^\/+|\/+$/g, ''); return value.includes('/') ? value : `${fallbackOrg}/${value}`; } function splitRepository(repository?: string | null, fallbackOrg = giteaAppOrg) { const normalizedRepository = normalizeRepository(repository, fallbackOrg); const [owner, ...repoParts] = normalizedRepository.split('/'); const repo = repoParts.join('/'); return owner && repo ? { owner, repo } : null; } function httpCloneUrl(repository?: string | null, fallbackOrg?: string) { const normalizedRepository = normalizeRepository(repository, fallbackOrg); return normalizedRepository ? `${giteaBaseUrl.replace(/\/$/, '')}/${normalizedRepository}.git` : '-'; } function sshCloneUrl(repository?: string | null, fallbackOrg?: string) { const normalizedRepository = normalizeRepository(repository, fallbackOrg); return normalizedRepository ? `${giteaSshBaseUrl.replace(/\/$/, '')}/${normalizedRepository}.git` : '-'; } function emptyForm(): SourceForm { return { repository: '', image: 'localhost:5000/studio-launcher-be' }; } function commitAuthor(commit: GiteaCommit) { return commit.author?.login || commit.author?.username || commit.author?.full_name || commit.author?.name || commit.commit?.author?.name || commit.committer?.login || '-'; } function commitMessage(commit: GiteaCommit) { return (commit.commit?.message || '').split(/\r?\n/)[0]?.trim() || '-'; } function commitDate(commit: GiteaCommit) { return commit.commit?.author?.date || commit.commit?.committer?.date || commit.created || ''; } function relativeTime(value?: string | null) { if (!value) return '-'; const time = new Date(value).getTime(); if (Number.isNaN(time)) return '-'; const seconds = Math.max(1, Math.floor((Date.now() - time) / 1000)); if (seconds < 60) return '방금 전'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes}분 전`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}시간 전`; const days = Math.floor(hours / 24); if (days === 1) return '어제'; if (days === 2) return '그저께'; if (days < 7) return `${days}일 전`; return new Intl.DateTimeFormat('ko-KR', { month: '2-digit', day: '2-digit' }).format(new Date(value)); } export function LauncherSourcePage() { const { toast } = useToast(); const [activeTarget, setActiveTarget] = useState('launcher-client'); const [versions, setVersions] = useState([]); const [platformRepos, setPlatformRepos] = useState([]); const [repos, setRepos] = useState([]); const [forms, setForms] = useState>({ 'launcher-client': emptyForm(), 'launcher-backend': emptyForm() }); const [commits, setCommits] = useState([]); const [commitsLoading, setCommitsLoading] = useState(false); const [commitsError, setCommitsError] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const loadingVisible = useMinimumLoading(loading); const sourceBranch = 'develop'; const activeVersion = useMemo(() => versions.find((version) => version.isActive) ?? versions[0], [versions]); const activeForm = forms[activeTarget]; const activeTargetMeta = targetLabels[activeTarget]; const updateForm = (patch: Partial) => { setForms((prev) => ({ ...prev, [activeTarget]: { ...prev[activeTarget], ...patch } })); }; const refresh = () => { setLoading(true); Promise.all([listLauncherVersions(), listPlatformRepos(), getLauncherSource()]) .then(([nextVersions, platformRepos, sourceResult]) => { const nextVersion = nextVersions.find((version) => version.isActive) ?? nextVersions[0]; const clientRepository = sourceResult.source.client?.repository ?? sourceResult.source.repository ?? nextVersion?.repository ?? (platformRepos[0] ? repoFullName(platformRepos[0]) : ''); const backendRepository = sourceResult.source.backend?.repository ?? (platformRepos[0] ? repoFullName(platformRepos[0]) : ''); setVersions(nextVersions); setPlatformRepos(platformRepos); setRepos(platformRepos); setForms({ 'launcher-client': { repository: clientRepository, image: '' }, 'launcher-backend': { repository: backendRepository, image: sourceResult.source.backend?.image ?? 'localhost:5000/studio-launcher-be' } }); }) .catch((error: Error) => { toast({ type: 'error', title: '소스 정보 조회 실패', message: error.message }); }) .finally(() => setLoading(false)); }; useEffect(() => { refresh(); }, []); useEffect(() => { setRepos(platformRepos); }, [platformRepos]); useEffect(() => { const repository = splitRepository(activeForm.repository, activeTargetMeta.defaultOrg); if (!repository) { setCommits([]); setCommitsError(''); return; } let canceled = false; setCommitsLoading(true); setCommitsError(''); listGiteaCommits(repository.owner, repository.repo, sourceBranch, 5) .then((nextCommits) => { if (!canceled) setCommits(nextCommits); }) .catch((error: Error) => { if (!canceled) { setCommits([]); setCommitsError(error.message); } }) .finally(() => { if (!canceled) setCommitsLoading(false); }); return () => { canceled = true; }; }, [activeForm.repository, activeTargetMeta.defaultOrg, sourceBranch]); const save = () => { if (!activeForm.repository.trim()) return; setSaving(true); updateLauncherSource({ target: activeTarget, repository: activeForm.repository.trim(), branch: sourceBranch, commitSha: '', image: activeTarget === 'launcher-backend' ? activeForm.image.trim() || undefined : undefined, dockerfile: activeTarget === 'launcher-backend' ? 'Dockerfile' : undefined }) .then(() => { toast({ type: 'success', title: '소스 연결', message: `${activeTargetMeta.title} 소스 연결을 저장했습니다.` }); refresh(); }) .catch((error: Error) => { toast({ type: 'error', title: '소스 연결 실패', message: error.message }); }) .finally(() => setSaving(false)); }; const copy = (value: string) => { navigator.clipboard?.writeText(value); toast({ type: 'success', message: '복사되었습니다.' }); }; return ( <> 새로고침 } />
{(Object.keys(targetLabels) as SourceTarget[]).map((target) => ( ))}
{activeTarget === 'launcher-backend' ? ( <> ) : null}
기준 {activeTarget === 'launcher-client' ? activeVersion?.version ?? '-' : activeForm.image || '-'}

HTTP Clone URL

{httpCloneUrl(activeForm.repository, activeTargetMeta.defaultOrg)}

SSH Clone URL

{sshCloneUrl(activeForm.repository, activeTargetMeta.defaultOrg)}

{activeForm.repository ? ( 저장소 열기 ) : null}
{commitsLoading ? ( ) : null} {!commitsLoading && commitsError ? ( ) : null} {!commitsLoading && !commitsError && commits.length === 0 ? ( ) : null} {!commitsLoading && !commitsError ? commits.map((commit) => ( )) : null}
작성자 SHA1 메시지 날짜
커밋을 불러오는 중입니다.
커밋 조회 실패: {commitsError}
표시할 커밋이 없습니다.
{commitAuthor(commit)} {commit.sha.slice(0, 10)} {commitMessage(commit)} {relativeTime(commitDate(commit))}
); }