Split browser workflows from presentation
This commit is contained in:
parent
c9642f4067
commit
0c2d992224
25 changed files with 1683 additions and 854 deletions
1009
web/src/App.svelte
1009
web/src/App.svelte
File diff suppressed because it is too large
Load diff
342
web/src/app.css
Normal file
342
web/src/app.css
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 20rem;
|
||||
background: #f4f0e7;
|
||||
color: #1e2a25;
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 4.5rem;
|
||||
padding: 0 4vw;
|
||||
border-bottom: 1px solid #d9d4c8;
|
||||
background: rgb(255 253 247 / 88%);
|
||||
}
|
||||
|
||||
.brand,
|
||||
.account,
|
||||
.toolbar,
|
||||
.roots,
|
||||
.error {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: inherit;
|
||||
font-weight: 750;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mark {
|
||||
display: grid;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
place-items: center;
|
||||
border-radius: 0.55rem 0.55rem 0.55rem 0.1rem;
|
||||
background: #bd4b30;
|
||||
color: white;
|
||||
font-family: Georgia, serif;
|
||||
}
|
||||
|
||||
main {
|
||||
width: min(72rem, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 3.5rem 0 6rem;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.resume-delete,
|
||||
.dropzone,
|
||||
.toolbar,
|
||||
.list article {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: end;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
color: #bd4b30;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
font-size: clamp(2.8rem, 7vw, 5.8rem);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.055em;
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.hero p:last-child {
|
||||
max-width: 38rem;
|
||||
color: #68716c;
|
||||
}
|
||||
|
||||
.roots {
|
||||
padding: 0.3rem;
|
||||
border: 1px solid #d6d0c4;
|
||||
border-radius: 999px;
|
||||
background: #ebe6dc;
|
||||
}
|
||||
|
||||
.roots button {
|
||||
padding: 0.6rem 1rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.roots button.active {
|
||||
background: white;
|
||||
box-shadow: 0 0.15rem 0.6rem rgb(30 42 37 / 12%);
|
||||
}
|
||||
|
||||
.roots button.shared.active {
|
||||
background: #713c30;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.shared-warning,
|
||||
.resume-delete,
|
||||
.notice,
|
||||
.error,
|
||||
.partial {
|
||||
margin: 1.5rem 0;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 0.7rem;
|
||||
}
|
||||
|
||||
.shared-warning {
|
||||
border: 1px solid #d9a78d;
|
||||
background: #fff0e7;
|
||||
}
|
||||
|
||||
.resume-delete {
|
||||
border: 1px solid #d2b665;
|
||||
background: #fff4cd;
|
||||
}
|
||||
|
||||
.notice {
|
||||
background: #e3efe6;
|
||||
color: #245c35;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8dfdc;
|
||||
color: #852d25;
|
||||
}
|
||||
|
||||
.partial {
|
||||
background: #e4ecf4;
|
||||
color: #2c526d;
|
||||
}
|
||||
|
||||
.breadcrumbs {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
min-height: 3rem;
|
||||
margin-top: 1.5rem;
|
||||
color: #7b817d;
|
||||
}
|
||||
|
||||
.breadcrumbs button,
|
||||
.quiet {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #4c6258;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
.dropzone {
|
||||
min-height: 8rem;
|
||||
padding: 1.5rem;
|
||||
border: 2px dashed #b8b2a6;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 253 247 / 70%);
|
||||
}
|
||||
|
||||
.dropzone.dragging {
|
||||
border-color: #bd4b30;
|
||||
background: #fff0e7;
|
||||
}
|
||||
|
||||
.dropzone div,
|
||||
.dropzone label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.dropzone span,
|
||||
.list small,
|
||||
.empty span {
|
||||
color: #68716c;
|
||||
}
|
||||
|
||||
.dropzone label {
|
||||
padding: 0.7rem 1rem;
|
||||
border-radius: 0.7rem;
|
||||
background: #1e2a25;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropzone input {
|
||||
width: 0.1px;
|
||||
height: 0.1px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.browser {
|
||||
margin-top: 1.5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid #d9d4c8;
|
||||
border-radius: 1rem;
|
||||
background: #fffdf7;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e1ddd3;
|
||||
}
|
||||
|
||||
.toolbar form {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.toolbar form > div {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
min-width: min(22rem, 45vw);
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid #b8b2a6;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar button,
|
||||
.load-more,
|
||||
.resume-delete button,
|
||||
.error button,
|
||||
.delete {
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid #b8b2a6;
|
||||
border-radius: 0.5rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.list article {
|
||||
border-bottom: 1px solid #eeeae1;
|
||||
}
|
||||
|
||||
.entry {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.entry > span:last-child {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
place-items: center;
|
||||
border-radius: 0.6rem;
|
||||
background: #e4ecf4;
|
||||
color: #2c526d;
|
||||
}
|
||||
|
||||
.icon.folder {
|
||||
background: #f2e7c7;
|
||||
color: #765f1d;
|
||||
}
|
||||
|
||||
.delete {
|
||||
margin-right: 1rem;
|
||||
color: #852d25;
|
||||
}
|
||||
|
||||
.delete.danger {
|
||||
background: #852d25;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
place-items: center;
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: block;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 44rem) {
|
||||
.hero,
|
||||
.toolbar,
|
||||
.dropzone,
|
||||
.resume-delete {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.roots {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.toolbar input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
21
web/src/components/AppHeader.svelte
Normal file
21
web/src/components/AppHeader.svelte
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import type { SessionResponse } from '../generated/contract.ts';
|
||||
|
||||
type Props = {
|
||||
session: SessionResponse | null;
|
||||
onlogout: () => Promise<void>;
|
||||
};
|
||||
|
||||
let { session, onlogout }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<a class="brand" href="/app" aria-label="Nuthatch home">
|
||||
<span class="mark">N</span>
|
||||
<span>Nuthatch</span>
|
||||
</a>
|
||||
<div class="account">
|
||||
<span>{session?.display ?? session?.user_id ?? 'Loading session…'}</span>
|
||||
<button class="quiet" type="button" onclick={() => void onlogout()}>Log out</button>
|
||||
</div>
|
||||
</header>
|
||||
22
web/src/components/Breadcrumbs.svelte
Normal file
22
web/src/components/Breadcrumbs.svelte
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import type { Breadcrumb } from '../presentation.ts';
|
||||
|
||||
type Props = {
|
||||
root: string;
|
||||
items: Breadcrumb[];
|
||||
busy: boolean;
|
||||
onopen: (path: string) => Promise<void>;
|
||||
};
|
||||
|
||||
let { root, items, busy, onopen }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="breadcrumbs" aria-label="Current folder">
|
||||
<button type="button" onclick={() => void onopen('')} disabled={busy}>{root}</button>
|
||||
{#each items as crumb}
|
||||
<span>/</span>
|
||||
<button type="button" onclick={() => void onopen(crumb.path)} disabled={busy}>
|
||||
{crumb.name}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
46
web/src/components/Feedback.svelte
Normal file
46
web/src/components/Feedback.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import type { MutationFollowup } from '../mutations.ts';
|
||||
|
||||
type Props = {
|
||||
notice: string;
|
||||
error: string;
|
||||
uploadPending: boolean;
|
||||
uploadFollowup: MutationFollowup;
|
||||
deletePending: boolean;
|
||||
deleteFollowup: MutationFollowup;
|
||||
onuploadretry: () => Promise<void>;
|
||||
ondeleteretry: () => Promise<void>;
|
||||
};
|
||||
|
||||
let {
|
||||
notice,
|
||||
error,
|
||||
uploadPending,
|
||||
uploadFollowup,
|
||||
deletePending,
|
||||
deleteFollowup,
|
||||
onuploadretry,
|
||||
ondeleteretry
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if notice !== ''}
|
||||
<p class="notice" role="status">{notice}</p>
|
||||
{/if}
|
||||
{#if error !== ''}
|
||||
<div class="error" role="alert">
|
||||
<span>{error}</span>
|
||||
{#if uploadPending}
|
||||
<button type="button" onclick={() => void onuploadretry()}>
|
||||
{uploadFollowup === 'restart' ? 'Restart complete upload' : 'Retry same upload'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if deletePending}
|
||||
<button type="button" onclick={() => void ondeleteretry()}>
|
||||
{deleteFollowup === 'restart'
|
||||
? 'Start new delete operation'
|
||||
: 'Retry same delete'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
121
web/src/components/ObjectBrowser.svelte
Normal file
121
web/src/components/ObjectBrowser.svelte
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
<script lang="ts">
|
||||
import type { EntryResponse, Root } from '../generated/contract.ts';
|
||||
import { formatSize } from '../presentation.ts';
|
||||
|
||||
type Props = {
|
||||
root: Root;
|
||||
entries: EntryResponse[];
|
||||
continuation: string | null;
|
||||
partial: boolean;
|
||||
busy: boolean;
|
||||
query: string;
|
||||
onquery: (query: string) => void;
|
||||
onbrowse: (append: boolean) => Promise<void>;
|
||||
onopen: (entry: EntryResponse) => Promise<void>;
|
||||
ondelete: (entry: EntryResponse) => Promise<void>;
|
||||
};
|
||||
|
||||
let {
|
||||
root,
|
||||
entries,
|
||||
continuation,
|
||||
partial,
|
||||
busy,
|
||||
query,
|
||||
onquery,
|
||||
onbrowse,
|
||||
onopen,
|
||||
ondelete
|
||||
}: Props = $props();
|
||||
|
||||
function submitSearch(event: SubmitEvent): void {
|
||||
event.preventDefault();
|
||||
void onbrowse(false);
|
||||
}
|
||||
|
||||
function changeQuery(event: Event): void {
|
||||
onquery((event.currentTarget as HTMLInputElement).value);
|
||||
}
|
||||
|
||||
function clearSearch(): void {
|
||||
onquery('');
|
||||
void onbrowse(false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="browser">
|
||||
<div class="toolbar">
|
||||
<form onsubmit={submitSearch}>
|
||||
<label for="search">Search this root</label>
|
||||
<div>
|
||||
<input
|
||||
id="search"
|
||||
value={query}
|
||||
oninput={changeQuery}
|
||||
placeholder="Case-insensitive substring"
|
||||
disabled={busy}
|
||||
/>
|
||||
<button type="submit" disabled={busy}>Search</button>
|
||||
{#if query !== ''}
|
||||
<button class="quiet" type="button" disabled={busy} onclick={clearSearch}>
|
||||
Clear
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
<button
|
||||
class="quiet"
|
||||
type="button"
|
||||
onclick={() => void onbrowse(false)}
|
||||
disabled={busy}>Refresh</button
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if partial}
|
||||
<p class="partial" role="status">
|
||||
Partial search results — the configured key or time bound was reached.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="list" aria-busy={busy}>
|
||||
{#each entries as entry}
|
||||
<article>
|
||||
<button
|
||||
class="entry"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={() => void onopen(entry)}
|
||||
>
|
||||
<span class:folder={entry.is_prefix} class="icon">
|
||||
{entry.is_prefix ? '↳' : '↓'}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{entry.name}{entry.is_prefix ? '/' : ''}</strong>
|
||||
<small>{entry.is_prefix ? 'Folder' : formatSize(entry.size)}</small>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class:danger={root === 'shared'}
|
||||
class="delete"
|
||||
type="button"
|
||||
onclick={() => void ondelete(entry)}
|
||||
disabled={busy}>Delete</button
|
||||
>
|
||||
</article>
|
||||
{:else}
|
||||
<div class="empty">
|
||||
<strong>{busy ? 'Loading…' : 'Nothing here yet'}</strong>
|
||||
<span>{query === '' ? 'Drop a file above to begin.' : 'Try another search.'}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if continuation !== null && query === ''}
|
||||
<button
|
||||
class="load-more"
|
||||
type="button"
|
||||
onclick={() => void onbrowse(true)}
|
||||
disabled={busy}>Load more</button
|
||||
>
|
||||
{/if}
|
||||
</section>
|
||||
23
web/src/components/ResumeDelete.svelte
Normal file
23
web/src/components/ResumeDelete.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type { PendingDelete } from '../mutations.ts';
|
||||
|
||||
type Props = {
|
||||
pending: PendingDelete | null;
|
||||
busy: boolean;
|
||||
onresume: () => Promise<void>;
|
||||
};
|
||||
|
||||
let { pending, busy, onresume }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if pending !== null}
|
||||
<aside class="resume-delete">
|
||||
<span>
|
||||
Recursive deletion of <strong>{pending.prefix}</strong> has unfinished per-key
|
||||
operations.
|
||||
</span>
|
||||
<button type="button" onclick={() => void onresume()} disabled={busy}>
|
||||
Resume deletion
|
||||
</button>
|
||||
</aside>
|
||||
{/if}
|
||||
46
web/src/components/UploadPanel.svelte
Normal file
46
web/src/components/UploadPanel.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
type Props = {
|
||||
busy: boolean;
|
||||
onfile: (file: File) => Promise<void>;
|
||||
};
|
||||
|
||||
let { busy, onfile }: Props = $props();
|
||||
let dragging = $state(false);
|
||||
|
||||
function chooseFile(event: Event): void {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (file !== undefined) void onfile(file);
|
||||
}
|
||||
|
||||
function dropFile(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
dragging = false;
|
||||
const file = event.dataTransfer?.files[0];
|
||||
if (file !== undefined) void onfile(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
role="group"
|
||||
aria-label="Upload file"
|
||||
class:dragging
|
||||
class="dropzone"
|
||||
ondragenter={(event) => {
|
||||
event.preventDefault();
|
||||
dragging = true;
|
||||
}}
|
||||
ondragover={(event) => event.preventDefault()}
|
||||
ondragleave={() => (dragging = false)}
|
||||
ondrop={dropFile}
|
||||
>
|
||||
<div>
|
||||
<strong>Drop a file here</strong>
|
||||
<span>or choose one to upload into this folder</span>
|
||||
</div>
|
||||
<label>
|
||||
Choose file
|
||||
<input type="file" onchange={chooseFile} disabled={busy} />
|
||||
</label>
|
||||
</section>
|
||||
47
web/src/components/WorkspaceHeader.svelte
Normal file
47
web/src/components/WorkspaceHeader.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<script lang="ts">
|
||||
import type { Root } from '../generated/contract.ts';
|
||||
import { rootHeading } from '../presentation.ts';
|
||||
|
||||
type Props = {
|
||||
root: Root;
|
||||
busy: boolean;
|
||||
onselect: (root: Root) => Promise<void>;
|
||||
};
|
||||
|
||||
let { root, busy, onselect }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="hero">
|
||||
<div>
|
||||
<p class="eyebrow">Object workspace</p>
|
||||
<h1>{rootHeading(root)}</h1>
|
||||
<p>
|
||||
{root === 'personal'
|
||||
? 'Private to your verified identity.'
|
||||
: 'Visible and mutable by every authenticated user.'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="roots" aria-label="Storage root">
|
||||
<button
|
||||
class:active={root === 'personal'}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={() => void onselect('personal')}>Personal</button
|
||||
>
|
||||
<button
|
||||
class:active={root === 'shared'}
|
||||
class:shared={root === 'shared'}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onclick={() => void onselect('shared')}>Shared</button
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if root === 'shared'}
|
||||
<aside class="shared-warning">
|
||||
<strong>Shared root</strong>
|
||||
Deletions and replacements affect everyone. Nuthatch asks for confirmation before
|
||||
destructive actions.
|
||||
</aside>
|
||||
{/if}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { mount } from 'svelte';
|
||||
import App from './App.svelte';
|
||||
import './app.css';
|
||||
|
||||
const target = document.getElementById('app');
|
||||
|
||||
|
|
|
|||
40
web/src/presentation.ts
Normal file
40
web/src/presentation.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import type { Root } from './generated/contract.ts';
|
||||
|
||||
export type Breadcrumb = {
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export function breadcrumbs(prefix: string): Breadcrumb[] {
|
||||
if (prefix === '') return [];
|
||||
return prefix.split('/').map((name, index, segments) => ({
|
||||
name,
|
||||
path: segments.slice(0, index + 1).join('/')
|
||||
}));
|
||||
}
|
||||
|
||||
export function parentPrefix(prefix: string): string {
|
||||
return prefix.split('/').slice(0, -1).join('/');
|
||||
}
|
||||
|
||||
export function uploadPath(prefix: string, fileName: string): string {
|
||||
return prefix === '' ? fileName : `${prefix}/${fileName}`;
|
||||
}
|
||||
|
||||
export function errorMessage(caught: unknown): string {
|
||||
return caught instanceof Error ? caught.message : String(caught);
|
||||
}
|
||||
|
||||
export function formatSize(size: number | null): string {
|
||||
if (size === null) return '—';
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'unit',
|
||||
unit: 'byte',
|
||||
notation: 'compact',
|
||||
unitDisplay: 'narrow'
|
||||
}).format(size);
|
||||
}
|
||||
|
||||
export function rootHeading(root: Root): string {
|
||||
return root === 'personal' ? 'Your files' : 'Shared files';
|
||||
}
|
||||
|
|
@ -145,6 +145,24 @@ export class BrowserProductClient implements ProductEndpoint {
|
|||
);
|
||||
}
|
||||
|
||||
async logout(csrfToken: string): Promise<void> {
|
||||
const response = await this.fetchRequest(
|
||||
this.baseUrl === '' ? '/logout' : new URL('/logout', this.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRF-Token': csrfToken }
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new ProductRequestError({
|
||||
kind: 'unexpected-http',
|
||||
status: response.status,
|
||||
message: response.statusText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async jsonRequest<Success, Code extends string>(
|
||||
contract: EndpointContract,
|
||||
request: object,
|
||||
|
|
|
|||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
67
web/src/workflows/browse.ts
Normal file
67
web/src/workflows/browse.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import type {
|
||||
BrowseResponse,
|
||||
EntryResponse,
|
||||
ProductEndpoint,
|
||||
Root
|
||||
} from '../generated/contract.ts';
|
||||
import { requireValue } from '../transport.ts';
|
||||
|
||||
export type BrowseState = {
|
||||
root: Root;
|
||||
prefix: string;
|
||||
query: string;
|
||||
entries: EntryResponse[];
|
||||
continuation: string | null;
|
||||
partial: boolean;
|
||||
};
|
||||
|
||||
export function initialBrowseState(): BrowseState {
|
||||
return {
|
||||
root: 'personal',
|
||||
prefix: '',
|
||||
query: '',
|
||||
entries: [],
|
||||
continuation: null,
|
||||
partial: false
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadBrowse(
|
||||
client: ProductEndpoint,
|
||||
state: BrowseState,
|
||||
append: boolean
|
||||
): Promise<BrowseResponse> {
|
||||
return requireValue(
|
||||
await client.browse({
|
||||
root: state.root,
|
||||
...(state.prefix === '' ? {} : { prefix: state.prefix }),
|
||||
...(state.query === '' ? {} : { q: state.query }),
|
||||
...(append && state.continuation !== null
|
||||
? { continuation: state.continuation }
|
||||
: {})
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function applyBrowsePage(
|
||||
state: BrowseState,
|
||||
page: BrowseResponse,
|
||||
append: boolean
|
||||
): void {
|
||||
state.entries = append ? [...state.entries, ...page.entries] : page.entries;
|
||||
state.continuation = page.continuation;
|
||||
state.partial = page.partial;
|
||||
}
|
||||
|
||||
export function selectRoot(state: BrowseState, root: Root): void {
|
||||
state.root = root;
|
||||
state.prefix = '';
|
||||
state.query = '';
|
||||
state.continuation = null;
|
||||
}
|
||||
|
||||
export function openPrefix(state: BrowseState, prefix: string): void {
|
||||
state.prefix = prefix;
|
||||
state.query = '';
|
||||
state.continuation = null;
|
||||
}
|
||||
52
web/src/workflows/recursive-delete-persistence.ts
Normal file
52
web/src/workflows/recursive-delete-persistence.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { DeleteBatchItem, Root } from '../generated/contract.ts';
|
||||
import {
|
||||
decodePendingDelete,
|
||||
encodePendingDelete,
|
||||
readPendingDelete,
|
||||
type PendingDelete
|
||||
} from '../mutations.ts';
|
||||
|
||||
const recursiveDeleteStorageKey = 'nuthatch-recursive-delete';
|
||||
|
||||
export type RecursiveDeletePersistence = {
|
||||
read(): PendingDelete | null;
|
||||
load(root: Root, prefix: string): DeleteBatchItem[];
|
||||
save(root: Root, prefix: string, items: DeleteBatchItem[]): void;
|
||||
clear(): void;
|
||||
};
|
||||
|
||||
export function browserRecursiveDeletePersistence(
|
||||
storage: Storage
|
||||
): RecursiveDeletePersistence {
|
||||
return {
|
||||
read() {
|
||||
const encoded = storage.getItem(recursiveDeleteStorageKey);
|
||||
const saved = readPendingDelete(encoded);
|
||||
if (encoded !== null && saved === null) {
|
||||
storage.removeItem(recursiveDeleteStorageKey);
|
||||
}
|
||||
return saved;
|
||||
},
|
||||
load(root, prefix) {
|
||||
const encoded = storage.getItem(recursiveDeleteStorageKey);
|
||||
const items = decodePendingDelete(encoded, root, prefix);
|
||||
if (encoded !== null && items.length === 0) {
|
||||
storage.removeItem(recursiveDeleteStorageKey);
|
||||
}
|
||||
return items;
|
||||
},
|
||||
save(root, prefix, items) {
|
||||
storage.setItem(
|
||||
recursiveDeleteStorageKey,
|
||||
encodePendingDelete(root, prefix, items)
|
||||
);
|
||||
},
|
||||
clear() {
|
||||
storage.removeItem(recursiveDeleteStorageKey);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultRecursiveDeletePersistence(): RecursiveDeletePersistence {
|
||||
return browserRecursiveDeletePersistence(globalThis.localStorage);
|
||||
}
|
||||
89
web/src/workflows/recursive-delete.ts
Normal file
89
web/src/workflows/recursive-delete.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type {
|
||||
DeletePlanResponse,
|
||||
ProductEndpoint,
|
||||
Root,
|
||||
SessionResponse
|
||||
} from '../generated/contract.ts';
|
||||
import { unfinishedDeleteItems, type PendingDelete } from '../mutations.ts';
|
||||
import { requireValue } from '../transport.ts';
|
||||
import type { RecursiveDeletePersistence } from './recursive-delete-persistence.ts';
|
||||
|
||||
export type RecursiveDeleteResult = {
|
||||
deleted: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
export class RecursiveDeletePageLimitError extends Error {
|
||||
readonly pages: number;
|
||||
|
||||
constructor(pages: number) {
|
||||
super(`Recursive delete stopped after ${pages} pages; run it again to continue.`);
|
||||
this.name = 'RecursiveDeletePageLimitError';
|
||||
this.pages = pages;
|
||||
}
|
||||
}
|
||||
|
||||
export function savedRecursiveDelete(
|
||||
persistence: RecursiveDeletePersistence
|
||||
): PendingDelete | null {
|
||||
return persistence.read();
|
||||
}
|
||||
|
||||
export async function runRecursiveDelete(
|
||||
client: ProductEndpoint,
|
||||
persistence: RecursiveDeletePersistence,
|
||||
session: SessionResponse,
|
||||
root: Root,
|
||||
prefix: string,
|
||||
nextOperationId: () => string,
|
||||
pageLimit = 100
|
||||
): Promise<RecursiveDeleteResult> {
|
||||
let deleted = 0;
|
||||
let completedPages = 0;
|
||||
let continuation: string | null = null;
|
||||
for (let page = 0; page < pageLimit; page += 1) {
|
||||
let items = persistence.load(root, prefix);
|
||||
let nextContinuation: string | null = null;
|
||||
if (items.length === 0) {
|
||||
const plan: DeletePlanResponse = requireValue(
|
||||
await client.deletePlan({
|
||||
root,
|
||||
path: prefix,
|
||||
...(continuation === null ? {} : { continuation })
|
||||
})
|
||||
);
|
||||
if (plan.paths.length === 0) {
|
||||
if (plan.continuation !== null) {
|
||||
continuation = plan.continuation;
|
||||
continue;
|
||||
}
|
||||
persistence.clear();
|
||||
return { deleted, pages: completedPages };
|
||||
}
|
||||
nextContinuation = plan.continuation;
|
||||
items = plan.paths.map((path) => ({
|
||||
path,
|
||||
operation_id: nextOperationId()
|
||||
}));
|
||||
persistence.save(root, prefix, items);
|
||||
}
|
||||
|
||||
const response = requireValue(
|
||||
await client.deleteBatch({
|
||||
body: { root, csrf: session.csrf, items }
|
||||
})
|
||||
);
|
||||
const unfinished = unfinishedDeleteItems(response.results, nextOperationId);
|
||||
deleted += response.results.length - unfinished.length;
|
||||
if (unfinished.length > 0) {
|
||||
persistence.save(root, prefix, unfinished);
|
||||
throw new Error(
|
||||
`${unfinished.length} objects remain. Pending operations keep their IDs; terminal failures restart with new IDs.`
|
||||
);
|
||||
}
|
||||
persistence.clear();
|
||||
completedPages += 1;
|
||||
continuation = nextContinuation;
|
||||
}
|
||||
throw new RecursiveDeletePageLimitError(pageLimit);
|
||||
}
|
||||
73
web/src/workflows/single-delete.ts
Normal file
73
web/src/workflows/single-delete.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import type {
|
||||
ProductEndpoint,
|
||||
Root,
|
||||
SessionResponse
|
||||
} from '../generated/contract.ts';
|
||||
import {
|
||||
captureOperation,
|
||||
mutationFollowup,
|
||||
type CapturedOperation,
|
||||
type MutationFollowup
|
||||
} from '../mutations.ts';
|
||||
import { requireValue } from '../transport.ts';
|
||||
|
||||
export type SingleDeleteState = {
|
||||
pending: CapturedOperation | null;
|
||||
followup: MutationFollowup;
|
||||
};
|
||||
|
||||
export function initialSingleDeleteState(): SingleDeleteState {
|
||||
return { pending: null, followup: 'replay' };
|
||||
}
|
||||
|
||||
export function captureSingleDelete(
|
||||
state: SingleDeleteState,
|
||||
root: Root,
|
||||
path: string,
|
||||
operationId: string
|
||||
): void {
|
||||
state.pending = captureOperation(root, path, operationId);
|
||||
state.followup = 'replay';
|
||||
}
|
||||
|
||||
export function prepareSingleDeleteRetry(
|
||||
state: SingleDeleteState,
|
||||
operationId: string
|
||||
): CapturedOperation | null {
|
||||
if (state.pending === null) return null;
|
||||
if (state.followup === 'restart') {
|
||||
state.pending.operationId = operationId;
|
||||
state.followup = 'replay';
|
||||
}
|
||||
return state.pending;
|
||||
}
|
||||
|
||||
export async function executeSingleDelete(
|
||||
client: ProductEndpoint,
|
||||
session: SessionResponse,
|
||||
state: SingleDeleteState
|
||||
): Promise<string> {
|
||||
const pending = state.pending;
|
||||
if (pending === null) throw new Error('No delete is pending.');
|
||||
const response = requireValue(
|
||||
await client.delete({
|
||||
body: {
|
||||
root: pending.root,
|
||||
path: pending.path,
|
||||
operation_id: pending.operationId,
|
||||
csrf: session.csrf
|
||||
}
|
||||
})
|
||||
);
|
||||
state.followup = mutationFollowup(response.result);
|
||||
if (state.followup !== 'complete') {
|
||||
throw new Error(
|
||||
state.followup === 'replay'
|
||||
? 'Delete is pending. Retry checks the same operation.'
|
||||
: `Delete is ${response.result}. Start a new delete operation.`
|
||||
);
|
||||
}
|
||||
const path = pending.path;
|
||||
state.pending = null;
|
||||
return path;
|
||||
}
|
||||
6
web/src/workflows/upload-identity.ts
Normal file
6
web/src/workflows/upload-identity.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export async function uploadContentSha256(file: File): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
|
||||
return Array.from(new Uint8Array(digest), (byte) =>
|
||||
byte.toString(16).padStart(2, '0')
|
||||
).join('');
|
||||
}
|
||||
84
web/src/workflows/upload.ts
Normal file
84
web/src/workflows/upload.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type {
|
||||
ProductEndpoint,
|
||||
Root,
|
||||
SessionResponse
|
||||
} from '../generated/contract.ts';
|
||||
import {
|
||||
captureOperation,
|
||||
mutationFollowup,
|
||||
type CapturedOperation,
|
||||
type MutationFollowup
|
||||
} from '../mutations.ts';
|
||||
import { requireValue } from '../transport.ts';
|
||||
|
||||
export type PendingUpload = CapturedOperation & {
|
||||
file: File;
|
||||
};
|
||||
|
||||
export type UploadState = {
|
||||
pending: PendingUpload | null;
|
||||
followup: MutationFollowup;
|
||||
};
|
||||
|
||||
export function initialUploadState(): UploadState {
|
||||
return { pending: null, followup: 'replay' };
|
||||
}
|
||||
|
||||
export function captureUpload(
|
||||
state: UploadState,
|
||||
root: Root,
|
||||
path: string,
|
||||
file: File,
|
||||
operationId: string
|
||||
): void {
|
||||
state.pending = {
|
||||
file,
|
||||
...captureOperation(root, path, operationId)
|
||||
};
|
||||
state.followup = 'replay';
|
||||
}
|
||||
|
||||
export function prepareUploadRetry(
|
||||
state: UploadState,
|
||||
operationId: string
|
||||
): PendingUpload | null {
|
||||
if (state.pending === null) return null;
|
||||
if (state.followup === 'restart') {
|
||||
state.pending.operationId = operationId;
|
||||
state.followup = 'replay';
|
||||
}
|
||||
return state.pending;
|
||||
}
|
||||
|
||||
export async function executeUpload(
|
||||
client: ProductEndpoint,
|
||||
session: SessionResponse,
|
||||
state: UploadState,
|
||||
digest: (file: File) => Promise<string>
|
||||
): Promise<string> {
|
||||
const pending = state.pending;
|
||||
if (pending === null) throw new Error('No upload is pending.');
|
||||
const contentSha256 = await digest(pending.file);
|
||||
const response = requireValue(
|
||||
await client.upload({
|
||||
root: pending.root,
|
||||
path: pending.path,
|
||||
operationId: pending.operationId,
|
||||
declaredSize: pending.file.size,
|
||||
contentSha256,
|
||||
csrfToken: session.csrf,
|
||||
body: pending.file
|
||||
})
|
||||
);
|
||||
state.followup = mutationFollowup(response.result);
|
||||
if (state.followup !== 'complete') {
|
||||
throw new Error(
|
||||
state.followup === 'replay'
|
||||
? 'Upload is pending. Retry checks the same operation.'
|
||||
: `Upload is ${response.result}. Start a complete replacement upload.`
|
||||
);
|
||||
}
|
||||
const path = pending.path;
|
||||
state.pending = null;
|
||||
return path;
|
||||
}
|
||||
37
web/test/browser-boundaries.test.mjs
Normal file
37
web/test/browser-boundaries.test.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
test('Svelte components own neither HTTP nor browser persistence policy', async () => {
|
||||
const sources = await sourceFiles('src');
|
||||
const svelte = sources.filter((file) => file.endsWith('.svelte'));
|
||||
for (const file of svelte) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
assert.doesNotMatch(source, /\bfetch\s*\(/, file);
|
||||
assert.doesNotMatch(source, /\blocalStorage\b/, file);
|
||||
assert.doesNotMatch(source, /\bon:/, file);
|
||||
assert.doesNotMatch(source, /\$:/, file);
|
||||
}
|
||||
|
||||
const storageOwners = [];
|
||||
for (const file of sources) {
|
||||
const source = await readFile(file, 'utf8');
|
||||
if (source.includes('nuthatch-recursive-delete')) storageOwners.push(file);
|
||||
}
|
||||
assert.deepEqual(storageOwners, [
|
||||
path.resolve('src/workflows/recursive-delete-persistence.ts')
|
||||
]);
|
||||
});
|
||||
|
||||
async function sourceFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (
|
||||
await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.resolve(directory, entry.name);
|
||||
return entry.isDirectory() ? sourceFiles(target) : [target];
|
||||
})
|
||||
)
|
||||
).flat();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue