web: give a tool its own page instead of sharing the index
This commit is contained in:
parent
895c6d3a3a
commit
bdce02d9ed
|
|
@ -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
|
||||
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
|
||||
|
||||
Unit tests live beside the code they test and cover the parser, the codec,
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@ const STRINGS: Record<string, Text> = {
|
|||
|
||||
'api.itemCount': { zh: '个接口项', en: 'items' },
|
||||
'tools.open': { zh: '打开', en: 'Open' },
|
||||
'tools.back': { zh: '全部工具', en: 'All tools' },
|
||||
'tools.choose': { zh: '共 {count} 个工具,按类别分组。', en: '{count} tools, grouped by category.' },
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
/**
|
||||
* 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,
|
||||
* 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
|
||||
|
|
@ -24,9 +30,9 @@
|
|||
let lang = $derived($language);
|
||||
|
||||
/**
|
||||
* The page root, measured so that every tool card shares one height. The
|
||||
* cards are spread over one grid per category, so the measurement has to
|
||||
* start from the element that contains all of them.
|
||||
* The index, measured so that every tool card shares one height. The cards
|
||||
* are spread over one grid per category, so the measurement has to start
|
||||
* from the element that contains all of them.
|
||||
*/
|
||||
let page = $state<HTMLElement | null>(null);
|
||||
$effect(() => uniformCards(page));
|
||||
|
|
@ -61,32 +67,17 @@
|
|||
const currentRegistry = registry;
|
||||
const currentId = id;
|
||||
if (!currentRegistry) return;
|
||||
// With no tool named in the route the first one is shown, so the page
|
||||
// opens on a working form rather than an invitation to pick.
|
||||
const wanted =
|
||||
(currentId
|
||||
? currentRegistry.tools.find((candidate) => candidate.id === currentId)
|
||||
: undefined) ?? currentRegistry.tools[0];
|
||||
const found = wanted ?? null;
|
||||
// A tool is opened by naming it in the route. Without a name the page is
|
||||
// the index, and no tool is selected.
|
||||
const found = currentId
|
||||
? (currentRegistry.tools.find((candidate) => candidate.id === currentId) ?? null)
|
||||
: null;
|
||||
tool = found;
|
||||
result = null;
|
||||
error = null;
|
||||
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. */
|
||||
async function run() {
|
||||
if (!tool) return;
|
||||
|
|
@ -143,47 +134,21 @@
|
|||
</script>
|
||||
|
||||
<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}
|
||||
<p class="empty">{tr('tools.loading', lang)}</p>
|
||||
{:else}
|
||||
{#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}
|
||||
{:else if tool}
|
||||
<!-- One tool on its own. The link back is the only way to the index,
|
||||
because the index is not on this page. -->
|
||||
<p class="breadcrumb">
|
||||
<a href="#/tools">← {tr('tools.back', lang)}</a>
|
||||
</p>
|
||||
|
||||
{#if !tool}
|
||||
<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;">
|
||||
<section class="card tool-form">
|
||||
<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>
|
||||
</header>
|
||||
<p style="color: var(--text-muted);">{pick(tool.description, lang)}</p>
|
||||
<p style="color: var(--text-muted)">{pick(tool.description, lang)}</p>
|
||||
|
||||
<form
|
||||
onsubmit={(event) => {
|
||||
|
|
@ -194,11 +159,9 @@
|
|||
<div class="form-grid">
|
||||
{#each tool.params as param}
|
||||
{@const invalid = error?.parameter === param.name}
|
||||
<div
|
||||
class="field {param.kind === 'multiline' ? 'wide' : ''} {invalid ? 'invalid' : ''}"
|
||||
>
|
||||
<div class="field {param.kind === 'multiline' ? 'wide' : ''} {invalid ? 'invalid' : ''}">
|
||||
<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}
|
||||
</label>
|
||||
{#if param.kind === 'select'}
|
||||
|
|
@ -208,7 +171,8 @@
|
|||
{/each}
|
||||
</select>
|
||||
{: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'}
|
||||
<input
|
||||
id={`field-${param.name}`}
|
||||
|
|
@ -251,9 +215,7 @@
|
|||
{#if error}
|
||||
<div class="notice error" style="margin-top: 1rem;">
|
||||
<p>
|
||||
{#if error.parameter}
|
||||
<strong>{error.parameter}:</strong>
|
||||
{/if}
|
||||
{#if error.parameter}<strong>{error.parameter}:</strong>{/if}
|
||||
{error.message}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -264,6 +226,33 @@
|
|||
<ResultView {result} />
|
||||
</div>
|
||||
{/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}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue