Files
boocode/apps/web/src/components/CreateProjectModal.tsx
indifferentketchup 4a9f207fe8 v1.5.1: bootstrap fixes (git + ssh in container, Tailscale host rewrite, /opt/projects label)
- Dockerfile: install git + openssh-client in runtime image; pre-populate
  /root/.ssh/known_hosts with the Tailscale ssh-keyscan for
  100.114.205.53:2222 (Gitea SSH). Without these, the bootstrap push
  step from inside the container fails with "command not found" or
  host-key prompts.
- docker-compose.yml: mount ./secrets/boocode_gitea as
  /root/.ssh/id_ed25519:ro so the container can authenticate to Gitea
  over SSH for the initial push.
- .gitignore: add secrets/ so the keypair never lands in the repo.
- project_bootstrap.ts: rewrite the Gitea-returned ssh_url's hostname
  from git.indifferentketchup.com to 100.114.205.53 before adding it
  as origin, so the push hits the Tailscale interface that the
  known_hosts entry covers.
- CreateProjectModal.tsx: preview label now reads "Folder:
  /opt/projects/<name>" to match the new BOOTSTRAP_ROOT (was /opt/).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:11:39 +00:00

172 lines
5.2 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { api } from '@/api/client';
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;
}
function previewFolderName(raw: string): string {
return raw
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 64);
}
export function CreateProjectModal({ open, onOpenChange }: Props) {
const navigate = useNavigate();
const [name, setName] = useState('');
const [commitMessage, setCommitMessage] = useState('Initial commit');
const [visibility, setVisibility] = useState<'private' | 'public'>('private');
const [createRemote, setCreateRemote] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setName('');
setCommitMessage('Initial commit');
setVisibility('private');
setCreateRemote(true);
setBusy(false);
setError(null);
}, [open]);
const folderPreview = previewFolderName(name);
async function submit() {
if (!folderPreview) {
setError('Project name must contain at least one letter or digit.');
return;
}
setBusy(true);
setError(null);
try {
const result = await api.projects.create({
name: name.trim(),
commit_message: commitMessage.trim() || 'Initial commit',
visibility,
create_gitea_remote: createRemote,
});
const warnings = result.bootstrap.warnings;
if (warnings.length > 0) {
toast.warning(`Project created with warnings: ${warnings.join('; ')}`);
} else {
toast.success(`Project "${result.project.name}" created`);
}
onOpenChange(false);
navigate(`/project/${result.project.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'failed to create project');
} finally {
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<DialogDescription>
Creates a folder under /opt with a git repo, .gitignore, and optionally a Gitea remote.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="proj-name">Project name</Label>
<Input
id="proj-name"
placeholder="My new project"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={busy}
autoFocus
/>
{name && (
<div className="text-xs text-muted-foreground font-mono">
Folder: /opt/projects/{folderPreview || <span className="text-destructive">(empty after sanitization)</span>}
</div>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="commit-msg">Initial commit message</Label>
<Input
id="commit-msg"
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
disabled={busy}
/>
</div>
<div className="space-y-1.5">
<Label>Visibility</Label>
<div className="flex gap-4 text-sm">
<label className="flex items-center gap-1.5">
<input
type="radio"
checked={visibility === 'private'}
onChange={() => setVisibility('private')}
disabled={busy}
/>
Private
</label>
<label className="flex items-center gap-1.5">
<input
type="radio"
checked={visibility === 'public'}
onChange={() => setVisibility('public')}
disabled={busy}
/>
Public
</label>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={createRemote}
onChange={(e) => setCreateRemote(e.target.checked)}
disabled={busy}
/>
Create Gitea remote and push
</label>
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
Cancel
</Button>
<Button onClick={() => void submit()} disabled={busy || !folderPreview}>
{busy ? 'Creating…' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}