mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
phone app: installable, opens offline, takes the share sheet onto the scratchpad, back lets go of one thing
A manifest with a 192, a 512 and a maskable icon, and a hand-written service worker built beside the app by a small vite plugin: it keeps index.html, the logo, the entry with its imports and the latin fonts, keeps each hashed asset once fetched, asks the server for pages first and answers from the cache when it is down, and never touches /api. The CSP names worker-src and manifest-src. The share target posts to /share, which the worker catches: the text waits in a cache and becomes a scratchpad note once someone is logged in, with the drawer down on it. On a touch screen one guard entry in the history makes back let go of one thing at a time — a prompt, the line, a sheet, the drawer, the editor, a tile, then a folder up — and leave only at a space's root. The bar and the band grow by the safe-area insets. The login no longer says the server is unreachable once it answers again with the session gone.
This commit is contained in:
1 parent
9363a0024d
commit
a27d5de9c7
23 files changed
+377
-26
No files matched your search
+2
-1
@@ -2,12 +2,13 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<meta name="theme-color" content="#0c0d0b" />
|
||||
<title>~/workspace</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "workspace",
|
||||
"short_name": "workspace",
|
||||
"description": "A private workspace: documents, a plan, a scratchpad and a diary.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0c0d0b",
|
||||
"theme_color": "#0c0d0b",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"share_target": {
|
||||
"action": "/share",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": { "title": "title", "text": "text", "url": "url" }
|
||||
}
|
||||
}
|
||||
+31
-2
@@ -3,7 +3,7 @@
|
||||
import { openOverview } from './lib/actions'
|
||||
import { hooks } from './lib/commands'
|
||||
import { keysOf } from './lib/keymap'
|
||||
import { dispatch } from './lib/modes'
|
||||
import { back, dispatch } from './lib/modes'
|
||||
import { embedSnippet } from './lib/markdown'
|
||||
import Agenda from './lib/Agenda.svelte'
|
||||
import Calendar from './lib/Calendar.svelte'
|
||||
@@ -30,7 +30,7 @@
|
||||
import Sidebar from './lib/Sidebar.svelte'
|
||||
import StatusBar from './lib/StatusBar.svelte'
|
||||
import { workspace, type Sheet } from './lib/store.svelte'
|
||||
import { dwindle } from './lib/util'
|
||||
import { dwindle, isTouchDevice } from './lib/util'
|
||||
|
||||
/*
|
||||
* A share link is /s/<token>#<key>. The fragment never leaves the browser, so
|
||||
@@ -181,6 +181,35 @@
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
|
||||
/*
|
||||
* On a phone, back lets go of one thing at a time rather than leaving the
|
||||
* app. One entry is kept in the history over the page: back takes it off,
|
||||
* `back()` lets go of something, and it goes on again. With nothing left to
|
||||
* let go of it stays off, so the next back leaves. It is put on at a touch,
|
||||
* since a browser skips an entry made without one.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (!isTouchDevice || shareToken) return
|
||||
let guarded = false
|
||||
const guard = () => {
|
||||
if (guarded) return
|
||||
history.pushState({ guard: true }, '')
|
||||
guarded = true
|
||||
}
|
||||
const popstate = () => {
|
||||
guarded = false
|
||||
if (back()) guard()
|
||||
// The entry under the guard still has the address the page opened on.
|
||||
else workspace.tree.syncUrl(workspace.tree.path)
|
||||
}
|
||||
window.addEventListener('pointerdown', guard, true)
|
||||
window.addEventListener('popstate', popstate)
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', guard, true)
|
||||
window.removeEventListener('popstate', popstate)
|
||||
}
|
||||
})
|
||||
|
||||
// Guard against losing a half-written entry to a stray refresh.
|
||||
$effect(() => {
|
||||
if (!workspace.tiles.all.some((tile) => tile.dirty)) return
|
||||
|
||||
+11
-6
@@ -41,10 +41,15 @@
|
||||
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, monospace;
|
||||
|
||||
/* The frame: a bar on top, a rail on the right, a band at the bottom, and a
|
||||
rounded opening between them that the panes are seen through. */
|
||||
--bar-h: 40px;
|
||||
rounded opening between them that the panes are seen through. Installed
|
||||
on a phone the app reaches under the status bar and the gesture bar
|
||||
(`viewport-fit=cover`), so the bar and the band grow by what those cover
|
||||
and keep their contents clear of them; in a browser both insets are 0. */
|
||||
--inset-top: env(safe-area-inset-top, 0px);
|
||||
--inset-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--bar-h: calc(40px + var(--inset-top));
|
||||
--rail-w: 48px;
|
||||
--status-h: 28px;
|
||||
--status-h: calc(28px + var(--inset-bottom));
|
||||
--frame: 8px;
|
||||
--frame-radius: 18px;
|
||||
|
||||
@@ -69,7 +74,7 @@
|
||||
@media (max-width: 767px) {
|
||||
:root {
|
||||
--rail-w: var(--frame);
|
||||
--status-h: 40px;
|
||||
--status-h: calc(40px + var(--inset-bottom));
|
||||
--frame: 6px;
|
||||
--frame-radius: 14px;
|
||||
}
|
||||
@@ -333,7 +338,7 @@ input::placeholder { color: var(--muted); }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 10px);
|
||||
padding: var(--inset-top) calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 10px);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
@@ -402,7 +407,7 @@ input::placeholder { color: var(--muted); }
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 0 calc(var(--rail-w) + 4px) 0 calc(var(--frame) + 4px);
|
||||
padding: 0 calc(var(--rail-w) + 4px) var(--inset-bottom) calc(var(--frame) + 4px);
|
||||
font-size: 11px;
|
||||
color: var(--subtle);
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
.mobilebar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px calc(8px + env(safe-area-inset-bottom));
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--rim);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
* doors.
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.status { padding: 0 6px; gap: 6px; overflow: visible; }
|
||||
.status { padding: 0 6px var(--inset-bottom); gap: 6px; overflow: visible; }
|
||||
.hint, .file, .readout, .flag, .pending { display: none; }
|
||||
.mode:not(.editing) { display: none; }
|
||||
.tools { display: flex; flex: 1; height: 100%; }
|
||||
|
||||
@@ -105,7 +105,7 @@ function tileKey(direction: Direction, shift: boolean) {
|
||||
}
|
||||
|
||||
/** The drawer up, and the keys back where they were: in the editor, when a document was being written. */
|
||||
async function raise() {
|
||||
export async function raise() {
|
||||
await workspace.scratch.close()
|
||||
if (!workspace.scratch.open && workspace.editing && workspace.pane === 'document') hooks.focusEditor()
|
||||
}
|
||||
|
||||
+31
-1
@@ -16,7 +16,8 @@
|
||||
* The `[y/N]` prompt sits over all of it, in App.svelte, and the command
|
||||
* line keeps its own keys while it has them.
|
||||
*/
|
||||
import { actions, type Run } from './actions'
|
||||
import { actions, raise, type Run } from './actions'
|
||||
import { hooks, runCommand } from './commands'
|
||||
import { bindings, type Binding, type Context } from './keymap'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
@@ -150,3 +151,32 @@ export function claim(event: KeyboardEvent): boolean {
|
||||
event.stopPropagation()
|
||||
return true
|
||||
}
|
||||
|
||||
/*
|
||||
* The phone's back gesture, or its back button: one thing let go of, the way
|
||||
* Esc and q let go of it on a keyboard — a question, the line, a sheet, the
|
||||
* drawer, the editor, a tile — and then up a folder. False when there is
|
||||
* nothing left to leave, and the app itself may go.
|
||||
*/
|
||||
export function back(): boolean {
|
||||
if (hooks.closeCommandLine()) return true
|
||||
const step = leaving()
|
||||
if (!step) return false
|
||||
void step()
|
||||
return true
|
||||
}
|
||||
|
||||
function leaving(): (() => unknown) | null {
|
||||
const { prompts, sheets, scratch, drafts, tree } = workspace
|
||||
if (prompts.prompt) return () => prompts.prompt?.resolve(false)
|
||||
if (prompts.secret) return () => prompts.secret?.resolve(null)
|
||||
if (prompts.verify) return () => prompts.verify?.resolve(null)
|
||||
if (sheets.shown !== 'none') return () => sheets.close()
|
||||
if (scratch.open) return scratch.editing ? () => drafts.leaveWriting() : () => workspace.guard(raise)
|
||||
if (drafts.writing || workspace.editing) return () => runCommand('q')
|
||||
if (workspace.fullscreen) return () => (workspace.fullscreen = false)
|
||||
if (workspace.focus !== 'list' && workspace.focus !== 'node') return () => runCommand('close')
|
||||
if (workspace.open && workspace.pane === 'document') return () => runCommand('q')
|
||||
if (tree.path.length > 1) return () => workspace.guard(() => tree.up())
|
||||
return null
|
||||
}
|
||||
@@ -163,6 +163,28 @@ export class Scratchpad {
|
||||
this.ws.say('scratched — Q shows the scratchpad')
|
||||
}
|
||||
|
||||
/*
|
||||
* What the phone's share sheet sent while the app was shut, kept by the
|
||||
* service worker in the cache `share` (see sw.ts): each made a note, and
|
||||
* the drawer down on the newest. One is let go of only once it is a note,
|
||||
* so a share made with no network or no login waits for the next start.
|
||||
*/
|
||||
async takeShared() {
|
||||
if (typeof caches === 'undefined') return
|
||||
const cache = await caches.open('share')
|
||||
const kept = [...(await cache.keys())].sort((a, b) => a.url.localeCompare(b.url, undefined, { numeric: true }))
|
||||
if (!kept.length) return
|
||||
for (const request of kept) {
|
||||
const text = await (await cache.match(request))?.text()
|
||||
if (text?.trim()) await api.createScratch(text)
|
||||
await cache.delete(request)
|
||||
}
|
||||
if (this.open) this.notes = await api.scratch()
|
||||
else await this.toggle()
|
||||
this.cursor = 0
|
||||
this.ws.say(kept.length === 1 ? 'shared — on the scratchpad' : `${kept.length} shared — on the scratchpad`)
|
||||
}
|
||||
|
||||
/**
|
||||
* `:keep [title]`: the note under the cursor made a document in the folder
|
||||
* being browsed, and taken off the scratchpad. Without a title, the first
|
||||
|
||||
@@ -109,10 +109,10 @@ export class Tree {
|
||||
* outside that and shows matches from anywhere in the current space.
|
||||
*/
|
||||
|
||||
/** Keep the address bar on the node being looked at, so it can be linked to. */
|
||||
/** Keep the address bar on the node being looked at, so it can be linked to — the history entry, back's guard among them, kept as it is. */
|
||||
syncUrl(path: Crumb[]) {
|
||||
const slugs = path.map((crumb) => crumb.slug).join('/')
|
||||
if (slugs) history.replaceState(null, '', `/n/${slugs}`)
|
||||
if (slugs) history.replaceState(history.state, '', `/n/${slugs}`)
|
||||
}
|
||||
|
||||
/** Open whatever `work/einarbeiten/01-was-ist-eebus` names. */
|
||||
|
||||
@@ -140,12 +140,16 @@ export class Workspace {
|
||||
await this.cards.loadAgenda()
|
||||
await this.vault.load()
|
||||
await this.cards.openAgendaIfDue()
|
||||
void this.guard(() => this.scratch.takeShared())
|
||||
} catch (error) {
|
||||
this.user = null
|
||||
// Down is not the same as logged out: say so, and keep asking.
|
||||
if (unreachable(error)) {
|
||||
this.notice = 'the server cannot be reached — trying again'
|
||||
setTimeout(() => void this.boot(), 5000)
|
||||
} else if (this.notice.startsWith('the server cannot')) {
|
||||
// Back, but the session did not last the wait.
|
||||
this.notice = ''
|
||||
}
|
||||
} finally {
|
||||
this.booting = false
|
||||
@@ -170,6 +174,8 @@ export class Workspace {
|
||||
await this.cards.loadAgenda()
|
||||
await this.vault.load()
|
||||
await this.cards.openAgendaIfDue()
|
||||
// A share that came in while nobody was logged in has waited for this.
|
||||
void this.guard(() => this.scratch.takeShared())
|
||||
}
|
||||
|
||||
async logout(everywhere = false) {
|
||||
|
||||
@@ -5,3 +5,7 @@ import App from './App.svelte'
|
||||
import './app.css'
|
||||
|
||||
export default mount(App, { target: document.getElementById('app')! })
|
||||
|
||||
// The worker that lets the app be installed and opened offline; see sw.ts.
|
||||
// Not in development, where it would keep Vite's modules.
|
||||
if (import.meta.env.PROD && 'serviceWorker' in navigator) void navigator.serviceWorker.register('/sw.js')
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* The service worker: what makes the workspace an app on a phone.
|
||||
*
|
||||
* - The app shell — index.html, the logo, and what they need to paint — is
|
||||
* kept, so the app opens with no network and says the server cannot be
|
||||
* reached, rather than the browser's error page.
|
||||
* - Every hashed file under /assets is kept the first time it is fetched;
|
||||
* they never change, so a lazy chunk is fetched once.
|
||||
* - The share sheet posts to /share. The text is kept here, in the cache
|
||||
* `share`, and the page takes it onto the scratchpad once someone is logged
|
||||
* in (`Scratchpad.takeShared`). The post never reaches the server.
|
||||
*
|
||||
* Nothing from /api is touched or kept: the caches hold the public shell only,
|
||||
* so logging out leaves nothing private behind.
|
||||
*
|
||||
* The worker is built by `serviceWorker` in vite.config.ts, which writes in
|
||||
* the two names below. The DOM types have no worker events, so the few used
|
||||
* here are spelled out; a module, so they stay its own.
|
||||
*/
|
||||
|
||||
export {}
|
||||
|
||||
declare const __VERSION__: string
|
||||
declare const __PRECACHE__: string[]
|
||||
|
||||
interface ExtendableEvent extends Event {
|
||||
waitUntil(promise: Promise<unknown>): void
|
||||
}
|
||||
interface FetchEvent extends ExtendableEvent {
|
||||
readonly request: Request
|
||||
respondWith(response: Response | Promise<Response>): void
|
||||
}
|
||||
interface Scope {
|
||||
skipWaiting(): Promise<void>
|
||||
clients: { claim(): Promise<void> }
|
||||
addEventListener(type: 'install' | 'activate', listener: (event: ExtendableEvent) => void): void
|
||||
addEventListener(type: 'fetch', listener: (event: FetchEvent) => void): void
|
||||
}
|
||||
|
||||
const worker = self as unknown as Scope
|
||||
const SHELL = `shell-${__VERSION__}`
|
||||
|
||||
worker.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const cache = await caches.open(SHELL)
|
||||
await cache.addAll(['/index.html', '/favicon.svg', ...__PRECACHE__])
|
||||
await worker.skipWaiting()
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
worker.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
for (const name of await caches.keys()) {
|
||||
if (name.startsWith('shell-') && name !== SHELL) await caches.delete(name)
|
||||
}
|
||||
await worker.clients.claim()
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
worker.addEventListener('fetch', (event) => {
|
||||
const request = event.request
|
||||
const url = new URL(request.url)
|
||||
if (url.origin !== location.origin) return
|
||||
if (request.method === 'POST' && url.pathname === '/share') return event.respondWith(share(request))
|
||||
if (request.method !== 'GET') return
|
||||
if (url.pathname.startsWith('/assets/')) return event.respondWith(asset(request))
|
||||
if (request.mode === 'navigate') return event.respondWith(fresh(request, '/index.html'))
|
||||
if (url.pathname === '/favicon.svg') return event.respondWith(fresh(request, '/favicon.svg'))
|
||||
})
|
||||
|
||||
/** A hashed file: from the cache, or fetched once and kept. */
|
||||
async function asset(request: Request): Promise<Response> {
|
||||
const cache = await caches.open(SHELL)
|
||||
const kept = await cache.match(request)
|
||||
if (kept) return kept
|
||||
const response = await fetch(request)
|
||||
if (response.ok) await cache.put(request, response.clone())
|
||||
return response
|
||||
}
|
||||
|
||||
/*
|
||||
* A page — /, /n/…, /s/… — is index.html whatever the path, and the logo is
|
||||
* the logo. The server's is newer, so it is asked first; the kept one answers
|
||||
* when it cannot be reached, or a proxy in front of it says it is down.
|
||||
*/
|
||||
async function fresh(request: Request, kept: string): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(request)
|
||||
if (response.status < 502) return response
|
||||
return (await caches.match(kept)) ?? response
|
||||
} catch (error) {
|
||||
const copy = await caches.match(kept)
|
||||
if (copy) return copy
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The share sheet: a title, some text, a link — any of them may be missing,
|
||||
* and Android often puts the link in the text as well. They become one note,
|
||||
* the title on its first line.
|
||||
*/
|
||||
async function share(request: Request): Promise<Response> {
|
||||
const form = await request.formData()
|
||||
const field = (name: string) => String(form.get(name) ?? '').trim()
|
||||
const [title, text, link] = [field('title'), field('text'), field('url')]
|
||||
const lines = [title && title !== text ? title : '', text, link && !text.includes(link) ? link : ''].filter(Boolean)
|
||||
if (lines.length) {
|
||||
const cache = await caches.open('share')
|
||||
await cache.put(`/share/${Date.now()}`, new Response(lines.join('\n\n')))
|
||||
}
|
||||
return Response.redirect('/', 303)
|
||||
}
|
||||
+48
-2
@@ -1,6 +1,6 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import type { Plugin } from 'vite'
|
||||
import type { Plugin, Rollup } from 'vite'
|
||||
|
||||
/*
|
||||
* KaTeX lists each font as woff2, woff and ttf. Every browser the app runs in
|
||||
@@ -15,8 +15,54 @@ const katexWoff2Only: Plugin = {
|
||||
},
|
||||
}
|
||||
|
||||
/*
|
||||
* src/sw.ts, built beside the app as /sw.js — unhashed, since its address is
|
||||
* what the browser remembers. Two names are written into it:
|
||||
*
|
||||
* __PRECACHE__ what index.html needs to paint: the entry, what it imports,
|
||||
* their CSS, and the latin faces of the two fonts
|
||||
* __VERSION__ a hash of every file name in the build, so a build that
|
||||
* changes anything changes sw.js too, and the browser takes
|
||||
* the new worker and drops the old shell
|
||||
*/
|
||||
const serviceWorker: Plugin = {
|
||||
name: 'service-worker',
|
||||
apply: 'build',
|
||||
buildStart() {
|
||||
this.emitFile({ type: 'chunk', id: '/src/sw.ts', fileName: 'sw.js' })
|
||||
},
|
||||
generateBundle(_, bundle) {
|
||||
const chunks = Object.values(bundle).filter((file): file is Rollup.OutputChunk => file.type === 'chunk')
|
||||
const entry = chunks.find((chunk) => chunk.isEntry && chunk.facadeModuleId?.endsWith('index.html'))
|
||||
const sw = bundle['sw.js']
|
||||
if (!entry || sw?.type !== 'chunk') return this.error('service worker: no entry, or no sw.js')
|
||||
|
||||
const shell = new Set<string>()
|
||||
const walk = (name: string) => {
|
||||
if (shell.has(name)) return
|
||||
shell.add(name)
|
||||
const chunk = bundle[name]
|
||||
if (chunk?.type !== 'chunk') return
|
||||
chunk.viteMetadata?.importedCss.forEach((css) => shell.add(css))
|
||||
chunk.imports.forEach(walk)
|
||||
}
|
||||
walk(entry.fileName)
|
||||
for (const name of Object.keys(bundle)) {
|
||||
if (/\/(jetbrains-mono|noto-sans)-latin(-ext)?-wght-normal-[^/]+\.woff2$/.test(name)) shell.add(name)
|
||||
}
|
||||
|
||||
// FNV-1a: only a change has to show, so nothing stronger is needed.
|
||||
let hash = 0x811c9dc5
|
||||
for (const char of Object.keys(bundle).sort().join('\n')) hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193)
|
||||
const version = (hash >>> 0).toString(16)
|
||||
sw.code = sw.code
|
||||
.replaceAll('__VERSION__', JSON.stringify(version))
|
||||
.replaceAll('__PRECACHE__', JSON.stringify([...shell].map((name) => `/${name}`)))
|
||||
},
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte(), katexWoff2Only],
|
||||
plugins: [svelte(), katexWoff2Only, serviceWorker],
|
||||
build: { target: 'es2022', chunkSizeWarningLimit: 900 },
|
||||
server: {
|
||||
port: 4243,
|
||||
|
||||
Reference in new issue
Block a user