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
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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.' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue