Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements. Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
This commit is contained in:
@@ -24,6 +24,8 @@
|
||||
let message = $state('');
|
||||
let preferredTarget = $state<number | null>(null);
|
||||
let nextKey = 0;
|
||||
let activeFile = $state('');
|
||||
const checkedCount = $derived(queue.filter(q => q.inspected).length);
|
||||
const pending = $derived(queue.filter(q => !q.result));
|
||||
const inspecting = $derived(queue.some(q => q.inspecting));
|
||||
const hasNew = $derived(pending.some(q => q.target === 'new'));
|
||||
@@ -56,19 +58,20 @@
|
||||
return scripts.filter(s => s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() === file.name.toLowerCase());
|
||||
}
|
||||
export async function addFiles(files: File[]) {
|
||||
if (busy || !files.length) return;
|
||||
if (busy || inspecting || !files.length) return;
|
||||
if (!dialog.open) open();
|
||||
for (const file of files) {
|
||||
const items = files.map(file => {
|
||||
const matches = matchingScripts(file);
|
||||
const item: Upload = { key: nextKey++, file, target: preferredTarget ?? (matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
||||
name: file.name.replace(/\.[^.]+$/, ''), duplicates: [], inspecting: true, inspected: false, error: '', result: null };
|
||||
queue = [...queue, item];
|
||||
// Work with the reactive proxy, rather than the original object.
|
||||
await inspect(queue.find(q => q.key === item.key)!);
|
||||
}
|
||||
return item;
|
||||
});
|
||||
queue = [...queue, ...items];
|
||||
for (const item of items) await inspect(queue.find(q => q.key === item.key)!);
|
||||
preferredTarget = null;
|
||||
}
|
||||
async function inspect(item: Upload) {
|
||||
activeFile = item.file.name;
|
||||
item.inspecting = true; item.error = '';
|
||||
try {
|
||||
const form = new FormData(); form.append('content', item.file);
|
||||
@@ -77,7 +80,7 @@
|
||||
if (!res.ok) throw new Error(body.error ?? 'Could not inspect this file.');
|
||||
item.duplicates = body.duplicates; item.inspected = true;
|
||||
} catch (e) { item.error = (e as Error).message; }
|
||||
finally { item.inspecting = false; }
|
||||
finally { item.inspecting = false; activeFile = ''; }
|
||||
}
|
||||
function duplicate(item: Upload) { return item.duplicates.find(d => d.scriptId === item.target); }
|
||||
function validation(item: Upload): string {
|
||||
@@ -99,6 +102,7 @@
|
||||
busy = true; message = '';
|
||||
try {
|
||||
for (const item of queue.filter(q => !q.result)) {
|
||||
activeFile = item.file.name;
|
||||
item.error = '';
|
||||
try {
|
||||
const form = new FormData();
|
||||
@@ -126,7 +130,7 @@
|
||||
if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); }
|
||||
}
|
||||
} catch (e) { message = (e as Error).message; }
|
||||
finally { busy = false; }
|
||||
finally { busy = false; activeFile = ''; }
|
||||
}
|
||||
function close(event?: Event) { if (busy || inspecting) { event?.preventDefault(); return; } dialog.close(); }
|
||||
</script>
|
||||
@@ -183,6 +187,7 @@
|
||||
{/if}
|
||||
{#if message}<p class="notice" role="status">{message}</p>{/if}
|
||||
<footer>
|
||||
{#if inspecting || busy}<p class="batch-progress" role="status">{inspecting ? `Checked ${checkedCount} of ${queue.length} files` : `Uploaded ${results.length} of ${queue.length} files`}{activeFile ? ` · ${activeFile}` : ''}</p>{:else if queue.length}<p class="batch-progress">{results.length} saved · {pending.length} remaining{queue.some(q => q.error) ? ` · ${queue.filter(q => q.error).length} need attention` : ''}</p>{/if}
|
||||
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
|
||||
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
|
||||
</footer>
|
||||
@@ -205,6 +210,8 @@
|
||||
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||
.success, .warning, .error { margin: 0; font-size: var(--text-sm); line-height: 1.5; } .success { color: var(--state-online); } .warning { color: var(--state-warning); } .error { color: var(--state-danger); } a { color: inherit; }
|
||||
.notice { padding: 14px; background: var(--surface-raised); border-radius: var(--radius-md); font-size: var(--text-sm); line-height: 1.6; }
|
||||
footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding-top: 20px; border-top: 1px solid var(--border-subtle); }
|
||||
footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding: 16px 0; border-top: 1px solid var(--border-subtle); position: sticky; bottom: 0; z-index: 3; background: var(--surface); }
|
||||
.batch-progress { flex-basis: 100%; margin: 0; color: var(--text-secondary); font-size: var(--text-sm); overflow-wrap: anywhere; }
|
||||
@media (max-width: 640px) { .drawer { padding: 20px; } }
|
||||
input:not([type='checkbox']):not([type='file']), select, textarea { border-color: var(--border-control); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user