feat(dashboard): auto-slug app names and infer route host kind from input

Two related polish passes on forms the operator hits most.

App create form: the slug field used to come before the name field and
demanded the operator hand-roll a valid slug. Now the name field comes
first and the slug is derived from it live, GitLab-style — Unicode
NFKD-decomposed, combining marks stripped (so `Café` → `cafe`), `ß`
mapped to `ss`, non-`[a-z0-9]` runs collapsed to `-`, trimmed and capped
at the backend's 63-char limit. The auto-sync releases as soon as the
operator edits the slug manually, and re-engages if they clear it. The
slug input itself runs every keystroke and paste through the same
normalizer, so dirty input never reaches the form state.

Route create form: the three-way host-kind `<select>` plus a sometimes-
disabled input was confusing — operators routinely picked the wrong
kind, typed a host the app didn't claim, and only saw the error after
hitting Create. Replace with a single text input that infers the kind
from what's there (`*` → any, `*.foo.com` → wildcard, `foo.com` →
strict), shows the detected kind as a colored chip beside the field, and
suggests the app's existing domain claims via a `<datalist>`. The same
matching logic the backend runs in `validate_route_host_against_app`
now lives in `route-utils.ts` so the form can surface a soft "not
covered by any claim" warning *before* submit. Path also pre-fills to
`/` so the most common case is one click away.

Lockfile drift from `npm install` (pre-existing 0.5.0 → 0.5.1 version
sync, npm metadata cleanup) is folded in here since it surfaced during
this work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-26 21:01:20 +02:00
parent ad5492a4bd
commit a393f11344
5 changed files with 273 additions and 72 deletions

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify';
let apps = $state<App[] | null>(null);
let listError = $state<string | null>(null);
@@ -10,10 +11,34 @@
let createSlug = $state('');
let createName = $state('');
let createDescription = $state('');
// Auto-derive slug from name until the user takes manual control of
// the slug field. Clearing the slug input releases the lock so the
// auto-derive resumes — matches the GitLab project-create UX.
let slugTouched = $state(false);
let creating = $state(false);
let createError = $state<string | null>(null);
let createHistoricalConflict = $state<App | null>(null);
function onNameInput(event: Event) {
const value = (event.target as HTMLInputElement).value;
createName = value;
if (!slugTouched) {
createSlug = slugify(value);
}
}
function onSlugInput(event: Event) {
const raw = (event.target as HTMLInputElement).value;
const normalized = slugify(raw);
createSlug = normalized;
// Re-sync the input element so a paste of "Hello World!" shows
// "hello-world" immediately, not the raw value.
if (raw !== normalized) {
(event.target as HTMLInputElement).value = normalized;
}
slugTouched = normalized.length > 0;
}
async function load() {
loading = true;
listError = null;
@@ -33,6 +58,7 @@
createDescription = '';
createError = null;
createHistoricalConflict = null;
slugTouched = false;
}
async function submitCreate(event: Event, forceTakeover = false) {
@@ -88,17 +114,28 @@
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
<div class="row">
<label>
<span>Slug</span>
<span>Name</span>
<input
bind:value={createSlug}
value={createName}
oninput={onNameInput}
required
pattern="[a-z0-9][a-z0-9-]*"
placeholder="my-app"
placeholder="My App"
/>
</label>
<label>
<span>Name</span>
<input bind:value={createName} required placeholder="My App" />
<span>Slug</span>
<input
value={createSlug}
oninput={onSlugInput}
required
pattern="[a-z0-9][a-z0-9-]*"
maxlength={SLUG_MAX}
placeholder="my-app"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
/>
</label>
</div>
<label>