Files
RenewCMS/docs/design/source/RenewCMS Handoff.dc.html
T

187 lines
22 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="icon" href="brand/favicon.svg">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>body{margin:0;background:#fafafa;color:#222;font-family:'IBM Plex Sans',sans-serif;font-size:13px;line-height:20px}*{box-sizing:border-box}a{color:#111;text-underline-offset:3px}a:hover{color:#444}code{font-family:'IBM Plex Mono',monospace;font-size:12px}</style>
</helmet>
<div data-screen-label="Handoff" style="max-width:1200px;margin:0 auto;padding:56px 24px 96px;display:flex;flex-direction:column;gap:64px">
<div style="display:flex;flex-direction:column;gap:16px">
<div style="display:flex;align-items:center;gap:12px"><svg width="36" height="36" viewBox="0 0 16 16" shape-rendering="crispEdges"><rect width="16" height="16" fill="#111"></rect><path fill="#fff" fill-rule="evenodd" d="M4 3h6v1h1v1h1v3h-1v1h-1v1h1v1h1v2h-2v-2h-1v-1H6v3H4zm2 2v3h4V5z"></path></svg><h1 style="margin:0;font:600 34px/42px 'IBM Plex Sans',sans-serif;letter-spacing:-0.02em;color:#111">RenewCMS handoff</h1></div>
<p style="margin:0;max-width:760px;font-size:16px;line-height:26px;color:#444;text-wrap:pretty">Everything needed to build the back-office in Vue 3, vue-router, Pinia and Nuxt UI v4. Tokens have one source of truth, <code>tokens/renewcms.css</code> plus <code>tokens/ui.config.ts</code>. Components use PascalCase names and routes are kebab-case, matching the code.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,260px),1fr));gap:1px;background:#e6e4e0;border:1px solid #e6e4e0">
<sc-for list="{{ files }}" as="f" hint-placeholder-count="10"><a href="{{ f.href }}" style="background:#fff;padding:14px 16px;display:flex;flex-direction:column;gap:2px;text-decoration:none"><span style="font-weight:600;color:#111">{{ f.t }}</span><span style="color:#666">{{ f.d }}</span></a></sc-for>
</div>
</div>
<section style="display:flex;flex-direction:column;gap:16px">
<h2 style="margin:0;font:600 24px/32px 'IBM Plex Sans',sans-serif;color:#111">Route map</h2>
<p style="margin:0;max-width:760px;color:#444;font-size:14px;line-height:22px;text-wrap:pretty">A global <code>router.beforeEach</code> guard reads <code>meta.auth</code>. On the first navigation it resolves the session with <code>GET /v1/auth/me</code> (API pending) and caches the result in the <code>session</code> store. A fetch wrapper, <code>useApi</code>, turns any 401 into <code>/login?redirect=&amp;reason=expired</code> and any 403 from an unverified account into <code>/register/pending</code>.</p>
<div style="border:1px solid #e6e4e0;background:#fff;overflow-x:auto"><div style="min-width:980px">
<div style="display:grid;grid-template-columns:230px 120px 90px 170px minmax(0,1fr);gap:14px;padding:10px 16px;background:#f3f2f0;color:#666;font-weight:500"><span>Path</span><span>Layout</span><span>Auth</span><span>Screen</span><span>API calls</span></div>
<sc-for list="{{ routes }}" as="r" hint-placeholder-count="13"><div style="display:grid;grid-template-columns:230px 120px 90px 170px minmax(0,1fr);gap:14px;padding:10px 16px;border-top:1px solid #e6e4e0"><code style="color:#111">{{ r.p }}</code><span>{{ r.l }}</span><span>{{ r.a }}</span><span style="font-weight:500">{{ r.s }}</span><code style="color:#444">{{ r.api }}</code></div></sc-for>
</div></div>
</section>
<section style="display:flex;flex-direction:column;gap:16px">
<h2 style="margin:0;font:600 24px/32px 'IBM Plex Sans',sans-serif;color:#111">Component inventory</h2>
<div style="border:1px solid #e6e4e0;background:#fff;overflow-x:auto"><div style="min-width:1100px">
<div style="display:grid;grid-template-columns:170px 200px minmax(0,1.4fr) 150px 150px 170px;gap:14px;padding:10px 16px;background:#f3f2f0;color:#666;font-weight:500"><span>Component</span><span>Nuxt UI base</span><span>Props</span><span>Emits</span><span>Slots</span><span>Used on</span></div>
<sc-for list="{{ components }}" as="c" hint-placeholder-count="20"><div style="display:grid;grid-template-columns:170px 200px minmax(0,1.4fr) 150px 150px 170px;gap:14px;padding:10px 16px;border-top:1px solid #e6e4e0;font-size:12px;line-height:18px"><span style="font:600 13px 'IBM Plex Sans',sans-serif;color:#111">{{ c.n }}</span><code style="color:#444">{{ c.b }}</code><code style="color:#222">{{ c.p }}</code><code style="color:#444">{{ c.e }}</code><code style="color:#444">{{ c.s }}</code><span style="color:#444">{{ c.u }}</span></div></sc-for>
</div></div>
<p style="margin:0;color:#666">The composables behind these components are <code>useSession</code>, <code>useApi</code>, <code>useArticles</code> (the list cache plus actions shared by the table, cards and editor), <code>useArticleDraft</code> (dirty state, save, and the sessionStorage mirror), <code>useImageUpload</code> (XHR with progress) and <code>useRetryAfter</code> (429 countdown).</p>
</section>
<section style="display:flex;flex-direction:column;gap:16px">
<h2 style="margin:0;font:600 24px/32px 'IBM Plex Sans',sans-serif;color:#111">State matrix</h2>
<p style="margin:0;max-width:760px;color:#444;font-size:14px;line-height:22px">Every state is drawn at desktop and mobile on the <a href="RenewCMS Screens Auth.dc.html">auth</a> and <a href="RenewCMS Screens Back-office.dc.html">back-office</a> boards, with final copy.</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,560px),1fr));gap:16px">
<sc-for list="{{ matrix }}" as="m" hint-placeholder-count="11">
<div style="background:#fff;border:1px solid #e6e4e0">
<div style="padding:12px 16px;border-bottom:1px solid #e6e4e0;display:flex;align-items:baseline;justify-content:space-between;gap:12px"><span style="font:600 15px/22px 'IBM Plex Sans',sans-serif;color:#111">{{ m.s }}</span><code style="color:#666">{{ m.r }}</code></div>
<sc-for list="{{ m.states }}" as="x" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:170px minmax(0,1fr);gap:12px;padding:8px 16px;border-top:1px solid #f3f2f0;font-size:12px;line-height:18px"><span style="font-weight:500;color:#111">{{ x.n }}</span><span style="color:#444">{{ x.t }}</span></div></sc-for>
</div>
</sc-for>
</div>
</section>
<section style="display:flex;flex-direction:column;gap:16px">
<h2 style="margin:0;font:600 24px/32px 'IBM Plex Sans',sans-serif;color:#111">HTTP status to copy</h2>
<p style="margin:0;max-width:760px;color:#444;font-size:14px;line-height:22px;text-wrap:pretty">Response bodies are plain text and are never shown. <code>useApi</code> throws an <code>ApiError</code> with the <code>status</code> and a <code>context</code> key, and each screen maps them to the copy below.</p>
<div style="border:1px solid #e6e4e0;background:#fff;overflow-x:auto"><div style="min-width:860px">
<div style="display:grid;grid-template-columns:70px 220px minmax(0,1fr) 150px;gap:14px;padding:10px 16px;background:#f3f2f0;color:#666;font-weight:500"><span>Status</span><span>Where</span><span>Copy</span><span>Surface</span></div>
<sc-for list="{{ errors }}" as="e" hint-placeholder-count="18"><div style="display:grid;grid-template-columns:70px 220px minmax(0,1fr) 150px;gap:14px;padding:10px 16px;border-top:1px solid #e6e4e0"><code style="color:#111;font-weight:500">{{ e.c }}</code><span>{{ e.w }}</span><span style="color:#222">{{ e.t }}</span><span style="color:#666">{{ e.s }}</span></div></sc-for>
</div></div>
</section>
<section style="display:flex;flex-direction:column;gap:16px">
<h2 style="margin:0;font:600 24px/32px 'IBM Plex Sans',sans-serif;color:#111">API pending</h2>
<div style="display:flex;flex-direction:column;border-top:1px solid #e6e4e0">
<sc-for list="{{ pending }}" as="p" hint-placeholder-count="5"><div style="display:grid;grid-template-columns:minmax(0,300px) minmax(0,1fr);gap:24px;padding:12px 0;border-bottom:1px solid #e6e4e0"><code style="color:#111;font-weight:500">{{ p.a }}</code><span style="color:#444;text-wrap:pretty">{{ p.d }}</span></div></sc-for>
</div>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
renderVals() {
const files = [
{ t: 'Prototype', d: 'The five flows, clickable', href: 'RenewCMS Prototype.dc.html' },
{ t: 'Foundations', d: 'Tokens, contrast, type, motion, icons', href: 'RenewCMS Foundations.dc.html' },
{ t: 'Primitives', d: 'Themed Nuxt UI components', href: 'RenewCMS Components Primitives.dc.html' },
{ t: 'Product components', d: '17 RenewCMS components', href: 'RenewCMS Components Product.dc.html' },
{ t: 'Auth screens', d: 'Seven public routes, every state', href: 'RenewCMS Screens Auth.dc.html' },
{ t: 'Back-office screens', d: 'Verified routes and global states', href: 'RenewCMS Screens Back-office.dc.html' },
{ t: 'Emails', d: 'Previews of the three templates', href: 'RenewCMS Emails.dc.html' },
{ t: 'tokens/renewcms.css', d: '@theme, :root, .dark, .article-content', href: 'tokens/renewcms.css' },
{ t: 'tokens/ui.config.ts', d: 'Nuxt UI colors, icons, overrides', href: 'tokens/ui.config.ts' },
{ t: 'tokens/vite.config.ts', d: 'Vite plugin setup', href: 'tokens/vite.config.ts' },
{ t: 'emails/verification.html', d: 'Go html/template', href: 'emails/verification.html' },
{ t: 'emails/invitation.html', d: 'Go html/template', href: 'emails/invitation.html' },
{ t: 'emails/password-reset.html', d: 'Go html/template', href: 'emails/password-reset.html' },
{ t: 'brand/favicon.svg', d: '16 px grid mark, dark-aware', href: 'brand/favicon.svg' }
]
const routes = [
['/', 'none', 'any', 'Redirect', 'none; goes to /home or /login'],
['/login', 'AuthShell', 'public', 'LoginPage', 'GET /v1/auth/registration* · POST /v1/auth/login'],
['/register', 'AuthShell', 'public', 'RegisterPage', 'POST /v1/auth/register'],
['/register/pending', 'AuthShell', 'session', 'PendingPage', 'DELETE /v1/auth/register · POST /v1/auth/logout'],
['/register/validate', 'AuthShell', 'public', 'VerifyPage', 'POST /v1/auth/verify (+ POST /login inline on 401)'],
['/invite/accept', 'AuthShell', 'public', 'AcceptInvitePage', 'GET /v1/auth/invite?c=* · POST /v1/auth/invite/accept*'],
['/register/reset/request', 'AuthShell', 'public', 'ResetRequestPage', 'POST /v1/auth/password-reset'],
['/register/reset/validate', 'AuthShell', 'public', 'ResetConfirmPage', 'POST /v1/auth/password-reset/confirm'],
['/home', 'DashboardShell', 'verified', 'HomePage', 'GET /v1/article/'],
['/article', 'DashboardShell', 'verified', 'ArticlesPage', 'GET /v1/article/ · PATCH /{id}/publish|unpublish · DELETE /{id}'],
['/article/new', 'DashboardShell', 'verified', 'ArticlesPage + NewArticleModal', 'POST /v1/article/'],
['/article/:id/edit', 'DashboardShell', 'verified', 'EditorPage', 'GET /{id} · PATCH /{id} · PATCH /{id}/publish|unpublish · POST /{id}/image · DELETE /{id}'],
['/integration', 'DashboardShell', 'verified', 'IntegrationPage', 'none'],
['/:pathMatch(.*)*', 'either', 'any', 'NotFoundPage', 'none']
].map(([p, l, a, s, api]) => ({ p, l, a, s, api }))
const components = [
['AuthShell', 'UPageCard, UColorModeSelect', 'title, description?', 'none', 'default, notice, footer', 'All public routes'],
['DashboardShell', 'UDashboardGroup, UDashboardSidebar, UNavigationMenu, UDashboardPanel, UDashboardNavbar, UBreadcrumb', 'none (reads route.meta and the session store)', 'none', 'default, navbar-right', 'All verified routes'],
['UserMenu', 'UDropdownMenu, UButton, UAvatar', 'none', 'logout', 'none', 'DashboardShell'],
['PageHeader', 'UPageHeader', 'title, description?', 'none', 'actions, meta', 'Home, Articles, Integration'],
['ArticleStatusBadge', 'UBadge', "online: boolean, size?: 'sm'|'md', pending?: 'publish'|'unpublish'", 'none', 'none', 'Home, Articles, Editor'],
['ArticleTable', 'UTabs, UInput, UTable, UDropdownMenu, UPagination', 'articles: Article[], loading, busyIds: Set<number>', 'publish, unpublish, delete, copy-url', 'empty, toolbar-right', 'Articles (md and up)'],
['ArticleCardList', 'ULink, UDropdownMenu (custom list)', 'same as ArticleTable', 'same as ArticleTable', 'empty', 'Articles (below md)'],
['EmptyState', 'UEmpty', "variant: 'no-articles'|'no-results'|'no-drafts'|'no-online', query?", 'action', 'none', 'Home, Articles'],
['ConfirmDialog', 'UModal via useOverlay', "title, description, confirmLabel, pendingLabel, tone: 'danger'|'neutral', action: () => Promise<void>", 'close(confirmed)', 'none', 'Articles, Editor, Pending'],
['NewArticleModal', 'UModal, UForm, UFormField, UInput, UTextarea', 'none', 'created(article)', 'none', 'Home, Articles, /article/new'],
['ArticleEditor', 'UEditor, UEditorToolbar, UDropdownMenu (+ custom Indent extension)', 'modelValue: string, articleId: number, disabled?', 'update:modelValue, request-image', 'none', 'Editor'],
['ImageUploader', 'UModal, UFileUpload, UProgress', 'articleId: number', 'uploaded(location), close', 'none', 'Editor'],
['SaveStatus', 'custom (span + UButton)', "state: 'clean'|'dirty'|'saving'|'saved'|'error'|'offline', savedAt?: Date", 'retry', 'none', 'Editor'],
['PublishControl', 'UButton', 'online: boolean, dirty: boolean, pending?: string, disabled?', 'publish, unpublish', 'none', 'Editor'],
['ArticleDetails', 'UCard (desktop) / USlideover (mobile)', 'article: Article', 'copy-url', 'none', 'Editor'],
['CharacterCounter', 'custom', 'value: number, max: number, min?: number, warnAt?: number', 'none', 'none', 'NewArticleModal, Editor, Register, Accept invite'],
['PasswordStrengthHint', 'custom (in UFormField #help)', 'rules: { label, met }[], showErrors: boolean', 'none', 'none', 'Register, Reset, Accept invite'],
['ResultPanel', 'custom + UButton', "tone: 'success'|'error'|'info', icon, title, description, actions: ButtonProps[]", 'none', 'default', 'Pending, Verify, Reset request, Reset, Accept invite, Register (403)'],
['CodeBlock', 'custom (shiki) + UButton', 'code: string, lang: string, label?', 'none', 'none', 'Integration'],
['EndpointCard', 'UCard, UBadge, UTabs', "method: 'GET', path, description, examples: { fetch, curl, response }", 'none', 'none', 'Integration'],
['ErrorView', 'custom + UButton', "kind: 'not-found'|'error'", 'retry', 'none', 'Not found, error boundary'],
['OfflineBanner', 'UBanner', 'none (reads useOnline)', 'none', 'none', 'DashboardShell'],
['AppBoot', 'USkeleton inside DashboardShell', 'none', 'none', 'none', 'First load of any verified route']
].map(([n, b, p, e, s, u]) => ({ n, b, p, e, s, u }))
const M = (s, r, list) => ({ s, r, states: list.map(([n, t]) => ({ n, t })) })
const matrix = [
M('App boot', 'any verified route', [['Checking session', 'First navigation, while GET /v1/auth/me* is pending'], ['Verified', '200 with is_verified: the route renders'], ['Unverified', '200 with is_verified false: goes to /register/pending'], ['No session', '401: goes to /login?redirect=']]),
M('Log in', '/login', [['Default', 'Registration closed'], ['Fresh install', 'registration.open is true*'], ['Validation errors', 'Client rules fail on submit'], ['Submitting', 'Request in flight'], ['Wrong credentials', '403'], ['Too many attempts', '429 with Retry-After*'], ['Session expired', '?reason=expired from a 401'], ['Password changed', '?reason=password-changed'], ['Server error', '500 or network']]),
M('Register', '/register', [['Default', 'Open instance'], ['Validation errors', 'Client rules fail'], ['Submitting', 'In flight'], ['Registration closed', '403'], ['Server error', '500 or network']]),
M('Pending', '/register/pending', [['Default', 'After registering (email known)'], ['Email unknown', 'After logging in unverified'], ['Confirm cancel', 'Cancel registration clicked'], ['Cancelling', 'DELETE in flight'], ['Cancelled', '200: /register with toast'], ['Already verified', '403: /home with toast']]),
M('Verify', '/register/validate?c=', [['Verifying', 'On load'], ['Success', '200: /home with toast'], ['Invalid or expired', '403'], ['No session', '401: inline log in, then retry'], ['Missing code', 'No c parameter']]),
M('Accept invite', '/invite/accept?c=', [['Checking the link', 'On load: GET invite*'], ['Default', 'Valid, unused code; email read-only'], ['Validation errors', 'Register rules fail'], ['Submitting', 'In flight'], ['Username taken', '409'], ['Invalid or used', '404 or no c parameter'], ['Expired', '410: ask the person who invited you'], ['Server error', '500 or network'], ['Success', '200: logged in, /home with toast']]),
M('Forgot password', '/register/reset/request', [['Default', 'Page load'], ['Validation', 'Invalid email'], ['Submitting', 'In flight'], ['Sent', 'Any 200 (neutral copy)'], ['Too many attempts', '429*'], ['Server error', '500 or network']]),
M('Reset password', '/register/reset/validate', [['Default', 'c and email present'], ['Too short', 'Under 8 characters'], ['Mismatch', 'Confirm field differs'], ['Submitting', 'In flight'], ['Success', '200: /login?reason=password-changed'], ['Invalid or expired', '403'], ['Missing parameters', 'No c or no email']]),
M('Home', '/home', [['Loading', 'List in flight (after 150 ms)'], ['Default', 'List loaded'], ['Empty', 'Zero articles'], ['Error', '500 or network']]),
M('Articles', '/article', [['Loading', 'List in flight'], ['Default', 'Loaded'], ['Empty', 'Zero articles'], ['No results', 'Search matches nothing'], ['Empty tab', 'No drafts, or nothing online'], ['Row busy', 'Publish or unpublish in flight'], ['Action failed', '500 or network: toast with Retry'], ['Row vanished', '404 on any row action'], ['Delete confirm, in progress, failed, done', 'ConfirmDialog, then DELETE: 204 or 500']]),
M('New article', 'modal · /article/new', [['Default', 'Opened'], ['Validation', 'Title under 3 characters'], ['Submitting', 'POST in flight'], ['Title rejected', 'Server 400'], ['Error', '500 or network'], ['Created', '201: editor, with a toast']]),
M('Editor', '/article/:id/edit', [['Loading', 'GET in flight'], ['Not found', '404'], ['Saved', 'Clean'], ['Unsaved', 'Any edit'], ['Saving', 'PATCH in flight'], ['Save failed', '500 or network'], ['Very long article', '120,000 characters: no limit, neutral count'], ['Very long: saving, saved, failed', 'PATCH with large body'], ['Link: create, edit, invalid URL, empty text, long URL', 'Ctrl/⌘ K link modal'], ['Preview', 'Switch on'], ['Uploading, rejected, inserted', 'ImageUploader'], ['Publishing, unpublishing', 'PATCH publish or unpublish'], ['Leave guard', 'Navigating away while dirty'], ['Offline', 'navigator.onLine is false'], ['Deleted', '204: /article with toast'], ['Session expired', '401: draft kept in sessionStorage, then /login']]),
M('Integration', '/integration', [['Default', 'Page load'], ['Copied', 'Copy clicked (2 s)']])
]
const errors = [
['400', 'Any form', 'The field error from the client rules. A server 400 that got through: "Something in the form isn\'t valid. Check the highlighted fields."', 'Field + alert'],
['401', 'Any back-office call', 'Goes to /login?redirect=<path>&reason=expired. "Your session expired. Log in again to pick up where you left off."', 'Redirect + alert'],
['401', 'Verify', '"Log in to finish verifying." Inline log in, then retry', 'Inline form'],
['403', 'Log in', '"That username and password don\'t match. Check both and try again."', 'Alert'],
['403', 'Register', '"This site already has an owner. Log in instead."', 'ResultPanel'],
['403', 'Any back-office call', 'Unverified: goes to /register/pending', 'Redirect'],
['403', 'Verify', '"This verification link doesn\'t work." Offers Cancel registration and start over', 'ResultPanel'],
['403', 'Cancel registration', '"Your account is already verified." Goes to /home', 'Toast'],
['404', 'Accept invite', '"This invite link doesn\'t work." Offers Go to log in', 'ResultPanel'],
['409', 'Accept invite', '"That username is taken. Try another."', 'Field'],
['410', 'Accept invite', '"This invite has expired. Ask the person who invited you to send a new one."', 'ResultPanel'],
['403', 'Reset confirm', '"This reset link has expired." Offers Request a new link', 'ResultPanel'],
['404', 'Editor load', '"This article doesn\'t exist." Offers All articles', 'ErrorView'],
['404', 'Row or editor action', '"That article no longer exists." The row is removed', 'Toast'],
['404', 'Unknown route', '"Page not found"', 'ErrorView'],
['429', 'Log in, reset request', '"Too many attempts. For your security, wait a moment before trying again." With a countdown*', 'Alert + disabled button'],
['500', 'Save', '"Save failed" + Retry. Changes are kept', 'SaveStatus'],
['500', 'Publish / unpublish', '"Couldn\'t publish" / "Couldn\'t unpublish" + Retry', 'Toast'],
['500', 'Delete', '"Couldn\'t delete the article, so it\'s still there. Try again in a moment."', 'Inline in dialog'],
['500', 'Upload', '"Upload failed. The server didn\'t accept the file. Your article hasn\'t changed."', 'ImageUploader'],
['network', 'Anywhere', '"Couldn\'t reach RenewCMS. Check your connection and try again." Offline: banner', 'Alert / toast / banner']
].map(([c, w, t, s]) => ({ c, w, t, s }))
const pending = [
{ a: 'GET /v1/auth/me → User | 401', d: 'The session check at boot. The HttpOnly cookie can\'t be read from JS. Until this exists, the guard can call GET /v1/article/ and infer: 401 means logged out, but verified and unverified users can\'t be told apart.' },
{ a: 'GET /v1/auth/registration → { open }', d: 'Shows "Create the owner account" on /login. Without it, the link is hidden and /register is reachable by URL only.' },
{ a: 'GET /v1/auth/invite?c=CODE → { email } | 404 | 410', d: 'Looks up the invite on load so the email can be shown and dead links are caught before the form. POST /v1/auth/invite/accept { code, username, password } returns 200 with the session cookie, 409 for a taken username, 404 or 410 as above. Neither exists yet.' },
{ a: '429 + Retry-After on /login, /password-reset', d: 'Drives the countdown state. The UI falls back to 60 s if the header is missing.' },
{ a: 'Serving /static/uploadedImages/*', d: 'The editor and preview are designed as if images load. Until they do, img tags show a broken image.' },
{ a: 'Not supported (disabled or omitted)', d: 'Renaming a title (shown read-only, with an explanation in Details), resending the verification email (the pending page explains how to start over), changing the password while logged in, editing the profile or email, listing or deleting individual images, multiple users.' }
]
return { files, routes, components, matrix, errors, pending }
}
}
</script>
</body>
</html>