'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}
);
}