diff --git a/ui/src/pages/AppsTab/AppsTab.module.css b/ui/src/pages/AppsTab/AppsTab.module.css index 337d4440..55117502 100644 --- a/ui/src/pages/AppsTab/AppsTab.module.css +++ b/ui/src/pages/AppsTab/AppsTab.module.css @@ -10,20 +10,63 @@ margin-bottom: 12px; } -.createForm { +/* Create modal */ +.createModal { + display: flex; + flex-direction: column; + gap: 14px; +} + +.createField { + display: flex; + flex-direction: column; + gap: 4px; +} + +.createLabel { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); +} + +.createLabelHint { + font-weight: 400; + font-style: italic; + margin-left: 6px; +} + +.fileRow { display: flex; align-items: center; - gap: 8px; - padding: 12px; - margin-bottom: 12px; - border: 1px solid var(--border-subtle); - border-radius: 6px; - background: var(--bg-raised); + gap: 10px; +} + +.fileName { + font-size: 12px; + color: var(--text-primary); + font-family: var(--font-mono); +} + +.deployToggle { + display: flex; + align-items: center; + gap: 10px; + font-size: 12px; + color: var(--text-primary); +} + +.stepIndicator { + font-size: 12px; + color: var(--accent, #6c7aff); + font-style: italic; } .createActions { display: flex; gap: 8px; + justify-content: flex-end; + padding-top: 8px; + border-top: 1px solid var(--border-subtle); } .nativeSelect { diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index 5efa92ec..18aa411e 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -5,6 +5,7 @@ import { Button, DataTable, Input, + Modal, MonoText, SectionHeader, Select, @@ -53,6 +54,14 @@ const STATUS_COLORS: Record(); const selectedEnv = useEnvironmentStore((s) => s.environment); @@ -72,12 +81,7 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde const { data: allApps = [], isLoading: allLoading } = useAllApps(); const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]); const { data: envApps = [], isLoading: envLoading } = useApps(envId); - - const [creating, setCreating] = useState(false); - const [newSlug, setNewSlug] = useState(''); - const [newDisplayName, setNewDisplayName] = useState(''); - const [newEnvId, setNewEnvId] = useState(''); - const createApp = useCreateApp(); + const [createOpen, setCreateOpen] = useState(false); const apps = selectedEnv ? envApps : allApps; const isLoading = selectedEnv ? envLoading : allLoading; @@ -107,41 +111,146 @@ function AppListView({ selectedEnv, environments }: { selectedEnv: string | unde }, ], [selectedEnv]); - async function handleCreate() { - if (!newSlug.trim() || !newDisplayName.trim() || !newEnvId) return; - try { - await createApp.mutateAsync({ environmentId: newEnvId, slug: newSlug.trim(), displayName: newDisplayName.trim() }); - toast({ title: 'App created', description: newSlug.trim(), variant: 'success' }); - setCreating(false); setNewSlug(''); setNewDisplayName(''); setNewEnvId(''); - } catch { toast({ title: 'Failed to create app', variant: 'error', duration: 86_400_000 }); } - } - if (isLoading) return ; return (
- +
- {creating && ( -
- - setNewSlug(e.target.value)} /> - setNewDisplayName(e.target.value)} /> -
- - + navigate(`/apps/${row.id}`)} /> + setCreateOpen(false)} + environments={environments} + defaultEnvId={envId} + onCreated={(appId) => { setCreateOpen(false); navigate(`/apps/${appId}`); }} + /> +
+ ); +} + +// ═══════════════════════════════════════════════════════════════════ +// CREATE APP MODAL +// ═══════════════════════════════════════════════════════════════════ + +function CreateAppModal({ open, onClose, environments, defaultEnvId, onCreated }: { + open: boolean; + onClose: () => void; + environments: Environment[]; + defaultEnvId: string | undefined; + onCreated: (appId: string) => void; +}) { + const { toast } = useToast(); + const createApp = useCreateApp(); + const uploadJar = useUploadJar(); + const createDeployment = useCreateDeployment(); + + const [name, setName] = useState(''); + const [slugEdited, setSlugEdited] = useState(false); + const [slug, setSlug] = useState(''); + const [envId, setEnvId] = useState(''); + const [file, setFile] = useState(null); + const [deploy, setDeploy] = useState(true); + const [busy, setBusy] = useState(false); + const [step, setStep] = useState(''); + const fileInputRef = useRef(null); + + // Reset on open + useEffect(() => { + if (open) { + setName(''); setSlug(''); setSlugEdited(false); setFile(null); + setDeploy(true); setBusy(false); setStep(''); + setEnvId(defaultEnvId || (environments.length > 0 ? environments[0].id : '')); + } + }, [open, defaultEnvId, environments]); + + // Auto-compute slug from name + useEffect(() => { + if (!slugEdited) setSlug(slugify(name)); + }, [name, slugEdited]); + + const canSubmit = name.trim() && slug.trim() && envId && file; + + async function handleSubmit() { + if (!canSubmit) return; + setBusy(true); + try { + // 1. Create app + setStep('Creating app...'); + const app = await createApp.mutateAsync({ environmentId: envId, slug: slug.trim(), displayName: name.trim() }); + + // 2. Upload JAR + setStep('Uploading JAR...'); + const version = await uploadJar.mutateAsync({ appId: app.id, file: file! }); + + // 3. Deploy (if requested) + if (deploy) { + setStep('Starting deployment...'); + await createDeployment.mutateAsync({ appId: app.id, appVersionId: version.id, environmentId: envId }); + } + + toast({ title: 'App created and deployed', description: name.trim(), variant: 'success' }); + onCreated(app.id); + } catch (e) { + toast({ title: 'Failed: ' + step, description: e instanceof Error ? e.message : 'Unknown error', variant: 'error', duration: 86_400_000 }); + } finally { + setBusy(false); + setStep(''); + } + } + + return ( + +
+
+ + setName(e.target.value)} placeholder="e.g. Payment Gateway" disabled={busy} /> +
+ +
+ + { setSlug(e.target.value); setSlugEdited(true); }} disabled={busy} /> +
+ +
+ + setFile(e.target.files?.[0] ?? null)} /> + + {file && {file.name} ({formatBytes(file.size)})}
- )} - navigate(`/apps/${row.id}`)} /> -
+ +
+
+ setDeploy(!deploy)} disabled={busy} /> + {deploy ? 'Deploy immediately after upload' : 'Upload only (deploy later)'} +
+
+ + {step &&
{step}
} + +
+ + +
+
+ ); }