web: give a tool its own page instead of sharing the index

This commit is contained in:
dev 2026-10-01 11:31:26 +08:00
parent b4ed846f73
commit 3a3caf5496
3 changed files with 127 additions and 121 deletions

View File

@ -399,6 +399,22 @@ reused. Neither is as fast as naming the content.
The service registers no service worker and emits no manifest, so there is The service registers no service worker and emits no manifest, so there is
no second cache layer to reason about. no second cache layer to reason about.
## Tool routes
The tool route has two states. Without a tool identifier the page is the
index: one card per tool, grouped by category, each card opening the tool.
With an identifier the page is that tool on its own.
The two states are one route rather than two pages. A single route means the
back button, a shared link, and a reload all behave the same way whether the
reader arrived from the index or from a link elsewhere. The tool page carries
a link back to the index, because the index is not on it.
The tool page holds no list. That is the point of the split: a reader who
opens a link to a tool sees the form rather than the list it was reached
from, and the form gets the whole width. The cost is that comparing two
tools takes two round trips through the index.
## Testing ## Testing
Unit tests live beside the code they test and cover the parser, the codec, Unit tests live beside the code they test and cover the parser, the codec,

View File

@ -298,6 +298,7 @@ const STRINGS: Record<string, Text> = {
'api.itemCount': { zh: '个接口项', en: 'items' }, 'api.itemCount': { zh: '个接口项', en: 'items' },
'tools.open': { zh: '打开', en: 'Open' }, 'tools.open': { zh: '打开', en: 'Open' },
'tools.back': { zh: '全部工具', en: 'All tools' },
'tools.choose': { zh: '共 {count} 个工具,按类别分组。', en: '{count} tools, grouped by category.' }, 'tools.choose': { zh: '共 {count} 个工具,按类别分组。', en: '{count} tools, grouped by category.' },
}; };

View File

