343 lines
12 KiB
Svelte
343 lines
12 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* The application shell.
|
|
*
|
|
* It loads the content that every page shares, tracks the route, and
|
|
* renders the page for the current route. Content is fetched once and
|
|
* reused, because it is embedded in the binary and does not change while
|
|
* the service runs.
|
|
*/
|
|
import type {
|
|
Health,
|
|
DocumentIndex,
|
|
Document,
|
|
Graph,
|
|
ApiIndex,
|
|
ToolRegistry,
|
|
SearchResponse,
|
|
} from './lib/types';
|
|
import { getJson, queryString } from './lib/api';
|
|
import {
|
|
currentRoute,
|
|
navigate,
|
|
onRouteChange,
|
|
scrollToTopOnNavigation,
|
|
} from './lib/router';
|
|
import { language, pick, setLanguage, tr, type Language } from './lib/i18n';
|
|
import { theme, toggleTheme } from './lib/theme';
|
|
import { untrack } from 'svelte';
|
|
import Home from './pages/Home.svelte';
|
|
import Docs from './pages/Docs.svelte';
|
|
import DocumentPage from './pages/Document.svelte';
|
|
import Search from './pages/Search.svelte';
|
|
import GraphPage from './pages/Graph.svelte';
|
|
import FlowsPage from './pages/Flows.svelte';
|
|
import ApiPage from './pages/Api.svelte';
|
|
import ToolsPage from './pages/Tools.svelte';
|
|
|
|
let route = $state(currentRoute());
|
|
/** The current language, read so that the shell re-renders on a switch. */
|
|
let lang = $derived($language);
|
|
let colour = $derived($theme);
|
|
|
|
/** Whether the sidebar covers the page, on a narrow screen. */
|
|
let menuOpen = $state(false);
|
|
|
|
/** Switches between the dark and light palettes. */
|
|
function onToggleTheme() {
|
|
toggleTheme(colour);
|
|
}
|
|
|
|
let health = $state<Health | null>(null);
|
|
let index = $state<DocumentIndex | null>(null);
|
|
let concepts = $state<Graph | null>(null);
|
|
let api = $state<ApiIndex | null>(null);
|
|
let tools = $state<ToolRegistry | null>(null);
|
|
|
|
let document = $state<Document | null>(null);
|
|
let documentId = $state<string | null>(null);
|
|
let search = $state<SearchResponse | null>(null);
|
|
let searchQuery = $state('');
|
|
let searchLoading = $state(false);
|
|
let status = $state<string | null>(null);
|
|
|
|
/** The chapters whose documents are hidden in the sidebar. */
|
|
let collapsed = $state<string[]>([]);
|
|
|
|
/** Folds a chapter, or unfolds it when it is already folded. */
|
|
// The sidebar covers the page on a narrow screen, so it is closed when the
|
|
// route changes. Following a link is the usual way the route changes, and
|
|
// the links are ordinary anchors, so this is where the close belongs.
|
|
$effect(() => {
|
|
void route;
|
|
untrack(() => {
|
|
menuOpen = false;
|
|
});
|
|
});
|
|
|
|
function toggleChapter(id: string) {
|
|
collapsed = collapsed.includes(id)
|
|
? collapsed.filter((entry) => entry !== id)
|
|
: [...collapsed, id];
|
|
}
|
|
|
|
/** Loads the content every page shares. */
|
|
async function loadShared() {
|
|
try {
|
|
const [healthResponse, indexResponse, graphResponse, apiResponse, toolsResponse] =
|
|
await Promise.all([
|
|
getJson<Health>('/health'),
|
|
getJson<DocumentIndex>('/api/docs'),
|
|
getJson<Graph>('/api/graph'),
|
|
getJson<ApiIndex>('/api/api'),
|
|
getJson<ToolRegistry>('/api/tools'),
|
|
]);
|
|
health = healthResponse;
|
|
index = indexResponse;
|
|
concepts = graphResponse;
|
|
api = apiResponse;
|
|
tools = toolsResponse;
|
|
} catch {
|
|
status = 'The service content could not be loaded. Reload the page to try again.';
|
|
}
|
|
}
|
|
|
|
/** Loads the document named by the route. */
|
|
async function loadDocument(id: string | null) {
|
|
if (!id) {
|
|
document = null;
|
|
documentId = null;
|
|
return;
|
|
}
|
|
if (id === documentId) return;
|
|
try {
|
|
document = await getJson<Document>(`/api/docs/${encodeURIComponent(id)}`);
|
|
documentId = id;
|
|
} catch {
|
|
document = null;
|
|
documentId = id;
|
|
}
|
|
}
|
|
|
|
/** Runs the search named by the route. */
|
|
async function loadSearch(query: string) {
|
|
if (!query) {
|
|
search = null;
|
|
searchQuery = '';
|
|
return;
|
|
}
|
|
searchLoading = true;
|
|
try {
|
|
search = await getJson<SearchResponse>(`/api/docs/search${queryString({ q: query, limit: 40 })}`);
|
|
searchQuery = query;
|
|
} catch {
|
|
search = null;
|
|
} finally {
|
|
searchLoading = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
void loadShared();
|
|
// A new page starts at the top; going back keeps the position.
|
|
const stopNavigation = scrollToTopOnNavigation();
|
|
const stop = onRouteChange(() => {
|
|
route = currentRoute();
|
|
});
|
|
return () => {
|
|
stop();
|
|
stopNavigation();
|
|
};
|
|
});
|
|
|
|
$effect(() => {
|
|
const current = route;
|
|
void loadDocument(current.name === 'docs' ? current.id : null);
|
|
void loadSearch(current.name === 'search' ? current.query.get('q') ?? '' : '');
|
|
});
|
|
|
|
/** Submits the search box. */
|
|
function submitSearch(event: SubmitEvent) {
|
|
event.preventDefault();
|
|
const input = (event.currentTarget as HTMLFormElement).querySelector('input');
|
|
const value = input?.value.trim() ?? '';
|
|
if (value === '') return;
|
|
navigate(`/search?q=${encodeURIComponent(value)}`);
|
|
}
|
|
|
|
/** The page title, used for the document title element. */
|
|
const title = $derived.by(() => {
|
|
if (route.name === 'docs' && document) {
|
|
const form = document.forms.find((entry) => entry.language === lang) ?? document.forms[0];
|
|
if (form) return `${form.title} · ApsisRS`;
|
|
}
|
|
if (route.name === 'api') return `${tr('nav.api', lang)} · ApsisRS`;
|
|
if (route.name === 'graph') return `${tr('nav.graph', lang)} · ApsisRS`;
|
|
if (route.name === 'flows') return `${tr('nav.flows', lang)} · ApsisRS`;
|
|
if (route.name === 'tools') return `${tr('nav.tools', lang)} · ApsisRS`;
|
|
if (route.name === 'search') return `${tr('search.title', lang)} · ApsisRS`;
|
|
return 'ApsisRS';
|
|
});
|
|
|
|
/** Switches the interface language. */
|
|
function toggleLanguage() {
|
|
const next: Language = lang === 'zh' ? 'en' : 'zh';
|
|
setLanguage(next);
|
|
}
|
|
|
|
$effect(() => {
|
|
window.document.title = title;
|
|
});
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{title}</title>
|
|
</svelte:head>
|
|
|
|
<div class="app">
|
|
<div class="sidebar-frame">
|
|
{#if menuOpen}
|
|
<!-- Dismisses the drawer by tapping the page behind it. It is a button
|
|
so that it is reachable from the keyboard. -->
|
|
<button
|
|
class="sidebar-scrim"
|
|
type="button"
|
|
tabindex={-1}
|
|
aria-label={tr('nav.closeMenu', lang)}
|
|
onclick={() => (menuOpen = false)}
|
|
></button>
|
|
{/if}
|
|
|
|
<aside class="sidebar" class:open={menuOpen}>
|
|
<div class="sidebar-brand">
|
|
<a href="#/">APSIS</a>
|
|
<p>{tr('app.subtitle', lang)}</p>
|
|
</div>
|
|
|
|
<nav>
|
|
<div class="sidebar-section">
|
|
<h2>{tr('nav.explore', lang)}</h2>
|
|
<a class="item" class:active={route.name === ''} href="#/">{tr('nav.overview', lang)}</a>
|
|
<a class="item" class:active={route.name === 'docs'} href="#/docs">{tr('nav.docs', lang)}</a>
|
|
<a class="item" class:active={route.name === 'graph'} href="#/graph"
|
|
>{tr('nav.graph', lang)}</a>
|
|
<a class="item" class:active={route.name === 'flows'} href="#/flows"
|
|
>{tr('nav.flows', lang)}</a>
|
|
<a class="item" class:active={route.name === 'api'} href="#/api">{tr('nav.api', lang)}</a>
|
|
<a class="item" class:active={route.name === 'tools'} href="#/tools"
|
|
>{tr('nav.tools', lang)}</a>
|
|
</div>
|
|
|
|
{#if index}
|
|
{#each index.groups as group (group.id)}
|
|
<div class="sidebar-section">
|
|
<h2 class="sidebar-group-title">{pick(group.title, lang)}</h2>
|
|
{#each group.sections as section (section.id)}
|
|
<div class="sidebar-chapter" class:collapsed={collapsed.includes(section.id)}>
|
|
<a class="sidebar-chapter-title" href="#/docs" title={pick(section.title, lang)}>
|
|
{pick(section.title, lang)}
|
|
</a>
|
|
<button
|
|
class="sidebar-chapter-toggle"
|
|
aria-expanded={!collapsed.includes(section.id)}
|
|
aria-label="{tr('nav.toggleChapter', lang)}: {pick(section.title, lang)}"
|
|
onclick={() => toggleChapter(section.id)}
|
|
>▾</button>
|
|
{#if !collapsed.includes(section.id)}
|
|
{#each section.documents as entry (entry.id)}
|
|
<a
|
|
class="item"
|
|
class:active={route.name === 'docs' && route.id === entry.id}
|
|
href={`#/docs/${entry.id}`}>{pick(entry.title, lang)}</a>
|
|
{/each}
|
|
{#each section.subsections as subject (subject.id)}
|
|
<div class="sidebar-subject">{pick(subject.title, lang)}</div>
|
|
{#each subject.documents as entry (entry.id)}
|
|
<a
|
|
class="item"
|
|
class:active={route.name === 'docs' && route.id === entry.id}
|
|
href={`#/docs/${entry.id}`}>{pick(entry.title, lang)}</a>
|
|
{/each}
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/each}
|
|
{/if}
|
|
</nav>
|
|
</aside>
|
|
</div>
|
|
|
|
<div class="main">
|
|
<header class="topbar">
|
|
<form class="search-box" onsubmit={submitSearch}>
|
|
<input
|
|
type="search"
|
|
placeholder={tr('search.placeholder', lang)}
|
|
value={searchQuery}
|
|
aria-label={tr('search.placeholder', lang)}
|
|
/>
|
|
<button type="submit">{tr('search.button', lang)}</button>
|
|
</form>
|
|
{#if health}
|
|
<span class="badge">{health.documents} {tr('nav.documents', lang)}</span>
|
|
<span class="badge">{health.tools} {tr('tool.plural', lang)}</span>
|
|
<span class="badge">v{health.version}</span>
|
|
{/if}
|
|
<button
|
|
class="ghost nav-button"
|
|
type="button"
|
|
aria-label={colour === 'dark' ? tr('nav.lightMode', lang) : tr('nav.darkMode', lang)}
|
|
title={colour === 'dark' ? tr('nav.lightMode', lang) : tr('nav.darkMode', lang)}
|
|
onclick={onToggleTheme}
|
|
>{colour === 'dark' ? '☀' : '☾'}</button>
|
|
<button
|
|
class="ghost"
|
|
type="button"
|
|
title={tr('docs.otherLanguageTitle', lang)}
|
|
onclick={toggleLanguage}
|
|
>{tr('docs.otherLanguage', lang)}</button>
|
|
<button
|
|
class="ghost nav-button menu-button"
|
|
type="button"
|
|
aria-expanded={menuOpen}
|
|
aria-label={menuOpen ? tr('nav.closeMenu', lang) : tr('nav.menu', lang)}
|
|
title={menuOpen ? tr('nav.closeMenu', lang) : tr('nav.menu', lang)}
|
|
onclick={() => (menuOpen = !menuOpen)}
|
|
>{menuOpen ? '✕' : '☰'}</button>
|
|
</header>
|
|
|
|
<main class="content" class:doc={route.name === 'docs' && route.id !== null} class:wide={route.name === 'graph' || route.name === 'flows'}>
|
|
{#if status}
|
|
<div class="notice error"><p>{status}</p></div>
|
|
{/if}
|
|
|
|
{#if route.name === ''}
|
|
<Home {health} {index} />
|
|
{:else if route.name === 'docs'}
|
|
{#if route.id}
|
|
<DocumentPage {document} id={route.id} />
|
|
{:else}
|
|
<Docs {index} />
|
|
{/if}
|
|
{:else if route.name === 'search'}
|
|
<Search response={search} query={searchQuery} loading={searchLoading} />
|
|
{:else if route.name === 'graph'}
|
|
<GraphPage {concepts} {api} />
|
|
{:else if route.name === 'flows'}
|
|
<FlowsPage id={route.id} />
|
|
{:else if route.name === 'api'}
|
|
<ApiPage index={api} id={route.id} />
|
|
{:else if route.name === 'tools'}
|
|
<ToolsPage registry={tools} id={route.id} />
|
|
{:else}
|
|
<div class="notice error">
|
|
<p>{tr('common.notFound', lang)} <code>{route.name}</code>。</p>
|
|
<p><a href="#/">{tr('common.goHome', lang)}</a></p>
|
|
</div>
|
|
{/if}
|
|
</main>
|
|
</div>
|
|
</div>
|