ui(deploy): Identity & Artifact section with filename auto-derive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 22:49:43 +02:00
parent d067490f71
commit 00c7c0cd71
3 changed files with 197 additions and 2 deletions

View File

@@ -5,3 +5,51 @@
padding: 16px 24px;
min-height: 100%;
}
.section {
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
background: var(--bg-surface);
}
.configGrid {
display: grid;
grid-template-columns: 180px 1fr;
gap: 10px 16px;
align-items: center;
margin-top: 8px;
}
.configLabel {
color: var(--text-muted);
font-size: 13px;
}
.readOnlyValue {
color: var(--text-primary);
font-size: 14px;
}
.fileRow {
display: flex;
align-items: center;
gap: 10px;
}
.stagedJar {
color: var(--amber);
font-size: 13px;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

View File

@@ -0,0 +1,107 @@
import { useRef } from 'react';
import { SectionHeader, Input, MonoText, Button, Badge } from '@cameleer/design-system';
import type { App, AppVersion } from '../../../api/queries/admin/apps';
import type { Environment } from '../../../api/queries/admin/environments';
import styles from './AppDeploymentPage.module.css';
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100);
}
function formatBytes(bytes: number): string {
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
interface IdentitySectionProps {
mode: 'net-new' | 'deployed';
environment: Environment;
app: App | null;
currentVersion: AppVersion | null;
name: string;
onNameChange: (next: string) => void;
stagedJar: File | null;
onStagedJarChange: (file: File | null) => void;
deploying: boolean;
}
export function IdentitySection({
mode, environment, app, currentVersion,
name, onNameChange, stagedJar, onStagedJarChange, deploying,
}: IdentitySectionProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const slug = app?.slug ?? slugify(name);
const externalUrl = (() => {
const defaults = environment.defaultContainerConfig ?? {};
const domain = String(defaults.routingDomain ?? '');
if (defaults.routingMode === 'subdomain' && domain) {
return `https://${slug || '...'}-${environment.slug}.${domain}/`;
}
const base = domain ? `https://${domain}` : window.location.origin;
return `${base}/${environment.slug}/${slug || '...'}/`;
})();
return (
<div className={styles.section}>
<SectionHeader>Identity & Artifact</SectionHeader>
<div className={styles.configGrid}>
<span className={styles.configLabel}>Application Name</span>
{mode === 'deployed' ? (
<span className={styles.readOnlyValue}>{name}</span>
) : (
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g. Payment Gateway"
disabled={deploying}
/>
)}
<span className={styles.configLabel}>Slug</span>
<MonoText size="sm">{slug || '...'}</MonoText>
<span className={styles.configLabel}>Environment</span>
<Badge label={environment.displayName} color="auto" />
<span className={styles.configLabel}>External URL</span>
<MonoText size="sm">{externalUrl}</MonoText>
{currentVersion && (
<>
<span className={styles.configLabel}>Current Version</span>
<span className={styles.readOnlyValue}>
v{currentVersion.version} · {currentVersion.jarFilename} · {formatBytes(currentVersion.jarSizeBytes)}
</span>
</>
)}
<span className={styles.configLabel}>Application JAR</span>
<div className={styles.fileRow}>
<input
ref={fileInputRef}
type="file"
accept=".jar"
className={styles.visuallyHidden}
onChange={(e) => onStagedJarChange(e.target.files?.[0] ?? null)}
/>
<Button
size="sm"
variant="secondary"
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={deploying}
>
{currentVersion ? 'Change JAR' : 'Select JAR'}
</Button>
{stagedJar && (
<span className={styles.stagedJar}>
staged: {stagedJar.name} ({formatBytes(stagedJar.size)})
</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,8 +1,11 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useLocation } from 'react-router';
import { useEnvironmentStore } from '../../../api/environment-store';
import { useEnvironments } from '../../../api/queries/admin/environments';
import { useApps } from '../../../api/queries/admin/apps';
import { useApps, useAppVersions } from '../../../api/queries/admin/apps';
import { PageLoader } from '../../../components/PageLoader';
import { IdentitySection } from './IdentitySection';
import { deriveAppName } from './utils/deriveAppName';
import styles from './AppDeploymentPage.module.css';
export default function AppDeploymentPage() {
@@ -15,12 +18,49 @@ export default function AppDeploymentPage() {
const isNetNew = location.pathname.endsWith('/apps/new');
const app = isNetNew ? null : apps.find((a) => a.slug === appId) ?? null;
const env = environments.find((e) => e.slug === selectedEnv);
const { data: versions = [] } = useAppVersions(selectedEnv, app?.slug);
const currentVersion = versions.slice().sort((a, b) => b.version - a.version)[0] ?? null;
// Form state
const [name, setName] = useState('');
const [stagedJar, setStagedJar] = useState<File | null>(null);
const lastDerivedRef = useRef<string>('');
// Initialize name when app loads
useEffect(() => {
if (app) setName(app.displayName);
}, [app]);
// Auto-derive from staged JAR (net-new mode only, don't overwrite manual edits)
useEffect(() => {
if (!stagedJar || app) return;
const derived = deriveAppName(stagedJar.name);
if (!name || name === lastDerivedRef.current) {
setName(derived);
lastDerivedRef.current = derived;
}
}, [stagedJar, app, name]);
if (envLoading || appsLoading) return <PageLoader />;
if (!env) return <div>Select an environment first.</div>;
const mode: 'net-new' | 'deployed' = app ? 'deployed' : 'net-new';
return (
<div className={styles.container}>
<h2>{app ? app.displayName : 'Create Application'}</h2>
{/* Identity section, tabs, primary button land in subsequent tasks */}
<IdentitySection
mode={mode}
environment={env}
app={app}
currentVersion={currentVersion}
name={name}
onNameChange={setName}
stagedJar={stagedJar}
onStagedJarChange={setStagedJar}
deploying={false}
/>
</div>
);
}