@ -2,6 +2,12 @@
/** /**
* The online tools. * The online tools.
* *
* The route has two states. Without a tool identifier the page is the
* index: one card per tool, grouped by category, each card opening the
* tool. With an identifier the page is that tool on its own, so a link
* that names a tool shows the form rather than the list it was reached
* from. The index carries a way back.
*
* The form is generated from the tool description the backend supplies, * The form is generated from the tool description the backend supplies,
* so a tool that is added to the registry appears here without a change to * so a tool that is added to the registry appears here without a change to
* this component. Running a tool posts the form values as JSON and renders * this component. Running a tool posts the form values as JSON and renders
@ -24,9 +30,9 @@
let lang = $derived($language); let lang = $derived($language);
/** /**
* The page root, measured so that every tool card shares one height. The * The index, measured so that every tool card shares one height. The cards
* cards are spread over one grid per category, so the measurement has to * are spread over one grid per category, so the measurement has to start
* start from the element that contains all of them. * from the element that contains all of them.
*/ */
let page = $state<HTMLElement | null>(null); let page = $state<HTMLElement | null>(null);
$effect(() => uniformCards(page)); $effect(() => uniformCards(page));
@ -61,32 +67,17 @@
const currentRegistry = registry; const currentRegistry = registry;
const currentId = id; const currentId = id;
if (!currentRegistry) return; if (!currentRegistry) return;
// With no tool named in the route the first one is shown, so the page // A tool is opened by naming it in the route. Without a name the page is
// opens on a working form rather than an invitation to pick. // the index, and no tool is selected.
const wanted = const found = currentId
(currentId ? (currentRegistry.tools.find((candidate) => candidate.id === currentId) ?? null)
? currentRegistry.tools.find((candidate) => candidate.id === currentId) : null;
: undefined) ?? currentRegistry.tools[0];
const found = wanted ?? null;
tool = found; tool = found;
result = null; result = null;
error = null; error = null;
values = defaultsFor(found); values = defaultsFor(found);
}); });
/**
* Moves the form into view after a tool is chosen.
*
* The index and the form share the page, so a reader who picks a tool from
* the list would otherwise stay on the index and not see the form. This
* runs only when the route names a tool, which is what a click does.
*/
let form = $state<HTMLElement | null>(null);
$effect(() => {
if (!id || !form) return;
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
/** Runs the selected tool. */ /** Runs the selected tool. */
async function run() { async function run() {
if (!tool) return; if (!tool) return;
@ -143,47 +134,21 @@
</script> </script>
<div bind:this={page} class="page-root"> <div bind:this={page} class="page-root">
<div class="page-header">
<h1>{tr('tools.title', lang)}</h1>
<p>{tr('tools.intro', lang)}</p>
</div>
{#if !registry} {#if !registry}
<p class="empty">{tr('tools.loading', lang)}</p> <p class="empty">{tr('tools.loading', lang)}</p>
{:else} {:else if tool}
{#each categories as group} <!-- One tool on its own. The link back is the only way to the index,
<section style="margin-bottom: 2rem;"> because the index is not on this page. -->
<h3 style="margin-bottom: 0.75rem;">{pick(group.category, lang)}</h3> <p class="breadcrumb">
<div class="grid cards"> <a href="#/tools">← {tr('tools.back', lang)}</a>
{#each group.tools as candidate} </p>
<a
class="card tool-card"
class:selected={id === candidate.id}
href={`#/tools/${candidate.id}`}
aria-label="{tr('tools.open', lang)}: {pick(candidate.name, lang)}"
>
<h4>
{pick(candidate.name, lang)}
<span class="tool-go" aria-hidden="true">›</span>
</h4>
<p>{pick(candidate.summary, lang)}</p>
</a>
{/each}
</div>
</section>
{/each}
{#if !tool} <section class="card tool-form">
<div class="notice muted">
<p>{tr('tools.select', lang)}</p>
</div>
{:else}
<section class="card tool-form" bind:this={form} style="margin-top: 1.5rem;">
<header style="display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;"> <header style="display: flex; align-items: baseline; gap: 0.75rem; flex-wrap: wrap;">
<h2 style="margin: 0; border: none;">{pick(tool.name, lang)}</h2> <h1 style="margin: 0; border: none;">{pick(tool.name, lang)}</h1>
<span class="badge">{pick(tool.category, lang)}</span> <span class="badge">{pick(tool.category, lang)}</span>
</header> </header>
<p style="color: var(--text-muted);">{pick(tool.description, lang)}</p> <p style="color: var(--text-muted)">{pick(tool.description, lang)}</p>
<form <form
onsubmit={(event) => { onsubmit={(event) => {
@ -194,11 +159,9 @@
<div class="form-grid"> <div class="form-grid">
{#each tool.params as param} {#each tool.params as param}
{@const invalid = error?.parameter === param.name} {@const invalid = error?.parameter === param.name}
<div <div class="field {param.kind === 'multiline' ? 'wide' : ''} {invalid ? 'invalid' : ''}">
class="field {param.kind === 'multiline' ? 'wide' : ''} {invalid ? 'invalid' : ''}"
>
<label for={`field-${param.name}`}> <label for={`field-${param.name}`}>
{pick(param.label, lang)}{#if param.required}<span style="color: var(--negative);" {pick(param.label, lang)}{#if param.required}<span style="color: var(--negative)"
> *</span>{/if} > *</span>{/if}
</label> </label>
{#if param.kind === 'select'} {#if param.kind === 'select'}
@ -208,7 +171,8 @@
{/each} {/each}
</select> </select>
{:else if param.kind === 'multiline'} {:else if param.kind === 'multiline'}
<textarea id={`field-${param.name}`} bind:value={values[param.name]} rows="4"></textarea> <textarea id={`field-${param.name}`} bind:value={values[param.name]} rows="4"
></textarea>
{:else if param.kind === 'number' || param.kind === 'integer'} {:else if param.kind === 'number' || param.kind === 'integer'}
<input <input
id={`field-${param.name}`} id={`field-${param.name}`}
@ -251,9 +215,7 @@
{#if error} {#if error}
<div class="notice error" style="margin-top: 1rem;"> <div class="notice error" style="margin-top: 1rem;">
<p> <p>
{#if error.parameter} {#if error.parameter}<strong>{error.parameter}:</strong>{/if}
<strong>{error.parameter}:</strong>
{/if}
{error.message} {error.message}
</p> </p>
</div> </div>
@ -264,6 +226,33 @@
<ResultView {result} /> <ResultView {result} />
</div> </div>
{/if} {/if}
{/if} {:else}
<!-- The index. -->
<div class="page-header">
<h1>{tr('tools.title', lang)}</h1>
<p>{tr('tools.intro', lang)}</p>
</div>
{#each categories as group}
<section style="margin-bottom: 2rem;">
<h3 style="margin-bottom: 0.75rem;">{pick(group.category, lang)}</h3>
<div class="grid cards">
{#each group.tools as candidate}
<a
class="card tool-card"
class:selected={id === candidate.id}
href={`#/tools/${candidate.id}`}
aria-label="{tr('tools.open', lang)}: {pick(candidate.name, lang)}"
>
<h4>
{pick(candidate.name, lang)}
<span class="tool-go" aria-hidden="true">›</span>
</h4>
<p>{pick(candidate.summary, lang)}</p>
</a>
{/each}
</div>
</section>
{/each}
{/if} {/if}
</div> </div>