'use client'; import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronDown, ChevronRight, Rocket, Upload } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { toast } from 'sonner'; import { EmptyStateWithBorder } from '@/components/layout/empty'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { Spinner } from '@/components/ui/spinner'; import { type PagesDeployment, PagesService } from '@/lib/services/openflare'; import { cn, formatDateTime } from '@/lib/utils'; import { DeploymentUploadDialog } from '../../components/deployment-upload-dialog'; import { deploymentFilesQueryKey, deploymentsQueryKey, formatBytes, projectQueryKey, projectsQueryKey, sourceQueryKey, } from '../../components/pages-utils'; import { DeploymentFilesPanel } from './deployment-files-panel'; const SOURCE_LABEL_KEYS: Record< PagesDeployment['source_type'], 'history.source.manual_upload' | 'history.source.manual_url' | null > = { manual_upload: 'history.source.manual_upload', manual_url: 'history.source.manual_url', remote_url: null, github_release: null, }; const TRIGGER_LABEL_KEYS: Record< PagesDeployment['trigger_type'], | 'history.trigger.manual_upload' | 'history.trigger.manual_url' | 'history.trigger.manual_sync' | 'history.trigger.scheduled_auto_update' > = { manual_upload: 'history.trigger.manual_upload', manual_url: 'history.trigger.manual_url', manual_sync: 'history.trigger.manual_sync', scheduled_auto_update: 'history.trigger.scheduled_auto_update', }; interface DeploymentHistoryProps { projectId: number; activeDeploymentId?: number | null; rootDir?: string; entryFile?: string; } type PendingAction = { type: 'activate' | 'delete'; deployment: PagesDeployment; }; function isActiveDeployment( deployment: PagesDeployment, activeDeploymentId?: number | null, ) { return deployment.id === activeDeploymentId || deployment.status === 'active'; } function sourceLabel( type: PagesDeployment['source_type'], t: (key: string) => string, ) { const key = SOURCE_LABEL_KEYS[type]; if (key) return t(key); return type === 'remote_url' ? 'Remote URL' : 'GitHub'; } function deploymentSnapshot( deployment: PagesDeployment, t: (key: string) => string, ) { return [ sourceLabel(deployment.source_type, t), deployment.source_label, t(TRIGGER_LABEL_KEYS[deployment.trigger_type]), ] .filter(Boolean) .join(' ยท '); } function DeploymentMeta({ deployment }: { deployment: PagesDeployment }) { const t = useTranslations('pages'); return ( <>

{t('history.fileMeta', { checksum: deployment.checksum.slice(0, 16), count: deployment.file_count, })} {formatBytes(deployment.total_size)}

{t('history.createdAt', { time: formatDateTime(deployment.created_at), })} {deployment.activated_at ? t('history.activatedAt', { time: formatDateTime(deployment.activated_at), }) : ''}

); } interface DeploymentRowProps { deployment: PagesDeployment; active: boolean; expanded: boolean; actionPending: boolean; showActions: boolean; onToggleExpand: () => void; onActivate: () => void; onDelete: () => void; projectId: number; } function DeploymentRow({ deployment, active, expanded, actionPending, showActions, onToggleExpand, onActivate, onDelete, projectId, }: DeploymentRowProps) { const t = useTranslations('pages'); return (
{t('history.deploymentNumber', { number: deployment.deployment_number, })} {deploymentSnapshot(deployment, (key) => t(key))}
{showActions ? (
) : null}
{expanded ? ( ) : null}
); } export function DeploymentHistory({ projectId, activeDeploymentId, rootDir = '', entryFile = 'index.html', }: DeploymentHistoryProps) { const t = useTranslations('pages'); const tCommon = useTranslations('common'); const queryClient = useQueryClient(); const [uploadOpen, setUploadOpen] = useState(false); const [expandedDeploymentId, setExpandedDeploymentId] = useState< number | null >(null); const [pendingAction, setPendingAction] = useState( null, ); const deploymentsQuery = useQuery({ queryKey: deploymentsQueryKey(projectId), queryFn: () => PagesService.listDeployments(projectId), }); const { productionDeployment, allDeployments } = useMemo(() => { const records = [...(deploymentsQuery.data ?? [])]; records.sort( (left, right) => right.deployment_number - left.deployment_number, ); const production = records.find((item) => isActiveDeployment(item, activeDeploymentId)) ?? null; return { productionDeployment: production, allDeployments: records, }; }, [activeDeploymentId, deploymentsQuery.data]); const invalidateDeploymentState = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: deploymentsQueryKey(projectId), }), queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }), queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }), queryClient.invalidateQueries({ queryKey: projectsQueryKey }), ]); }; const activateMutation = useMutation({ mutationFn: (deploymentId: number) => PagesService.activateDeployment(projectId, deploymentId), onSuccess: async () => { toast.success(t('history.activated')); await invalidateDeploymentState(); setPendingAction(null); }, onError: (error) => { toast.error(error instanceof Error ? error.message : t('history.activateFailed')); }, }); const deleteMutation = useMutation({ mutationFn: (deploymentId: number) => PagesService.deleteDeployment(projectId, deploymentId), onSuccess: async (_, deploymentId) => { toast.success(t('history.deleted')); queryClient.removeQueries({ queryKey: deploymentFilesQueryKey(projectId, deploymentId), }); await invalidateDeploymentState(); setPendingAction(null); }, onError: (error) => { toast.error(error instanceof Error ? error.message : t('history.deleteFailed')); }, }); const actionPending = activateMutation.isPending || deleteMutation.isPending; const toggleExpand = (deploymentId: number) => { setExpandedDeploymentId((current) => current === deploymentId ? null : deploymentId, ); }; const dialogs = ( <> { if (!open && !actionPending) setPendingAction(null); }} > {pendingAction?.type === 'activate' ? t('history.activateTitle') : t('history.deleteTitle')} {pendingAction?.type === 'activate' ? t('history.activateDesc') : t('history.deleteDesc', { number: pendingAction?.deployment.deployment_number ?? 0, })} {tCommon('cancel')} { event.preventDefault(); if (!pendingAction) return; if (pendingAction.type === 'activate') { activateMutation.mutate(pendingAction.deployment.id); } else { deleteMutation.mutate(pendingAction.deployment.id); } }} > {actionPending ? : null} {t('history.confirm')} ); if (deploymentsQuery.isLoading) { return ( <> {dialogs} ); } if (deploymentsQuery.isError) { return ( <>
void deploymentsQuery.refetch()} />
{dialogs} ); } return (
Production {t('history.productionHint')} {productionDeployment ? ( toggleExpand(productionDeployment.id)} onActivate={() => undefined} onDelete={() => undefined} projectId={projectId} /> ) : ( )} All deployments {t('history.historyHint')} {allDeployments.length === 0 ? ( ) : ( allDeployments.map((deployment) => { const active = isActiveDeployment(deployment, activeDeploymentId); return ( toggleExpand(deployment.id)} onActivate={() => setPendingAction({ type: 'activate', deployment }) } onDelete={() => setPendingAction({ type: 'delete', deployment }) } projectId={projectId} /> ); }) )} {dialogs}
); }