initial
This commit is contained in:
120
apps/web/src/components/AddProjectModal.tsx
Normal file
120
apps/web/src/components/AddProjectModal.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { AvailableProject } from '@/api/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onAdded: () => void;
|
||||
}
|
||||
|
||||
export function AddProjectModal({ open, onOpenChange, onAdded }: Props) {
|
||||
const [available, setAvailable] = useState<AvailableProject[] | null>(null);
|
||||
const [customPath, setCustomPath] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
setCustomPath('');
|
||||
setAvailable(null);
|
||||
api.projects
|
||||
.available()
|
||||
.then(setAvailable)
|
||||
.catch((err) =>
|
||||
setError(err instanceof Error ? err.message : 'failed to list available projects')
|
||||
);
|
||||
}, [open]);
|
||||
|
||||
async function add(path: string) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.projects.add({ path });
|
||||
onAdded();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'failed to add');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Pick from detected repos in /opt or type a path.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border max-h-64 overflow-y-auto">
|
||||
{available === null && (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">Loading…</div>
|
||||
)}
|
||||
{available && available.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
No undiscovered repos in /opt.
|
||||
</div>
|
||||
)}
|
||||
{available?.map((p) => (
|
||||
<button
|
||||
key={p.path}
|
||||
disabled={busy}
|
||||
onClick={() => void add(p.path)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-muted disabled:opacity-50 border-b last:border-b-0"
|
||||
>
|
||||
<div className="text-sm font-medium">{p.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{p.path}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="custom-path">Custom path</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="custom-path"
|
||||
placeholder="/opt/some-repo"
|
||||
value={customPath}
|
||||
onChange={(e) => setCustomPath(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => void add(customPath.trim())}
|
||||
disabled={busy || !customPath.trim()}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user