- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs - apps/worker: Bun worker stub, DB connection, graceful SIGTERM - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference) - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy) - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api - docker-compose.yml: 4-service target (api, spa, db, worker) - packages/db/src/client.ts: shared Drizzle client for api + worker - db/client.ts: root-level alias for convenience Parent: t_e1cbd87d -> t_24c9c3fd (T0)
415 lines
16 KiB
TypeScript
415 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { Download, Upload, Check, AlertCircle, Loader2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────────
|
|
|
|
interface CollectionInfo {
|
|
name: string;
|
|
label: string;
|
|
}
|
|
|
|
interface ImportResultItem {
|
|
collection: string;
|
|
imported: number;
|
|
failed: number;
|
|
errors: string[];
|
|
}
|
|
|
|
interface ImportResult {
|
|
success: boolean;
|
|
imported: number;
|
|
failed: number;
|
|
results: ImportResultItem[];
|
|
}
|
|
|
|
const DEFAULT_COLLECTIONS: CollectionInfo[] = [
|
|
{ name: 'tasks', label: 'Tasks' },
|
|
{ name: 'habits', label: 'Habits' },
|
|
{ name: 'projects', label: 'Projects' },
|
|
{ name: 'notes', label: 'Notes' },
|
|
{ name: 'reports', label: 'Reports' },
|
|
{ name: 'milestones', label: 'Milestones' },
|
|
{ name: 'domains', label: 'Domains' },
|
|
{ name: 'tags', label: 'Tags' },
|
|
{ name: 'agents', label: 'Agents' },
|
|
{ name: 'webhooks', label: 'Webhooks' },
|
|
];
|
|
|
|
// ── Main Component ─────────────────────────────────────────────────────────
|
|
|
|
export function SettingsImportExport() {
|
|
const [exporting, setExporting] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
const [exportProgress, setExportProgress] = useState(0);
|
|
const [importProgress, setImportProgress] = useState(0);
|
|
const [selectedCollections, setSelectedCollections] = useState<string[]>(
|
|
DEFAULT_COLLECTIONS.map((c) => c.name)
|
|
);
|
|
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
|
const [confirmImport, setConfirmImport] = useState(false);
|
|
const [pendingImportFile, setPendingImportFile] = useState<File | null>(null);
|
|
const [exportError, setExportError] = useState<string | null>(null);
|
|
const [importError, setImportError] = useState<string | null>(null);
|
|
|
|
// ── Export ────────────────────────────────────────────────────────────────
|
|
|
|
async function handleExport() {
|
|
setExporting(true);
|
|
setExportProgress(0);
|
|
setExportError(null);
|
|
let progressInterval: ReturnType<typeof setInterval> | undefined;
|
|
|
|
try {
|
|
// Simulate progress while fetching
|
|
progressInterval = setInterval(() => {
|
|
setExportProgress((prev) => Math.min(prev + 10, 90));
|
|
}, 200);
|
|
|
|
const response = await fetch('/api/export', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ collections: selectedCollections }),
|
|
});
|
|
|
|
clearInterval(progressInterval);
|
|
progressInterval = undefined;
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Export failed');
|
|
}
|
|
|
|
const data = await response.json();
|
|
setExportProgress(100);
|
|
|
|
// Download as JSON
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
|
type: 'application/json',
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `project-e-export-${new Date().toISOString().split('T')[0]}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
toast.success('Export completed successfully');
|
|
} catch (error) {
|
|
console.error('Failed to export:', error);
|
|
setExportError('Failed to export data. Please try again.');
|
|
toast.error('Failed to export data');
|
|
} finally {
|
|
if (progressInterval) clearInterval(progressInterval);
|
|
setExporting(false);
|
|
setExportProgress(0);
|
|
}
|
|
}
|
|
|
|
// ── Import ────────────────────────────────────────────────────────────────
|
|
|
|
function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
|
|
// Reset the input so the same file can be selected again
|
|
event.target.value = '';
|
|
|
|
if (!file.name.endsWith('.json')) {
|
|
toast.error('Please select a JSON file');
|
|
return;
|
|
}
|
|
|
|
setPendingImportFile(file);
|
|
setImportResult(null);
|
|
setImportError(null);
|
|
setConfirmImport(true);
|
|
}
|
|
|
|
async function executeImport() {
|
|
if (!pendingImportFile) return;
|
|
|
|
setConfirmImport(false);
|
|
setImporting(true);
|
|
setImportProgress(0);
|
|
setImportError(null);
|
|
let progressInterval: ReturnType<typeof setInterval> | undefined;
|
|
|
|
try {
|
|
const text = await pendingImportFile.text();
|
|
const data = JSON.parse(text);
|
|
|
|
if (!data.version) {
|
|
setImportError('Invalid file. Select a valid Project E export and try again.');
|
|
toast.error('Invalid file — missing version field. Is this a valid Project E export?');
|
|
return;
|
|
}
|
|
|
|
// Simulate progress
|
|
progressInterval = setInterval(() => {
|
|
setImportProgress((prev) => Math.min(prev + 5, 90));
|
|
}, 300);
|
|
|
|
const response = await fetch('/api/import', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(data),
|
|
});
|
|
|
|
clearInterval(progressInterval);
|
|
progressInterval = undefined;
|
|
|
|
if (!response.ok) {
|
|
let message = 'Import failed. Please try again.';
|
|
try {
|
|
const errorData = await response.json();
|
|
message = errorData.error?.message || message;
|
|
} catch {
|
|
// Use the default message when the server does not return JSON.
|
|
}
|
|
setImportError(message);
|
|
toast.error(message);
|
|
return;
|
|
}
|
|
|
|
const result: ImportResult = await response.json();
|
|
setImportProgress(100);
|
|
setImportResult(result);
|
|
setPendingImportFile(null);
|
|
|
|
if (result.success) {
|
|
toast.success(`Import complete: ${result.imported} records imported`);
|
|
} else {
|
|
toast.warning(
|
|
`Import finished with errors: ${result.imported} imported, ${result.failed} failed`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to import:', error);
|
|
setImportError('Failed to parse or import the file. Please try again.');
|
|
toast.error('Failed to parse import file. Please check the format.');
|
|
} finally {
|
|
if (progressInterval) clearInterval(progressInterval);
|
|
setImporting(false);
|
|
}
|
|
}
|
|
|
|
// ── Collection toggle ─────────────────────────────────────────────────────
|
|
|
|
function toggleCollection(name: string) {
|
|
setSelectedCollections((prev) =>
|
|
prev.includes(name) ? prev.filter((c) => c !== name) : [...prev, name]
|
|
);
|
|
}
|
|
|
|
function toggleAllCollections() {
|
|
if (selectedCollections.length === DEFAULT_COLLECTIONS.length) {
|
|
setSelectedCollections([]);
|
|
} else {
|
|
setSelectedCollections(DEFAULT_COLLECTIONS.map((c) => c.name));
|
|
}
|
|
}
|
|
|
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Import & Export</CardTitle>
|
|
<CardDescription>Backup your data or restore from a previous export.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* ── Export Section ─────────────────────────────────────────────────── */}
|
|
<div className="rounded-lg border p-4">
|
|
<h3 className="font-semibold">Export data</h3>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Download your data as a JSON file. Choose which collections to include.
|
|
</p>
|
|
|
|
{/* Collection selection */}
|
|
<div className="mt-4 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="select-all"
|
|
checked={selectedCollections.length === DEFAULT_COLLECTIONS.length}
|
|
onCheckedChange={toggleAllCollections}
|
|
/>
|
|
<Label htmlFor="select-all" className="text-sm font-medium">
|
|
Select all
|
|
</Label>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-5">
|
|
{DEFAULT_COLLECTIONS.map((collection) => (
|
|
<div key={collection.name} className="flex items-center gap-2">
|
|
<Checkbox
|
|
id={`export-${collection.name}`}
|
|
checked={selectedCollections.includes(collection.name)}
|
|
onCheckedChange={() => toggleCollection(collection.name)}
|
|
/>
|
|
<Label
|
|
htmlFor={`export-${collection.name}`}
|
|
className="text-sm text-muted-foreground"
|
|
>
|
|
{collection.label}
|
|
</Label>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress */}
|
|
{exporting && (
|
|
<div className="mt-4 space-y-2">
|
|
<Progress value={exportProgress} className="h-2" aria-label={`Export progress: ${exportProgress}%`} />
|
|
<p className="text-xs text-muted-foreground">Exporting... {exportProgress}%</p>
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
onClick={handleExport}
|
|
disabled={exporting || selectedCollections.length === 0}
|
|
className="mt-4"
|
|
>
|
|
{exporting ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Download className="mr-2 h-4 w-4" />
|
|
)}
|
|
{exporting ? 'Exporting...' : 'Export to JSON'}
|
|
</Button>
|
|
{exportError && <p className="mt-2 text-sm text-destructive" role="alert">{exportError}</p>}
|
|
</div>
|
|
|
|
{/* ── Import Section ─────────────────────────────────────────────────── */}
|
|
<div className="rounded-lg border p-4">
|
|
<h3 className="font-semibold">Import data</h3>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Restore from a previously exported JSON file. All existing data will be supplemented.
|
|
</p>
|
|
|
|
{/* Progress */}
|
|
{importing && (
|
|
<div className="mt-4 space-y-2">
|
|
<Progress value={importProgress} className="h-2" aria-label={`Import progress: ${importProgress}%`} />
|
|
<p className="text-xs text-muted-foreground">Importing... {importProgress}%</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Import results */}
|
|
{importResult && (
|
|
<div className="mt-4 rounded-lg border p-3">
|
|
<div className="flex items-center gap-2">
|
|
{importResult.success ? (
|
|
<Check className="h-4 w-4 text-green-500" />
|
|
) : (
|
|
<AlertCircle className="h-4 w-4 text-yellow-500" />
|
|
)}
|
|
<span className="text-sm font-medium">
|
|
{importResult.imported} imported, {importResult.failed} failed
|
|
</span>
|
|
</div>
|
|
|
|
{importResult.results.length > 0 && (
|
|
<div className="mt-3 space-y-2">
|
|
{importResult.results.map((r) => (
|
|
<div key={r.collection} className="flex items-center justify-between text-sm">
|
|
<span className="capitalize text-muted-foreground">{r.collection}</span>
|
|
<span>
|
|
{r.imported} ok
|
|
{r.failed > 0 && (
|
|
<span className="text-destructive">, {r.failed} failed</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{importResult.results.some((r) => r.errors.length > 0) && (
|
|
<div className="mt-3">
|
|
<p className="text-xs font-medium text-destructive">Errors:</p>
|
|
<div className="mt-1 max-h-32 overflow-auto" role="list" aria-label="Import errors">
|
|
{importResult.results
|
|
.flatMap((r) => r.errors)
|
|
.slice(0, 10)
|
|
.map((error, i) => (
|
|
<p key={i} className="text-xs text-muted-foreground">
|
|
{error}
|
|
</p>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<label className="mt-4 inline-block">
|
|
<input
|
|
type="file"
|
|
accept=".json"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
disabled={importing}
|
|
/>
|
|
<Button variant="outline" disabled={importing} asChild>
|
|
<span>
|
|
{importing ? (
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
)}
|
|
{importing ? 'Importing...' : 'Import from JSON'}
|
|
</span>
|
|
</Button>
|
|
</label>
|
|
{importError && (
|
|
<div className="mt-2 flex items-center gap-3 text-sm text-destructive" role="alert">
|
|
<span>{importError}</span>
|
|
{pendingImportFile && <Button variant="outline" size="sm" onClick={executeImport} disabled={importing}>Retry import</Button>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Import Confirmation Dialog ─────────────────────────────────────── */}
|
|
<AlertDialog open={confirmImport} onOpenChange={setConfirmImport}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Confirm import</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
This will import data from the selected file. Existing records will not be
|
|
overwritten, but new records will be created for each item in the file. Are you sure
|
|
you want to proceed?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel onClick={() => setPendingImportFile(null)}>
|
|
Cancel
|
|
</AlertDialogCancel>
|
|
<AlertDialogAction onClick={executeImport}>
|
|
<Upload className="mr-2 h-4 w-4" />
|
|
Import
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|