410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
1716 lines
54 KiB
Svelte
1716 lines
54 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from 'svelte';
|
|
import { goto, beforeNavigate } from '$app/navigation';
|
|
import type { HookKinds, PluginWorkloadInput } from '$lib/types';
|
|
import type { RedeployTrigger } from '$lib/api';
|
|
import * as api from '$lib/api';
|
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
import TriggerKindForm, {
|
|
createTriggerKindFormState,
|
|
isTriggerFormValid,
|
|
buildTriggerInput
|
|
} from '$lib/components/TriggerKindForm.svelte';
|
|
import SourceKindPicker from '$lib/components/workload/SourceKindPicker.svelte';
|
|
import ImageSourceForm from '$lib/components/workload/ImageSourceForm.svelte';
|
|
import ComposeSourceForm from '$lib/components/workload/ComposeSourceForm.svelte';
|
|
import StaticSourceForm from '$lib/components/workload/StaticSourceForm.svelte';
|
|
import DockerfileSourceForm from '$lib/components/workload/DockerfileSourceForm.svelte';
|
|
import AppManifest, { type ManifestRow } from '$lib/components/workload/AppManifest.svelte';
|
|
import WizardRail from '$lib/components/wizard/WizardRail.svelte';
|
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
|
import {
|
|
emptyImageState,
|
|
emptyComposeState,
|
|
emptyStaticState,
|
|
emptyDockerfileState,
|
|
seedImageState,
|
|
seedComposeState,
|
|
seedStaticState,
|
|
seedDockerfileState,
|
|
imageToConfig,
|
|
composeToConfig,
|
|
staticToConfig,
|
|
dockerfileToConfig,
|
|
stringifyConfig,
|
|
type ImageFormState,
|
|
type ComposeFormState,
|
|
type StaticFormState,
|
|
type DockerfileFormState,
|
|
type GitSourceState
|
|
} from '$lib/workload/sourceForms';
|
|
import { t } from '$lib/i18n';
|
|
|
|
// `triggers` is no longer hardcoded into the workload row — the
|
|
// kind list is kept on `kinds` only for reference but the wizard
|
|
// now composes a standalone Trigger record (or picks one) and
|
|
// binds it after the workload is created.
|
|
let kinds = $state<HookKinds>({ sources: [], triggers: [] });
|
|
let name = $state('');
|
|
let sourceKind = $state('image');
|
|
let sourceConfig = $state('{}');
|
|
let publicSubdomain = $state('');
|
|
let publicDomain = $state('');
|
|
let publicPort = $state(0);
|
|
|
|
// Trigger UX modes — three branches:
|
|
// inline → create a new trigger inline; bind after workload create.
|
|
// pick → select an existing trigger; bind after workload create.
|
|
// skip → create the workload without any binding.
|
|
type TriggerMode = 'inline' | 'pick' | 'skip';
|
|
let triggerMode = $state<TriggerMode>('inline');
|
|
let triggerForm = $state(createTriggerKindFormState({ kind: 'registry' }));
|
|
|
|
// Existing-trigger picker. `existingTriggers` is loaded lazily on
|
|
// mount; if the request fails the operator can still create one
|
|
// inline or skip altogether — we don't block the wizard.
|
|
let existingTriggers = $state<RedeployTrigger[]>([]);
|
|
let pickedTriggerId = $state('');
|
|
|
|
// ── Source-config form state ──────────────────────────────────────
|
|
// One state object per source kind, modelled by the shared (pure,
|
|
// unit-tested) `sourceForms.ts` module. The form bodies live in
|
|
// dedicated components under $lib/components/workload/ that bind these
|
|
// objects; serialization to the `source_config` POST body is done here
|
|
// via the module's serializers so the shape stays byte-identical to the
|
|
// legacy inline helpers. The advancedJson toggle swaps the chosen form
|
|
// component for the raw-JSON editor.
|
|
let advancedJson = $state(false);
|
|
let imageState = $state<ImageFormState>(emptyImageState());
|
|
let composeState = $state<ComposeFormState>(emptyComposeState());
|
|
let staticState = $state<StaticFormState>(emptyStaticState());
|
|
let dockerfileState = $state<DockerfileFormState>(emptyDockerfileState());
|
|
|
|
// ── Discovery transient status (lifted out of the source forms) ───
|
|
// StaticSourceForm / DockerfileSourceForm (and the StaticDiscoveryWizard
|
|
// they embed) unmount whenever the operator flips Advanced JSON or
|
|
// switches source kind, which would normally reset the loaded folder
|
|
// tree + the detect/test pills + the mode-override sentinel. Holding that
|
|
// transient status HERE (the page does not unmount on those toggles) and
|
|
// binding it down via the wizard's optional bindable props makes it
|
|
// persist for the session: re-opening the form shows the already-loaded
|
|
// tree and the detect/test results again. The picker open-flags + items
|
|
// stay local to the wizard (modals are transient; re-opening reloads).
|
|
//
|
|
// Static and dockerfile keep SEPARATE status objects because they are
|
|
// distinct sources sharing only the git field VALUES — never their
|
|
// discovery results. The git field values themselves already persist via
|
|
// the staticState/dockerfileState objects above; this is only the status.
|
|
let staticDetectStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
|
|
let staticDetectError = $state('');
|
|
let staticTestStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
|
|
let staticTestError = $state('');
|
|
let staticTree = $state<api.FolderEntry[]>([]);
|
|
let staticModeUserOverride = $state(false);
|
|
let staticTreeLoaded = $state(false);
|
|
|
|
let dockerfileDetectStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
|
|
let dockerfileDetectError = $state('');
|
|
let dockerfileTestStatus = $state<'idle' | 'pending' | 'ok' | 'error'>('idle');
|
|
let dockerfileTestError = $state('');
|
|
|
|
const useComposeForm = $derived(sourceKind === 'compose' && !advancedJson);
|
|
const useImageForm = $derived(sourceKind === 'image' && !advancedJson);
|
|
const useStaticForm = $derived(sourceKind === 'static' && !advancedJson);
|
|
const useDockerfileForm = $derived(sourceKind === 'dockerfile' && !advancedJson);
|
|
|
|
// Image-ref conflict detection. The ImageSourceForm runs the debounced
|
|
// /api/discovery/image/conflicts lookup and writes its results back into
|
|
// this triplet (bound below). The submit gate reads them: the first
|
|
// submit while conflicts are present is blocked + acknowledged; a second
|
|
// click proceeds.
|
|
let imageConflicts = $state<api.ImageConflict[]>([]);
|
|
let imageConflictAcknowledged = $state(false);
|
|
let imageConflictBlocked = $state(false);
|
|
|
|
// Registry list for the image registry_name picker. Loaded lazily on
|
|
// mount; failure to load is non-fatal — the field falls back to a plain
|
|
// text input via the empty-list path so the form still works.
|
|
let registries = $state<{ name: string; url: string }[]>([]);
|
|
|
|
let loading = $state(true);
|
|
let submitting = $state(false);
|
|
let error = $state('');
|
|
|
|
// Unsaved-changes guard state. `submitted` flips true around a
|
|
// successful create so the navigation guard does NOT prompt when we
|
|
// programmatically route to the new workload's detail page. It stays
|
|
// true thereafter (the page is being torn down anyway).
|
|
let submitted = $state(false);
|
|
|
|
// Cache the schema sample per kind so flipping the dropdown back and
|
|
// forth doesn't re-fetch. Plugins can grow new fields server-side
|
|
// without a frontend rebuild — that's the whole point of routing the
|
|
// initial form body through /api/hooks/kinds/{kind}/schema instead of
|
|
// hardcoding samples in this file.
|
|
const schemaCache = new Map<string, string>();
|
|
async function fetchSampleJSON(kind: string): Promise<string> {
|
|
if (!kind) return '{}';
|
|
const cached = schemaCache.get(kind);
|
|
if (cached !== undefined) return cached;
|
|
try {
|
|
const res = await api.getHookKindSchema(kind);
|
|
const text = JSON.stringify(res.sample ?? {}, null, 2);
|
|
schemaCache.set(kind, text);
|
|
return text;
|
|
} catch {
|
|
return sourceConfigSample(kind) || '{}';
|
|
}
|
|
}
|
|
|
|
// Seed the per-kind state object from a source_config JSON blob. Thin
|
|
// wrappers over the shared `sourceForms.ts` seeders — only the chosen
|
|
// kind's object is reseeded so switching kinds preserves the others.
|
|
function seedFormState(kind: string, jsonText: string): void {
|
|
if (kind === 'compose') composeState = seedComposeState(jsonText);
|
|
else if (kind === 'image') imageState = seedImageState(jsonText);
|
|
else if (kind === 'static') staticState = seedStaticState(jsonText);
|
|
else if (kind === 'dockerfile') dockerfileState = seedDockerfileState(jsonText);
|
|
}
|
|
|
|
// Serialize the per-kind state object back to the canonical JSON string
|
|
// for the Advanced-JSON editor view. env/volumes (image) and storage_* /
|
|
// unknown keys (static/dockerfile) are preserved from the current
|
|
// sourceConfig via the module serializers.
|
|
function formStateToJSON(kind: string): string {
|
|
if (kind === 'compose') return stringifyConfig(composeToConfig(composeState));
|
|
if (kind === 'image') return stringifyConfig(imageToConfig(imageState, sourceConfig));
|
|
if (kind === 'static') return stringifyConfig(staticToConfig(staticState, sourceConfig));
|
|
if (kind === 'dockerfile') return stringifyConfig(dockerfileToConfig(dockerfileState, sourceConfig));
|
|
return sourceConfig;
|
|
}
|
|
|
|
onMount(async () => {
|
|
try {
|
|
kinds = await api.listHookKinds();
|
|
if (kinds.sources.length > 0 && !kinds.sources.includes(sourceKind)) {
|
|
sourceKind = kinds.sources[0];
|
|
}
|
|
sourceConfig = await fetchSampleJSON(sourceKind);
|
|
seedFormState(sourceKind, sourceConfig);
|
|
// Best-effort registry list for the picker. If the user
|
|
// is on an installation without registries configured,
|
|
// the field gracefully renders as a free-text input.
|
|
try {
|
|
registries = (await api.listRegistries()).map((r) => ({ name: r.name, url: r.url }));
|
|
} catch {
|
|
registries = [];
|
|
}
|
|
// Best-effort fetch of existing triggers — feeds the
|
|
// "Pick existing" mode. Failure leaves the picker empty
|
|
// and the operator can still create one inline.
|
|
try {
|
|
existingTriggers = await api.listTriggers();
|
|
} catch {
|
|
existingTriggers = [];
|
|
}
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : $t('apps.new.loadingKinds');
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
});
|
|
|
|
function sourceConfigSample(kind: string): string {
|
|
switch (kind) {
|
|
case 'image':
|
|
return JSON.stringify(
|
|
{
|
|
image: 'registry.example.com/owner/app',
|
|
registry_name: '',
|
|
port: 8080,
|
|
healthcheck: '/healthz',
|
|
env: {},
|
|
volumes: [],
|
|
cpu_limit: 0,
|
|
memory_limit: 0,
|
|
default_tag: 'latest'
|
|
},
|
|
null,
|
|
2
|
|
);
|
|
case 'compose':
|
|
return JSON.stringify(
|
|
{
|
|
compose_yaml: 'services:\n web:\n image: nginx:alpine\n ports:\n - "80"\n',
|
|
compose_project_name: ''
|
|
},
|
|
null,
|
|
2
|
|
);
|
|
case 'static':
|
|
return JSON.stringify(
|
|
{
|
|
provider: 'gitea',
|
|
base_url: 'https://git.example.com',
|
|
repo_owner: 'owner',
|
|
repo_name: 'pages',
|
|
branch: 'main',
|
|
folder_path: '',
|
|
mode: 'static',
|
|
render_markdown: false
|
|
},
|
|
null,
|
|
2
|
|
);
|
|
case 'dockerfile':
|
|
return JSON.stringify(
|
|
{
|
|
provider: 'gitea',
|
|
base_url: 'https://git.example.com',
|
|
repo_owner: 'owner',
|
|
repo_name: 'myservice',
|
|
branch: 'main',
|
|
context_path: '',
|
|
dockerfile_path: 'Dockerfile',
|
|
port: 8080
|
|
},
|
|
null,
|
|
2
|
|
);
|
|
default:
|
|
return '{}';
|
|
}
|
|
}
|
|
|
|
// Tracks the previously-selected sourceKind across an onSourceChange
|
|
// transition. Used to detect static ↔ dockerfile (both git-clone
|
|
// sources sharing the static* state vars for provider/repo/branch/
|
|
// token) so we can preserve those fields across the switch instead
|
|
// of overwriting them with sample placeholders.
|
|
//
|
|
// Initialized with the same literal as `sourceKind` (intentional
|
|
// mirror, not a snapshot-of-state — the value is updated manually
|
|
// inside onSourceChange, so no Svelte reactivity is needed).
|
|
let prevSourceKind = $state<string>('image');
|
|
|
|
async function onSourceChange() {
|
|
const previous = prevSourceKind;
|
|
const current = sourceKind;
|
|
prevSourceKind = current;
|
|
|
|
const wasGit = previous === 'static' || previous === 'dockerfile';
|
|
const isGit = current === 'static' || current === 'dockerfile';
|
|
|
|
// Cross-git transition: snapshot the shared git-discovery slice
|
|
// before fetching the new sample so we can restore after seed.
|
|
// Without this, switching static → dockerfile (or back) clobbers
|
|
// the user's already-typed provider/baseURL/repo/branch/token
|
|
// with the placeholder values baked into the sample blob. The
|
|
// slice lives on whichever git state object was active before.
|
|
let gitSnapshot: GitSourceState | null = null;
|
|
if (wasGit && isGit) {
|
|
const prevState = previous === 'dockerfile' ? dockerfileState : staticState;
|
|
gitSnapshot = {
|
|
provider: prevState.provider,
|
|
baseURL: prevState.baseURL,
|
|
repoOwner: prevState.repoOwner,
|
|
repoName: prevState.repoName,
|
|
branch: prevState.branch,
|
|
accessToken: prevState.accessToken
|
|
};
|
|
}
|
|
|
|
sourceConfig = await fetchSampleJSON(current);
|
|
// Switching INTO any kind seeds its form state from the sample so
|
|
// the operator sees something sensible immediately. Switching OUT
|
|
// leaves the other kinds' state alone — re-entry restores it.
|
|
seedFormState(current, sourceConfig);
|
|
advancedJson = false;
|
|
|
|
// Restore the shared git fields if we were just on a git source and
|
|
// are switching to another git source. Their values are still valid;
|
|
// the new kind's seed only matters for kind-specific fields
|
|
// (folder/mode for static; context/dockerfile/port for dockerfile).
|
|
if (gitSnapshot) {
|
|
const target = current === 'dockerfile' ? dockerfileState : staticState;
|
|
target.provider = gitSnapshot.provider;
|
|
target.baseURL = gitSnapshot.baseURL;
|
|
target.repoOwner = gitSnapshot.repoOwner;
|
|
target.repoName = gitSnapshot.repoName;
|
|
target.branch = gitSnapshot.branch;
|
|
target.accessToken = gitSnapshot.accessToken;
|
|
// The TARGET kind's git fields were just overwritten with the other
|
|
// source's values, so any persisted detect/test result (or loaded
|
|
// tree) for the target no longer corresponds to what's shown.
|
|
// Invalidate it — same "fields changed → invalidate downstream"
|
|
// philosophy the wizard's own selectRepo/selectBranch follow. The
|
|
// SOURCE kind's status is left intact so flipping back restores it.
|
|
invalidateDiscoveryStatus(current);
|
|
// Keep sourceConfig in sync so the Advanced JSON view reflects
|
|
// the actual (preserved) state, not the sample placeholders.
|
|
sourceConfig = formStateToJSON(current);
|
|
}
|
|
}
|
|
|
|
// Reset the lifted transient discovery status for a git source kind. Used
|
|
// when a cross-git switch overwrites that kind's repo coordinates, so a
|
|
// stale "connection OK" pill / loaded tree doesn't linger against fields
|
|
// the user never probed under this kind.
|
|
function invalidateDiscoveryStatus(kind: string): void {
|
|
if (kind === 'static') {
|
|
staticDetectStatus = 'idle';
|
|
staticDetectError = '';
|
|
staticTestStatus = 'idle';
|
|
staticTestError = '';
|
|
staticTree = [];
|
|
staticTreeLoaded = false;
|
|
} else if (kind === 'dockerfile') {
|
|
dockerfileDetectStatus = 'idle';
|
|
dockerfileDetectError = '';
|
|
dockerfileTestStatus = 'idle';
|
|
dockerfileTestError = '';
|
|
}
|
|
}
|
|
|
|
// Toggle between the kind-aware form and the raw JSON editor.
|
|
// Direction matters: going to Advanced JSON commits the form fields
|
|
// into sourceConfig first so the user sees the same data they were
|
|
// editing; going back to the form re-seeds from whatever they typed
|
|
// in the JSON so manual JSON edits aren't lost.
|
|
function toggleAdvancedJSON() {
|
|
if (!advancedJson) {
|
|
sourceConfig = formStateToJSON(sourceKind);
|
|
advancedJson = true;
|
|
} else {
|
|
seedFormState(sourceKind, sourceConfig);
|
|
advancedJson = false;
|
|
}
|
|
}
|
|
|
|
// JSON validity hints — non-blocking, just a small status pill in the
|
|
// editor footer. Server still gets the parse error if the user submits.
|
|
function jsonOk(s: string): boolean {
|
|
try {
|
|
JSON.parse(s);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
const sourceValid = $derived(jsonOk(sourceConfig));
|
|
|
|
const sourceLines = $derived(sourceConfig.split('\n').length);
|
|
const sourceBytes = $derived(new Blob([sourceConfig]).size);
|
|
|
|
// Trigger-step validity. Inline mode requires a complete kind+name+config;
|
|
// pick mode requires a chosen trigger; skip mode is always valid.
|
|
const triggerStepValid = $derived.by(() => {
|
|
if (triggerMode === 'skip') return true;
|
|
if (triggerMode === 'pick') return !!pickedTriggerId;
|
|
return isTriggerFormValid(triggerForm);
|
|
});
|
|
|
|
// ── Manifest summary (Review step) ──────────────────────────────
|
|
// A read-only spec-sheet of the whole workload, rendered below the
|
|
// public-face inputs on the final step. All value-derivation lives here
|
|
// (the page owns the state); AppManifest is pure presentation. Each row
|
|
// is { label, value, mono? } — mono marks machine-readable literals
|
|
// (image refs, repo paths, branches, ports, FQDNs).
|
|
|
|
// Source row value — the kind's 1-2 key identifiers. The kind itself is
|
|
// shown as a badge in the manifest header, so this is just the values.
|
|
const sourceSummary = $derived.by((): { value: string; mono: boolean } => {
|
|
const dash = ' · ';
|
|
if (sourceKind === 'image') {
|
|
const ref = imageState.ref.trim();
|
|
const tag = imageState.defaultTag.trim() || 'latest';
|
|
const reg = imageState.registryName.trim() || $t('apps.new.manifest.registryPublic');
|
|
const refTag = ref ? `${ref}:${tag}` : '';
|
|
return { value: refTag ? `${refTag}${dash}${reg}` : reg, mono: true };
|
|
}
|
|
if (sourceKind === 'compose') {
|
|
const project = composeState.projectName.trim();
|
|
return {
|
|
value: project || $t('apps.new.composeProjectPlaceholder'),
|
|
mono: !!project
|
|
};
|
|
}
|
|
if (sourceKind === 'static') {
|
|
const repo = gitRepoRef(staticState.repoOwner, staticState.repoName, staticState.branch);
|
|
const folder = staticState.folderPath.trim() || $t('apps.new.manifest.folderRoot');
|
|
return { value: `${repo}${dash}${folder}${dash}${staticState.mode}`, mono: true };
|
|
}
|
|
if (sourceKind === 'dockerfile') {
|
|
const repo = gitRepoRef(
|
|
dockerfileState.repoOwner,
|
|
dockerfileState.repoName,
|
|
dockerfileState.branch
|
|
);
|
|
const dfPath = dockerfileState.dockerfilePath.trim() || 'Dockerfile';
|
|
const port =
|
|
typeof dockerfileState.port === 'number' && dockerfileState.port > 0
|
|
? `:${dockerfileState.port}`
|
|
: '';
|
|
return { value: `${repo}${dash}${dfPath}${port}`, mono: true };
|
|
}
|
|
return { value: sourceKind, mono: true };
|
|
});
|
|
|
|
// owner/repo@branch — the canonical git source identifier used by both
|
|
// static and dockerfile sources. Falls back gracefully on empty fields.
|
|
function gitRepoRef(owner: string, name: string, branch: string): string {
|
|
const o = owner.trim();
|
|
const n = name.trim();
|
|
const b = branch.trim() || 'main';
|
|
const slug = o && n ? `${o}/${n}` : o || n || '—';
|
|
return `${slug}@${b}`;
|
|
}
|
|
|
|
// Trigger row value — inline reads the inline form's kind+name; pick reads
|
|
// the chosen existing trigger; skip is "Manual only".
|
|
const triggerSummary = $derived.by((): string => {
|
|
if (triggerMode === 'skip') return $t('apps.new.manifest.triggerManual');
|
|
if (triggerMode === 'pick') {
|
|
const picked = existingTriggers.find((tr) => tr.id === pickedTriggerId);
|
|
if (picked) return `${picked.name} · ${picked.kind}`;
|
|
return $t('apps.new.triggers.pickPlaceholder');
|
|
}
|
|
const nm = triggerForm.name.trim();
|
|
return nm ? `${nm} · ${triggerForm.kind}` : triggerForm.kind;
|
|
});
|
|
|
|
// Public-face row value — FQDN + port, or "Internal only" when nothing set.
|
|
const faceSummary = $derived.by((): { value: string; mono: boolean } => {
|
|
const sub = publicSubdomain.trim();
|
|
const dom = publicDomain.trim();
|
|
const hasPort = typeof publicPort === 'number' && publicPort > 0;
|
|
if (!sub && !dom && !hasPort) {
|
|
return { value: $t('apps.new.manifest.internalOnly'), mono: false };
|
|
}
|
|
const host = [sub, dom].filter(Boolean).join('.');
|
|
const port = hasPort ? `:${publicPort}` : '';
|
|
return { value: `${host || '—'}${port}`, mono: true };
|
|
});
|
|
|
|
const manifestRows = $derived<ManifestRow[]>([
|
|
{ label: $t('apps.new.manifest.name'), value: name.trim() || $t('apps.new.manifest.unnamed') },
|
|
{ label: $t('apps.new.manifest.source'), value: sourceSummary.value, mono: sourceSummary.mono },
|
|
{ label: $t('apps.new.manifest.trigger'), value: triggerSummary },
|
|
{ label: $t('apps.new.manifest.publicFace'), value: faceSummary.value, mono: faceSummary.mono }
|
|
]);
|
|
|
|
// ── Wizard step state ───────────────────────────────────────────
|
|
// The form is split into four progressive steps. `currentStep` is
|
|
// 1-based; `maxReached` tracks the furthest validly-reached step so
|
|
// the rail can jump back (never skip forward past an invalid step).
|
|
// Per-step validity reuses the exact conditions the final submit gate
|
|
// enforced before the wizard split, so nothing regresses.
|
|
const STEP_COUNT = 4;
|
|
let currentStep = $state(1);
|
|
let maxReached = $state(1);
|
|
|
|
const step1Valid = $derived(name.trim() !== '' && sourceKind !== '');
|
|
// Cleared <input type=number> binds to null in Svelte 5 (not 0), and
|
|
// `null > 0` is false — guard against null/NaN/non-positive so a blank
|
|
// port field doesn't pass. Mirrors the legacy dockerfilePortValid check.
|
|
const dockerfilePortValid = $derived(
|
|
typeof dockerfileState.port === 'number' &&
|
|
Number.isFinite(dockerfileState.port) &&
|
|
dockerfileState.port > 0
|
|
);
|
|
// Per-step validity reuses the EXACT conditions the page enforced before
|
|
// the extraction (owner+name for git sources; +port for dockerfile; the
|
|
// raw-JSON path gates on parseability). The stricter sourceForms.ts
|
|
// predicates additionally require base_url, so we keep these inline to
|
|
// avoid regressing the gate.
|
|
const step2Valid = $derived(
|
|
!(
|
|
(!useComposeForm && !useImageForm && !useStaticForm && !useDockerfileForm && !sourceValid) ||
|
|
(useImageForm && !imageState.ref.trim()) ||
|
|
(useStaticForm && (!staticState.repoOwner.trim() || !staticState.repoName.trim())) ||
|
|
(useDockerfileForm &&
|
|
(!dockerfileState.repoOwner.trim() ||
|
|
!dockerfileState.repoName.trim() ||
|
|
!dockerfilePortValid))
|
|
)
|
|
);
|
|
const step3Valid = $derived(triggerStepValid);
|
|
|
|
function stepValid(step: number): boolean {
|
|
if (step === 1) return step1Valid;
|
|
if (step === 2) return step2Valid;
|
|
if (step === 3) return step3Valid;
|
|
return true;
|
|
}
|
|
|
|
// Active step's validity — gates the Next button.
|
|
const canAdvance = $derived(stepValid(currentStep));
|
|
|
|
const stepLabels = $derived([
|
|
$t('apps.new.wizard.stepBasics'),
|
|
$t('apps.new.wizard.stepConfigure'),
|
|
$t('apps.new.wizard.stepTrigger'),
|
|
$t('apps.new.wizard.stepReview')
|
|
]);
|
|
|
|
function goToStep(step: number) {
|
|
// Rail navigation: only into an already-reached step.
|
|
if (step < 1 || step > maxReached) return;
|
|
currentStep = step;
|
|
}
|
|
|
|
function goNext() {
|
|
if (currentStep >= STEP_COUNT || !canAdvance) return;
|
|
currentStep += 1;
|
|
if (currentStep > maxReached) maxReached = currentStep;
|
|
}
|
|
|
|
function goBack() {
|
|
if (currentStep > 1) currentStep -= 1;
|
|
}
|
|
|
|
// ── Unsaved-changes guard ─────────────────────────────────────────
|
|
// `dirty` is intentionally conservative (not over-eager): it trips
|
|
// once the operator has invested meaningful effort — named the
|
|
// workload, advanced past the Basics step, or named an inline
|
|
// trigger. The seeded source-config placeholders (e.g. owner/app)
|
|
// deliberately do NOT count, so simply landing on the page and
|
|
// switching source kinds won't nag on leave.
|
|
const dirty = $derived(
|
|
name.trim() !== '' || currentStep > 1 || triggerForm.name.trim() !== ''
|
|
);
|
|
|
|
// SvelteKit in-app navigation guard. A successful create sets
|
|
// `submitted` first so the goto(`/apps/{id}`) is never blocked. Rather
|
|
// than the browser's native confirm, cancel the navigation and open the
|
|
// app's ConfirmDialog; on confirm, re-issue the nav with `confirmedLeave`
|
|
// set so the second beforeNavigate pass lets it through. Hard unloads
|
|
// (tab close / reload / external) can't show custom UI, so those fall
|
|
// through to the native beforeunload prompt below.
|
|
let leaveConfirmOpen = $state(false);
|
|
let pendingNavUrl = $state<string | null>(null);
|
|
let confirmedLeave = $state(false);
|
|
|
|
beforeNavigate((nav) => {
|
|
if (submitted || submitting || confirmedLeave || !dirty) return;
|
|
if (nav.willUnload || !nav.to) return;
|
|
nav.cancel();
|
|
pendingNavUrl = nav.to.url.href;
|
|
leaveConfirmOpen = true;
|
|
});
|
|
|
|
function confirmLeave() {
|
|
leaveConfirmOpen = false;
|
|
confirmedLeave = true;
|
|
const url = pendingNavUrl;
|
|
pendingNavUrl = null;
|
|
if (url) void goto(url);
|
|
}
|
|
|
|
function cancelLeave() {
|
|
leaveConfirmOpen = false;
|
|
pendingNavUrl = null;
|
|
}
|
|
|
|
// Browser-level guard for tab close / reload / external navigation,
|
|
// which beforeNavigate does not cover. Setting returnValue triggers
|
|
// the native "Leave site?" prompt. Removed on destroy.
|
|
function onBeforeUnload(event: BeforeUnloadEvent) {
|
|
if (submitted || submitting || !dirty) return;
|
|
event.preventDefault();
|
|
// Legacy browsers require returnValue to be set to a string.
|
|
event.returnValue = '';
|
|
}
|
|
|
|
onMount(() => {
|
|
window.addEventListener('beforeunload', onBeforeUnload);
|
|
});
|
|
onDestroy(() => {
|
|
if (typeof window !== 'undefined') {
|
|
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
}
|
|
});
|
|
|
|
async function submit(e: Event) {
|
|
e.preventDefault();
|
|
// Enter pressed inside a non-final step advances the wizard rather
|
|
// than submitting a half-filled form.
|
|
if (currentStep < STEP_COUNT) {
|
|
goNext();
|
|
return;
|
|
}
|
|
error = '';
|
|
submitting = true;
|
|
try {
|
|
let parsedSource: unknown;
|
|
if (useComposeForm) {
|
|
// All four form paths route through the shared sourceForms.ts
|
|
// serializers so the POSTed source_config is byte-identical to
|
|
// the legacy inline helpers (key order + preserve/scrub rules
|
|
// included). env/volumes (image) and storage_* / unknown keys
|
|
// (static/dockerfile) are preserved from `sourceConfig`.
|
|
parsedSource = composeToConfig(composeState);
|
|
} else if (useImageForm) {
|
|
parsedSource = imageToConfig(imageState, sourceConfig);
|
|
// Conflict guard: if the resolved image ref already belongs to
|
|
// other workloads and the operator hasn't acknowledged that
|
|
// fact, intercept the first click so they get a chance to
|
|
// review. A second click (with imageConflictAcknowledged
|
|
// flipped true) goes through unchanged.
|
|
if (imageConflicts.length > 0 && !imageConflictAcknowledged) {
|
|
imageConflictAcknowledged = true;
|
|
imageConflictBlocked = true;
|
|
submitting = false;
|
|
return;
|
|
}
|
|
imageConflictBlocked = false;
|
|
} else if (useStaticForm) {
|
|
parsedSource = staticToConfig(staticState, sourceConfig);
|
|
} else if (useDockerfileForm) {
|
|
parsedSource = dockerfileToConfig(dockerfileState, sourceConfig);
|
|
} else {
|
|
try {
|
|
parsedSource = JSON.parse(sourceConfig);
|
|
} catch {
|
|
throw new Error($t('apps.new.errors.sourceConfigInvalid'));
|
|
}
|
|
}
|
|
|
|
// Triggers no longer ride on the workload row; the backend
|
|
// still accepts the legacy fields but the new code path
|
|
// passes a manual placeholder + empty config and binds a
|
|
// real Trigger record after creation.
|
|
const body: PluginWorkloadInput = {
|
|
name: name.trim(),
|
|
source_kind: sourceKind,
|
|
source_config: parsedSource,
|
|
trigger_kind: '',
|
|
trigger_config: {}
|
|
};
|
|
if (publicSubdomain || publicDomain || publicPort > 0) {
|
|
body.public_faces = [
|
|
{
|
|
subdomain: publicSubdomain,
|
|
domain: publicDomain,
|
|
target_service: '',
|
|
target_port: publicPort,
|
|
access_list_id: 0,
|
|
enable_ssl: true
|
|
}
|
|
];
|
|
}
|
|
|
|
const created = await api.createPluginWorkload(body);
|
|
// The workload now exists — mark submitted so the navigation
|
|
// guard does not prompt on the goto below (or on any redirect
|
|
// the bind step might trigger).
|
|
submitted = true;
|
|
|
|
// Bind a trigger to the freshly-created workload. Keep going
|
|
// to the detail page even on bind failure — the operator can
|
|
// retry from the workload's Triggers panel without losing
|
|
// their work.
|
|
if (triggerMode === 'inline' || triggerMode === 'pick') {
|
|
try {
|
|
if (triggerMode === 'inline') {
|
|
const inline = buildTriggerInput(triggerForm);
|
|
await api.bindTriggerToWorkload(created.id, { inline });
|
|
} else if (pickedTriggerId) {
|
|
await api.bindTriggerToWorkload(created.id, {
|
|
trigger_id: pickedTriggerId
|
|
});
|
|
}
|
|
} catch (be) {
|
|
const msg = be instanceof Error ? be.message : $t('apps.new.errors.triggerBindUnknown');
|
|
// Surface the bind failure to the user, then still
|
|
// route to the detail page where they can retry.
|
|
try {
|
|
sessionStorage.setItem(
|
|
`tinyforge.bindError.${created.id}`,
|
|
$t('apps.new.triggers.bindError', { error: msg })
|
|
);
|
|
} catch {
|
|
// session storage may be disabled — ignore.
|
|
}
|
|
}
|
|
}
|
|
goto(`/apps/${created.id}`);
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : $t('apps.new.errors.createFailed');
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{$t('apps.new.pageTitle')}</title>
|
|
</svelte:head>
|
|
|
|
<div class="forge">
|
|
{#snippet newLede()}
|
|
{$t('apps.new.ledePrefix')} <em>{$t('apps.new.ledeSourceLabel')}</em>
|
|
{$t('apps.new.ledeSourceMid')} <em>{$t('apps.new.ledeTriggerLabel')}</em> {$t('apps.new.ledeSuffix')}
|
|
{/snippet}
|
|
|
|
<ForgeHero
|
|
backHref="/apps"
|
|
backLabel={$t('apps.new.backLabel')}
|
|
eyebrowSuffix={$t('apps.new.eyebrowSuffix')}
|
|
title={$t('apps.new.title')}
|
|
size="lg"
|
|
lede_html={newLede}
|
|
/>
|
|
|
|
{#if loading}
|
|
<div class="loading-line">
|
|
<span class="spinner" aria-hidden="true"></span>
|
|
<span>{$t('apps.new.loadingKinds')}</span>
|
|
</div>
|
|
{:else}
|
|
<div class="wizard-shell">
|
|
<WizardRail
|
|
steps={stepLabels.map((label) => ({ label }))}
|
|
current={currentStep}
|
|
{maxReached}
|
|
onselect={goToStep}
|
|
/>
|
|
<form onsubmit={submit} class="form" novalidate>
|
|
<span class="reg reg-tl" aria-hidden="true"></span>
|
|
<span class="reg reg-tr" aria-hidden="true"></span>
|
|
<span class="reg reg-bl" aria-hidden="true"></span>
|
|
<span class="reg reg-br" aria-hidden="true"></span>
|
|
|
|
{#if error}
|
|
<div class="alert"><span class="alert-tag">{$t('apps.new.alertTag')}</span><span>{error}</span></div>
|
|
{/if}
|
|
|
|
{#if currentStep === 1}
|
|
<div class="wizard-step">
|
|
<div class="field">
|
|
<label for="app-name" class="field-label">
|
|
<span class="num">01</span>
|
|
<span class="lbl">{$t('apps.new.fieldName')}</span>
|
|
<span class="req">{$t('apps.new.fieldNameRequired')}</span>
|
|
</label>
|
|
<input
|
|
id="app-name"
|
|
type="text"
|
|
bind:value={name}
|
|
required
|
|
placeholder={$t('apps.new.fieldNamePlaceholder')}
|
|
class="input"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
/>
|
|
<p class="hint">{$t('apps.new.fieldNameHint')}</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<div class="field-label">
|
|
<span class="num">02</span>
|
|
<span class="lbl">{$t('apps.new.fieldSourcePlugin')}</span>
|
|
<span class="opt">{$t('apps.new.fieldNameRequired')}</span>
|
|
</div>
|
|
<SourceKindPicker
|
|
kinds={kinds.sources}
|
|
bind:value={sourceKind}
|
|
onchange={onSourceChange}
|
|
ariaLabel={$t('apps.new.fieldSourcePlugin')}
|
|
/>
|
|
<p class="hint">{$t('apps.new.fieldSourceHint')}</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if currentStep === 2}
|
|
<div class="wizard-step">
|
|
<div class="field">
|
|
<div class="field-label">
|
|
<span class="num">03</span>
|
|
<span class="lbl">{$t('apps.new.fieldSourceConfig')}</span>
|
|
<span class="req">
|
|
{useComposeForm
|
|
? $t('apps.new.fieldConfigYaml')
|
|
: useImageForm || useStaticForm || useDockerfileForm
|
|
? $t('apps.new.fieldConfigForm')
|
|
: $t('apps.new.fieldConfigJson')}
|
|
</span>
|
|
</div>
|
|
{#if useComposeForm}
|
|
<ComposeSourceForm bind:form={composeState} onAdvanced={toggleAdvancedJSON} />
|
|
{:else if useImageForm}
|
|
<!-- Image source form. Owns inspect + conflict-lookup
|
|
async UX internally; writes the conflict triplet back
|
|
here so the submit gate can run the two-click flow. -->
|
|
<ImageSourceForm
|
|
bind:form={imageState}
|
|
{registries}
|
|
bind:submitting
|
|
bind:conflicts={imageConflicts}
|
|
bind:conflictAcknowledged={imageConflictAcknowledged}
|
|
bind:conflictBlocked={imageConflictBlocked}
|
|
onAdvanced={toggleAdvancedJSON}
|
|
/>
|
|
{:else if useStaticForm}
|
|
<!-- Static source form. Provider + repo + mode in
|
|
dedicated controls; access_token treated as a
|
|
password input. Augmented with inline discovery:
|
|
provider auto-detect, test-connection probe, and
|
|
repo / branch / folder pickers fed by the
|
|
/api/discovery/git/* endpoints. -->
|
|
<StaticSourceForm
|
|
bind:form={staticState}
|
|
bind:modeUserOverride={staticModeUserOverride}
|
|
bind:treeLoaded={staticTreeLoaded}
|
|
bind:tree={staticTree}
|
|
bind:detectStatus={staticDetectStatus}
|
|
bind:detectError={staticDetectError}
|
|
bind:testStatus={staticTestStatus}
|
|
bind:testError={staticTestError}
|
|
onAdvanced={toggleAdvancedJSON}
|
|
/>
|
|
{:else if useDockerfileForm}
|
|
<!-- Dockerfile source form. Shares the provider + repo +
|
|
branch + token git-discovery wiring with the static
|
|
source (StaticDiscoveryWizard in its compact variant —
|
|
same handlers, no folder tree). The build-step controls
|
|
(context path, dockerfile path, port) are the only
|
|
dockerfile-specific UI. -->
|
|
<DockerfileSourceForm
|
|
bind:form={dockerfileState}
|
|
bind:detectStatus={dockerfileDetectStatus}
|
|
bind:detectError={dockerfileDetectError}
|
|
bind:testStatus={dockerfileTestStatus}
|
|
bind:testError={dockerfileTestError}
|
|
onAdvanced={toggleAdvancedJSON}
|
|
/>
|
|
{:else}
|
|
<div class="editor">
|
|
<div class="editor-head">
|
|
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
|
<span class="editor-title">{$t('apps.new.sourceConfigJsonTitle', { kind: sourceKind })}</span>
|
|
<span class="spacer"></span>
|
|
{#if sourceKind === 'compose' || sourceKind === 'image' || sourceKind === 'static' || sourceKind === 'dockerfile'}
|
|
<button
|
|
type="button"
|
|
class="editor-chip"
|
|
onclick={toggleAdvancedJSON}
|
|
title={$t('apps.new.switchToFormTitle')}
|
|
>
|
|
{$t('apps.new.backToForm')}
|
|
</button>
|
|
{/if}
|
|
<button
|
|
type="button"
|
|
class="editor-chip"
|
|
onclick={() => (sourceConfig = sourceConfigSample(sourceKind))}
|
|
>
|
|
{$t('apps.new.resetSample')}
|
|
</button>
|
|
</div>
|
|
<textarea
|
|
id="app-source-config"
|
|
bind:value={sourceConfig}
|
|
rows="12"
|
|
spellcheck="false"
|
|
class="code-area"
|
|
aria-label={$t('apps.new.sourceConfigJsonAria')}
|
|
></textarea>
|
|
<div class="editor-foot">
|
|
<span class="foot-status" class:bad={!sourceValid}>
|
|
<span class="foot-dot" aria-hidden="true"></span>
|
|
{sourceValid ? $t('apps.new.jsonOk') : $t('apps.new.jsonInvalid')}
|
|
</span>
|
|
<span class="sep">·</span>
|
|
<span>{sourceLines} {$t('apps.new.linesUnit')}</span>
|
|
<span class="sep">·</span>
|
|
<span>{sourceBytes} B</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<!-- The repo + branch EntityPicker mounts now live inside
|
|
StaticDiscoveryWizard (one mount each), so the static and
|
|
dockerfile forms share them automatically. -->
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if currentStep === 3}
|
|
<div class="wizard-step">
|
|
<fieldset class="field group">
|
|
<legend class="field-label as-legend">
|
|
<span class="num">04</span>
|
|
<span class="lbl">{$t('apps.new.triggers.section')}</span>
|
|
<span class="opt">{$t('apps.new.triggerNumOptional')}</span>
|
|
</legend>
|
|
<p class="hint">{$t('apps.new.triggers.sectionSub')}</p>
|
|
|
|
<!-- Mode selector — three short cards. The "active" card
|
|
reveals its sub-form below. Skipping is explicit so
|
|
users can ship the workload now and wire triggers
|
|
later from the detail page. -->
|
|
<div
|
|
class="trig-mode-row"
|
|
role="radiogroup"
|
|
aria-label={$t('apps.new.triggers.section')}
|
|
>
|
|
<button
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={triggerMode === 'inline'}
|
|
class="trig-mode-card"
|
|
class:active={triggerMode === 'inline'}
|
|
onclick={() => (triggerMode = 'inline')}
|
|
>
|
|
<span class="trig-mode-tag mono">{$t('apps.new.triggerNewTag')}</span>
|
|
<span class="trig-mode-name">{$t('apps.new.triggers.modeInline')}</span>
|
|
<span class="trig-mode-hint">{$t('apps.new.triggers.modeInlineHint')}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={triggerMode === 'pick'}
|
|
class="trig-mode-card"
|
|
class:active={triggerMode === 'pick'}
|
|
onclick={() => (triggerMode = 'pick')}
|
|
>
|
|
<span class="trig-mode-tag mono">{$t('apps.new.triggerPickTag')}</span>
|
|
<span class="trig-mode-name">{$t('apps.new.triggers.modePick')}</span>
|
|
<span class="trig-mode-hint">{$t('apps.new.triggers.modePickHint')}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="radio"
|
|
aria-checked={triggerMode === 'skip'}
|
|
class="trig-mode-card"
|
|
class:active={triggerMode === 'skip'}
|
|
onclick={() => (triggerMode = 'skip')}
|
|
>
|
|
<span class="trig-mode-tag mono">{$t('apps.new.triggerSkipTag')}</span>
|
|
<span class="trig-mode-name">{$t('apps.new.triggers.modeSkip')}</span>
|
|
<span class="trig-mode-hint">{$t('apps.new.triggers.modeSkipHint')}</span>
|
|
</button>
|
|
</div>
|
|
|
|
{#if triggerMode === 'inline'}
|
|
<div class="trig-sub">
|
|
<TriggerKindForm
|
|
bind:state={triggerForm}
|
|
idPrefix="app-trig"
|
|
showName={true}
|
|
showWebhook={true}
|
|
showKindPicker={true}
|
|
/>
|
|
</div>
|
|
{:else if triggerMode === 'pick'}
|
|
<div class="trig-sub">
|
|
{#if existingTriggers.length === 0}
|
|
<div class="note muted-note">
|
|
<span class="note-tag">{$t('apps.new.noteEmptyTag')}</span>
|
|
<p>{$t('apps.new.triggers.pickEmpty')}</p>
|
|
</div>
|
|
{:else}
|
|
<label class="sub" for="app-trig-pick">
|
|
<span class="sub-label">{$t('apps.new.triggers.pickLabel')}</span>
|
|
<select
|
|
id="app-trig-pick"
|
|
class="input"
|
|
bind:value={pickedTriggerId}
|
|
>
|
|
<option value="">{$t('apps.new.triggers.pickPlaceholder')}</option>
|
|
{#each existingTriggers as tr (tr.id)}
|
|
<option value={tr.id}>
|
|
{tr.name} · {tr.kind}{tr.webhook_enabled
|
|
? ` · ${$t('apps.new.triggers.pickWebhookOn')}`
|
|
: ''}
|
|
</option>
|
|
{/each}
|
|
</select>
|
|
<span class="hint">{$t('apps.new.triggers.pickHint')}</span>
|
|
</label>
|
|
{/if}
|
|
</div>
|
|
{:else}
|
|
<div class="trig-sub">
|
|
<div class="note muted-note">
|
|
<span class="note-tag">{$t('apps.new.noteSkipTag')}</span>
|
|
<p>{$t('apps.new.triggers.skippedNote')}</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</fieldset>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if currentStep === 4}
|
|
<div class="wizard-step">
|
|
<fieldset class="field group">
|
|
<legend class="field-label as-legend">
|
|
<span class="num">05</span>
|
|
<span class="lbl">{$t('apps.new.faceLabel')}</span>
|
|
<span class="opt">{$t('apps.new.faceOptional')}</span>
|
|
</legend>
|
|
<div class="row three">
|
|
<label class="sub" for="app-public-subdomain">
|
|
<span class="sub-label">{$t('apps.new.faceSubdomain')}</span>
|
|
<input
|
|
id="app-public-subdomain"
|
|
type="text"
|
|
class="input"
|
|
bind:value={publicSubdomain}
|
|
placeholder={$t('apps.new.faceSubdomainPlaceholder')}
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
<label class="sub" for="app-public-domain">
|
|
<span class="sub-label">{$t('apps.new.faceDomain')}</span>
|
|
<input
|
|
id="app-public-domain"
|
|
type="text"
|
|
class="input"
|
|
bind:value={publicDomain}
|
|
placeholder={$t('apps.new.faceDomainPlaceholder')}
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
<label class="sub" for="app-public-port">
|
|
<span class="sub-label">{$t('apps.new.facePort')}</span>
|
|
<input
|
|
id="app-public-port"
|
|
type="number"
|
|
class="input"
|
|
bind:value={publicPort}
|
|
min="0"
|
|
max="65535"
|
|
/>
|
|
</label>
|
|
</div>
|
|
<p class="hint">{$t('apps.new.faceHint')}</p>
|
|
</fieldset>
|
|
<AppManifest rows={manifestRows} {sourceKind} />
|
|
</div>
|
|
{/if}
|
|
|
|
{#if currentStep === STEP_COUNT && useImageForm && imageConflictBlocked && imageConflicts.length > 0}
|
|
<div class="note conflict-blocked-note" role="status" aria-live="polite">
|
|
<span class="note-tag">{$t('apps.new.imageConflictTag')}</span>
|
|
<p>{$t('apps.new.imageConflictBlockedSubmit')}</p>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="wizard-nav">
|
|
{#if currentStep > 1}
|
|
<button type="button" class="forge-btn-ghost" onclick={goBack}>
|
|
<span class="arrow back" aria-hidden="true">←</span>
|
|
<span>{$t('apps.new.wizard.back')}</span>
|
|
</button>
|
|
{:else}
|
|
<a href="/apps" class="forge-btn-ghost">{$t('apps.new.cancel')}</a>
|
|
{/if}
|
|
|
|
{#if currentStep < STEP_COUNT}
|
|
<button type="button" class="forge-btn" onclick={goNext} disabled={!canAdvance}>
|
|
<span>{$t('apps.new.wizard.next')}</span>
|
|
<span class="arrow" aria-hidden="true">→</span>
|
|
</button>
|
|
{:else}
|
|
<button
|
|
class="forge-btn"
|
|
class:btn-warn={useImageForm && imageConflictBlocked && imageConflicts.length > 0}
|
|
type="submit"
|
|
disabled={submitting || !step1Valid || !step2Valid || !step3Valid}
|
|
>
|
|
<span>
|
|
{#if submitting}
|
|
{$t('apps.new.submitting')}
|
|
{:else if useImageForm && imageConflictBlocked && imageConflicts.length > 0}
|
|
{$t('apps.new.submitAnyway')}
|
|
{:else}
|
|
{$t('apps.new.submit')}
|
|
{/if}
|
|
</span>
|
|
<span class="arrow" aria-hidden="true">→</span>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
{/if}
|
|
|
|
<ConfirmDialog
|
|
open={leaveConfirmOpen}
|
|
title={$t('apps.new.unsavedChangesTitle')}
|
|
message={$t('apps.new.unsavedChanges')}
|
|
confirmLabel={$t('apps.new.unsavedChangesConfirm')}
|
|
onconfirm={confirmLeave}
|
|
oncancel={cancelLeave}
|
|
/>
|
|
</div>
|
|
|
|
<style>
|
|
.forge {
|
|
--accent: var(--forge-accent);
|
|
--accent-soft: var(--forge-accent-soft);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
max-width: 880px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.loading-line {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
color: var(--text-secondary);
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.78rem;
|
|
letter-spacing: 0.08em;
|
|
text-transform: uppercase;
|
|
}
|
|
.spinner {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
border: 1.5px solid var(--border-primary);
|
|
border-top-color: var(--accent);
|
|
animation: spin 0.9s linear infinite;
|
|
}
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
/* ── Form shell with registration corners ─────── */
|
|
.form {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-2xl);
|
|
padding: 1.75rem;
|
|
}
|
|
|
|
/* ── Wizard: step rail + progressive steps ─────── */
|
|
.wizard-shell {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: var(--space-8);
|
|
}
|
|
.wizard-shell > .form {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
/* One step visible at a time; preserve the form's inter-field rhythm. */
|
|
.wizard-step {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1.5rem;
|
|
}
|
|
/* Staggered entrance — one orchestrated reveal as each step lands. */
|
|
.wizard-step > * {
|
|
animation: step-rise 360ms cubic-bezier(0.16, 1, 0.3, 1) backwards;
|
|
}
|
|
.wizard-step > *:nth-child(2) {
|
|
animation-delay: 60ms;
|
|
}
|
|
.wizard-step > *:nth-child(3) {
|
|
animation-delay: 120ms;
|
|
}
|
|
.wizard-step > *:nth-child(n + 4) {
|
|
animation-delay: 180ms;
|
|
}
|
|
@keyframes step-rise {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(8px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: none;
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.wizard-step > * {
|
|
animation: none;
|
|
}
|
|
}
|
|
.wizard-nav {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
padding-top: 1rem;
|
|
border-top: 1px solid var(--border-secondary);
|
|
}
|
|
.arrow.back {
|
|
margin-right: 0.1rem;
|
|
}
|
|
@media (max-width: 820px) {
|
|
.wizard-shell {
|
|
flex-direction: column;
|
|
gap: var(--space-5);
|
|
}
|
|
.wizard-shell > .form {
|
|
width: 100%;
|
|
}
|
|
}
|
|
.reg {
|
|
position: absolute;
|
|
width: 10px;
|
|
height: 10px;
|
|
border-color: var(--color-brand-500);
|
|
border-style: solid;
|
|
border-width: 0;
|
|
pointer-events: none;
|
|
}
|
|
.reg-tl {
|
|
top: -1px;
|
|
left: -1px;
|
|
border-top-width: 2px;
|
|
border-left-width: 2px;
|
|
border-top-left-radius: var(--radius-2xl);
|
|
}
|
|
.reg-tr {
|
|
top: -1px;
|
|
right: -1px;
|
|
border-top-width: 2px;
|
|
border-right-width: 2px;
|
|
border-top-right-radius: var(--radius-2xl);
|
|
}
|
|
.reg-bl {
|
|
bottom: -1px;
|
|
left: -1px;
|
|
border-bottom-width: 2px;
|
|
border-left-width: 2px;
|
|
border-bottom-left-radius: var(--radius-2xl);
|
|
}
|
|
.reg-br {
|
|
bottom: -1px;
|
|
right: -1px;
|
|
border-bottom-width: 2px;
|
|
border-right-width: 2px;
|
|
border-bottom-right-radius: var(--radius-2xl);
|
|
}
|
|
|
|
/* ── Alert ─────────────────────────────────────── */
|
|
.alert {
|
|
display: flex;
|
|
gap: 0.7rem;
|
|
align-items: center;
|
|
padding: 0.7rem 0.9rem;
|
|
background: var(--color-danger-light);
|
|
color: var(--color-danger-dark);
|
|
border: 1px solid var(--color-danger);
|
|
border-left-width: 4px;
|
|
border-radius: var(--radius-lg);
|
|
font-size: 0.875rem;
|
|
}
|
|
.alert-tag {
|
|
font-family: var(--forge-mono);
|
|
font-weight: 700;
|
|
font-size: 0.65rem;
|
|
letter-spacing: 0.16em;
|
|
padding: 0.15rem 0.4rem;
|
|
background: var(--color-danger);
|
|
color: #fff;
|
|
border-radius: var(--radius-sm);
|
|
}
|
|
:global([data-theme='dark']) .alert {
|
|
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
|
color: #fca5a5;
|
|
}
|
|
|
|
/* ── Fields ────────────────────────────────────── */
|
|
.field {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.55rem;
|
|
margin: 0;
|
|
padding: 0;
|
|
border: 0;
|
|
}
|
|
.field.group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.7rem;
|
|
}
|
|
.field-label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.55rem;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
.field-label.as-legend {
|
|
float: none;
|
|
width: 100%;
|
|
}
|
|
.num {
|
|
display: inline-flex;
|
|
width: 26px;
|
|
height: 26px;
|
|
justify-content: center;
|
|
align-items: center;
|
|
background: var(--text-primary);
|
|
color: var(--surface-card);
|
|
border-radius: var(--radius-sm);
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
}
|
|
.lbl {
|
|
font-family: var(--font-family-sans);
|
|
font-weight: 600;
|
|
font-size: 1.1rem;
|
|
letter-spacing: -0.01em;
|
|
line-height: 1;
|
|
color: var(--text-primary);
|
|
}
|
|
.req,
|
|
.opt {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.58rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.14em;
|
|
}
|
|
.req {
|
|
color: var(--color-danger);
|
|
}
|
|
.opt {
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.input {
|
|
width: 100%;
|
|
background: var(--surface-input);
|
|
border: 1px solid var(--border-input);
|
|
border-radius: var(--radius-lg);
|
|
padding: 0.6rem 0.8rem;
|
|
font-size: 0.92rem;
|
|
color: var(--text-primary);
|
|
font-family: inherit;
|
|
outline: none;
|
|
transition: border-color 120ms ease, box-shadow 120ms ease;
|
|
}
|
|
.input:focus {
|
|
border-color: var(--border-focus);
|
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
}
|
|
.input:focus-visible {
|
|
outline: none;
|
|
}
|
|
|
|
.hint {
|
|
font-size: 0.78rem;
|
|
color: var(--text-tertiary);
|
|
margin: 0;
|
|
line-height: 1.45;
|
|
}
|
|
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 0.9rem;
|
|
}
|
|
.row.three {
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
}
|
|
@media (max-width: 600px) {
|
|
.row,
|
|
.row.three {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
.sub {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
}
|
|
.sub-label {
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.62rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
/* ── Code editor frame ─────────────────────────── */
|
|
.editor {
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-lg);
|
|
overflow: hidden;
|
|
background: var(--surface-input);
|
|
transition: border-color 150ms ease, box-shadow 150ms ease;
|
|
}
|
|
.editor:focus-within {
|
|
border-color: var(--border-focus);
|
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
}
|
|
.editor-head {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.55rem 0.8rem;
|
|
background: var(--surface-card-hover);
|
|
border-bottom: 1px solid var(--border-primary);
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.7rem;
|
|
color: var(--text-tertiary);
|
|
}
|
|
.editor-head .dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--border-input);
|
|
}
|
|
.editor-head .dot:nth-of-type(1) {
|
|
background: #ef4444aa;
|
|
}
|
|
.editor-head .dot:nth-of-type(2) {
|
|
background: #f59e0baa;
|
|
}
|
|
.editor-head .dot:nth-of-type(3) {
|
|
background: #10b981aa;
|
|
}
|
|
.editor-title {
|
|
margin-left: 0.4rem;
|
|
color: var(--text-secondary);
|
|
font-weight: 600;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.spacer {
|
|
flex: 1;
|
|
}
|
|
.editor-chip {
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-md);
|
|
padding: 0.22rem 0.55rem;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.6rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.1em;
|
|
text-transform: uppercase;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
transition: all 120ms ease;
|
|
}
|
|
.editor-chip:hover {
|
|
border-color: var(--color-brand-400);
|
|
color: var(--text-primary);
|
|
background: var(--surface-card-hover);
|
|
}
|
|
.editor-chip:focus-visible {
|
|
outline: 2px solid var(--border-focus);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.code-area {
|
|
display: block;
|
|
width: 100%;
|
|
border: 0;
|
|
background: transparent;
|
|
padding: 0.85rem 1rem;
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.82rem;
|
|
line-height: 1.55;
|
|
color: var(--text-primary);
|
|
resize: vertical;
|
|
outline: none;
|
|
tab-size: 2;
|
|
}
|
|
.code-area::placeholder {
|
|
color: var(--text-tertiary);
|
|
}
|
|
|
|
.editor-foot {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.45rem 0.8rem;
|
|
border-top: 1px solid var(--border-primary);
|
|
background: var(--surface-card-hover);
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.62rem;
|
|
color: var(--text-tertiary);
|
|
}
|
|
.foot-status {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
color: var(--color-success-dark);
|
|
letter-spacing: 0.1em;
|
|
font-weight: 600;
|
|
}
|
|
.foot-status .foot-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: var(--color-success);
|
|
}
|
|
.foot-status.bad {
|
|
color: var(--color-danger-dark);
|
|
}
|
|
.foot-status.bad .foot-dot {
|
|
background: var(--color-danger);
|
|
}
|
|
:global([data-theme='dark']) .foot-status {
|
|
color: #86efac;
|
|
}
|
|
:global([data-theme='dark']) .foot-status.bad {
|
|
color: #fca5a5;
|
|
}
|
|
.sep {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Wizard nav uses the app-wide primary button (`.forge-btn`, defined in
|
|
app.css) so Next/Create match every other primary action in the app.
|
|
The only local additions are the arrow-nudge affordance and the
|
|
conflict-state `.btn-warn` variant preserved from the legacy button. */
|
|
.forge-btn .arrow {
|
|
transition: transform 150ms ease;
|
|
}
|
|
.forge-btn:hover:not(:disabled) .arrow {
|
|
transform: translateX(2px);
|
|
}
|
|
.forge-btn:focus-visible {
|
|
outline: 2px solid var(--border-focus);
|
|
outline-offset: 2px;
|
|
}
|
|
/* Variant used on the second submit click when image-conflicts are
|
|
present but the operator chose to proceed anyway. The colour shift
|
|
plus label change ("Forge anyway") signals that the click did
|
|
register without forcing a modal. */
|
|
.forge-btn.btn-warn {
|
|
background: var(--color-warning, var(--color-danger));
|
|
color: #fff;
|
|
}
|
|
.forge-btn.btn-warn:hover:not(:disabled) {
|
|
filter: brightness(1.05);
|
|
}
|
|
|
|
/* Three-column row grid — used by the public-face step. (The image /
|
|
dockerfile form variants of this grid moved into their components.) */
|
|
.row.three {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 0.9rem;
|
|
}
|
|
@media (max-width: 720px) {
|
|
.row.three {
|
|
grid-template-columns: 1fr 1fr;
|
|
}
|
|
}
|
|
@media (max-width: 480px) {
|
|
.row.three {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
/* ── Trigger mode picker ──────────────────────────
|
|
Three short cards (NEW / PICK / SKIP). The active
|
|
card lights up its tag in brand colour and reveals
|
|
the matching sub-form below in a soft inset panel. */
|
|
.trig-mode-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
gap: 0.55rem;
|
|
}
|
|
@media (max-width: 720px) {
|
|
.trig-mode-row {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
.trig-mode-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.35rem;
|
|
padding: 0.75rem 0.85rem;
|
|
text-align: left;
|
|
background: var(--surface-card);
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-lg);
|
|
cursor: pointer;
|
|
transition: border-color 150ms ease, background 150ms ease,
|
|
transform 150ms ease;
|
|
}
|
|
.trig-mode-card:hover {
|
|
border-color: color-mix(in srgb, var(--forge-accent) 40%, var(--border-primary));
|
|
transform: translateY(-1px);
|
|
}
|
|
.trig-mode-card.active {
|
|
border-color: var(--forge-accent);
|
|
background: var(--forge-accent-soft);
|
|
box-shadow: inset 0 0 0 1px var(--forge-accent);
|
|
}
|
|
.trig-mode-tag {
|
|
display: inline-flex;
|
|
align-self: flex-start;
|
|
padding: 0.18rem 0.5rem;
|
|
background: var(--text-primary);
|
|
color: var(--surface-card);
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.58rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.18em;
|
|
border-radius: var(--radius-sm);
|
|
line-height: 1;
|
|
}
|
|
.trig-mode-card.active .trig-mode-tag {
|
|
background: var(--forge-accent);
|
|
}
|
|
.trig-mode-name {
|
|
font-weight: 600;
|
|
font-size: 0.92rem;
|
|
color: var(--text-primary);
|
|
letter-spacing: -0.01em;
|
|
}
|
|
.trig-mode-hint {
|
|
font-size: 0.7rem;
|
|
color: var(--text-tertiary);
|
|
line-height: 1.45;
|
|
}
|
|
.trig-sub {
|
|
margin-top: 0.2rem;
|
|
padding: 0.95rem 1rem;
|
|
border: 1px solid var(--border-primary);
|
|
border-radius: var(--radius-lg);
|
|
background: var(--surface-input);
|
|
}
|
|
.note {
|
|
display: flex;
|
|
gap: 0.7rem;
|
|
align-items: flex-start;
|
|
padding: 0.65rem 0.85rem;
|
|
background: var(--surface-card-hover);
|
|
border: 1px dashed var(--border-primary);
|
|
border-radius: var(--radius-lg);
|
|
}
|
|
.muted-note {
|
|
background: transparent;
|
|
}
|
|
.note-tag {
|
|
padding: 0.16rem 0.4rem;
|
|
background: var(--text-primary);
|
|
color: var(--surface-card);
|
|
font-family: var(--forge-mono);
|
|
font-size: 0.56rem;
|
|
font-weight: 700;
|
|
letter-spacing: 0.18em;
|
|
border-radius: var(--radius-sm);
|
|
flex: 0 0 auto;
|
|
line-height: 1.4;
|
|
}
|
|
.note p {
|
|
margin: 0;
|
|
font-size: 0.83rem;
|
|
color: var(--text-secondary);
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.conflict-blocked-note {
|
|
border-color: var(--color-warning, var(--forge-accent));
|
|
}
|
|
.conflict-blocked-note .note-tag {
|
|
background: var(--color-warning, var(--forge-accent));
|
|
}
|
|
</style>
|