@sempods/apps 0.8.0 → 0.9.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +166 -0
- package/README.md +13 -6
- package/bin/sempods-apps.mjs +1 -0
- package/instructions/app-workflow.md +145 -72
- package/instructions/publish.md +29 -11
- package/instructions/setup.md +2 -2
- package/instructions/visual-check.md +20 -2
- package/package.json +5 -3
- package/scripts/build-site.mjs +37 -9
- package/scripts/check-theme.mjs +398 -0
- package/scripts/check.mjs +16 -5
- package/scripts/icon.mjs +1 -1
- package/scripts/lib/apps.mjs +115 -27
- package/scripts/lib/generate.mjs +58 -28
- package/scripts/lib/json.mjs +6 -0
- package/scripts/lib/kit.mjs +81 -0
- package/scripts/lib/merge.mjs +12 -9
- package/scripts/lib/sdk.mjs +10 -2
- package/scripts/lib/themes.mjs +45 -19
- package/scripts/migrate.mjs +6 -1
- package/scripts/new-app.mjs +2 -7
- package/scripts/sdk-update.mjs +32 -10
- package/shared/files/{e13474647ebaf26d042d76f4917203d209a2b14c0d2af8d12139ea328530823e → 3b75b42e15713ac93916d9c2978c2826dfedef18ed5255d444482484bc4b3334} +8 -5
- package/shared/files/{843c078e84912158607ebb8ce0042589d676a742bcd3cf8b6c474d62ff3a6845 → 5f709dbc1d0ab5c918a1ff06c32342a3f95569b1570793bfd3a03642bb6a7e43} +4 -2
- package/shared/snapshot.json +3 -3
- package/skeleton/app/package.json +3 -0
- package/skeleton/app/src/App.tsx +53 -21
- package/skeleton/app/src/examples/ContextSwitch.tsx +131 -0
- package/skeleton/app/src/examples/ListScreen.tsx +154 -56
- package/skeleton/app/src/index.css +10 -110
- package/skeleton/app/src/main.tsx +1 -3
- package/skeleton/app/src/places.test.ts +128 -0
- package/skeleton/app/src/places.ts +146 -0
- package/skeleton/app/src/texts.ts +8 -0
- package/skeleton/app/src/themes/calm.css +22 -8
- package/skeleton/app/src/themes/fresh.css +22 -8
- package/skeleton/app/src/themes/playful.css +22 -8
- package/skeleton/app/src/themes/warm.css +22 -8
- package/skeleton/app/vite.config.ts +12 -2
- package/skills/update/SKILL.md +6 -1
- package/update-policy.json +1 -1
- package/skeleton/app/src/routes.test.ts +0 -19
- package/skeleton/app/src/routes.ts +0 -139
- package/skeleton/app/src/ui/Composer.tsx +0 -18
- package/skeleton/app/src/ui/ContextSwitch.tsx +0 -94
- package/skeleton/app/src/ui/Dialog.tsx +0 -120
- package/skeleton/app/src/ui/Menu.tsx +0 -118
- package/skeleton/app/src/ui/Tabs.tsx +0 -39
- package/skeleton/app/src/ui/dismiss.ts +0 -30
- package/skeleton/app/src/ui/icons.tsx +0 -53
- package/skeleton/app/src/ui/ui.css +0 -249
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { AddressModel } from '@sempods/app-ui';
|
|
2
|
+
import { app } from './sempods.generated.ts';
|
|
3
|
+
|
|
4
|
+
// The app's addresses as places: the start page at the app's path, a view per
|
|
5
|
+
// route in apps.json `routes` (for example /<id>/bought), an item per
|
|
6
|
+
// resource address (/<id>/items/<id>), the sign-in callback and an explicit
|
|
7
|
+
// page for anything else. `useNav(places)` in App.tsx moves between them:
|
|
8
|
+
// links, bookmarks and Back work, and an unsaved edit asks first. The address
|
|
9
|
+
// model is the app's own; change places and addresses here.
|
|
10
|
+
|
|
11
|
+
/** A route listed in apps.json. */
|
|
12
|
+
export type Route = (typeof app.routes)[number];
|
|
13
|
+
|
|
14
|
+
export type Place =
|
|
15
|
+
| { readonly kind: 'start' }
|
|
16
|
+
| { readonly kind: 'view'; readonly route: Route }
|
|
17
|
+
/**
|
|
18
|
+
* One item of src/examples/ListScreen.tsx, at `items/<id>`. `context` is
|
|
19
|
+
* the source Context the address names; the item is edited there, never
|
|
20
|
+
* in another one. Rename it with the list (for example `tasks/:id`).
|
|
21
|
+
*/
|
|
22
|
+
| {
|
|
23
|
+
readonly kind: 'item';
|
|
24
|
+
readonly subject: string;
|
|
25
|
+
readonly context?: string;
|
|
26
|
+
}
|
|
27
|
+
| { readonly kind: 'callback' }
|
|
28
|
+
| { readonly kind: 'not-found'; readonly reason: 'unknown' | 'invalid' };
|
|
29
|
+
|
|
30
|
+
/** The resource pattern of an item; apps.json `resources` declares it. */
|
|
31
|
+
export const ITEM = 'items/:id';
|
|
32
|
+
|
|
33
|
+
/** An absolute IRI, as the SDK accepts it: a scheme and a rest. */
|
|
34
|
+
const absolute = (value: string) => /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(value);
|
|
35
|
+
|
|
36
|
+
/** A subject IRI as one path segment: UTF-8 in base64url. */
|
|
37
|
+
export function encodeSubject(iri: string): string {
|
|
38
|
+
let binary = '';
|
|
39
|
+
for (const byte of new TextEncoder().encode(iri))
|
|
40
|
+
binary += String.fromCharCode(byte);
|
|
41
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The subject IRI of a path segment; null when it names none. */
|
|
45
|
+
export function decodeSubject(id: string): string | null {
|
|
46
|
+
if (!/^[\w-]+$/.test(id)) return null;
|
|
47
|
+
try {
|
|
48
|
+
const binary = atob(id.replace(/-/g, '+').replace(/_/g, '/'));
|
|
49
|
+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
50
|
+
const iri = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
51
|
+
return absolute(iri) ? iri : null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The address model over the app's path, routes and resource patterns. An
|
|
59
|
+
* address the app does not declare in apps.json is not found, so the parser,
|
|
60
|
+
* the host and the installed app always agree.
|
|
61
|
+
*/
|
|
62
|
+
export function addressModel(config: {
|
|
63
|
+
readonly basePath: string;
|
|
64
|
+
readonly routes: readonly string[];
|
|
65
|
+
readonly resources: readonly string[];
|
|
66
|
+
}): AddressModel<Place> {
|
|
67
|
+
const { basePath } = config;
|
|
68
|
+
const model: AddressModel<Place> = {
|
|
69
|
+
format(place) {
|
|
70
|
+
switch (place.kind) {
|
|
71
|
+
case 'start':
|
|
72
|
+
case 'not-found':
|
|
73
|
+
return basePath;
|
|
74
|
+
case 'view':
|
|
75
|
+
return basePath + place.route;
|
|
76
|
+
case 'callback':
|
|
77
|
+
return basePath + 'callback';
|
|
78
|
+
case 'item': {
|
|
79
|
+
const path = `${basePath}items/${encodeSubject(place.subject)}`;
|
|
80
|
+
return place.context
|
|
81
|
+
? `${path}?context=${encodeURIComponent(place.context)}`
|
|
82
|
+
: path;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
parse(pathname, search) {
|
|
87
|
+
const here = pathname + search;
|
|
88
|
+
// A known address in another form (a trailing slash) becomes its
|
|
89
|
+
// canonical one. Its query stays, so a deep link keeps it through
|
|
90
|
+
// sign-in; parameters the address sets itself come first.
|
|
91
|
+
const known = (place: Place) => {
|
|
92
|
+
const address = model.format(place);
|
|
93
|
+
const own = address.split('?')[1];
|
|
94
|
+
if (own === undefined) return { place, canonical: address + search };
|
|
95
|
+
const set = new URLSearchParams(own);
|
|
96
|
+
const rest = new URLSearchParams(
|
|
97
|
+
[...new URLSearchParams(search)].filter(([key]) => !set.has(key)),
|
|
98
|
+
).toString();
|
|
99
|
+
return { place, canonical: rest ? `${address}&${rest}` : address };
|
|
100
|
+
};
|
|
101
|
+
// An unknown or invalid address keeps itself, so its page shows the
|
|
102
|
+
// address the person followed.
|
|
103
|
+
const missing = (reason: 'unknown' | 'invalid') => ({
|
|
104
|
+
place: { kind: 'not-found', reason } as const,
|
|
105
|
+
canonical: here,
|
|
106
|
+
});
|
|
107
|
+
if (!(pathname + '/').startsWith(basePath)) return missing('unknown');
|
|
108
|
+
const path = pathname.slice(basePath.length).replace(/\/$/, '');
|
|
109
|
+
// The runtime reads the callback's parameters: it keeps its address.
|
|
110
|
+
if (path === 'callback')
|
|
111
|
+
return { place: { kind: 'callback' }, canonical: here };
|
|
112
|
+
if (path === '') return known({ kind: 'start' });
|
|
113
|
+
// Literal routes win over resource patterns sharing their prefix.
|
|
114
|
+
const route = config.routes.find((r) => r === path);
|
|
115
|
+
if (route !== undefined)
|
|
116
|
+
return known({ kind: 'view', route: route as Route });
|
|
117
|
+
const item = /^items\/([^/]+)$/.exec(path);
|
|
118
|
+
if (item?.[1] && config.resources.includes(ITEM)) {
|
|
119
|
+
const subject = decodeSubject(item[1]);
|
|
120
|
+
const context = new URLSearchParams(search).get('context');
|
|
121
|
+
if (!subject || (context !== null && !absolute(context)))
|
|
122
|
+
return missing('invalid');
|
|
123
|
+
return known({
|
|
124
|
+
kind: 'item',
|
|
125
|
+
subject,
|
|
126
|
+
...(context ? { context } : {}),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return missing('unknown');
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
return model;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The address a sign-in callback returns to, kept in the app. A callback
|
|
137
|
+
* without a matching sign-in, for example an expired one, returns to `/` when
|
|
138
|
+
* `returnTo` is a function, as the generated one is; that is the site's
|
|
139
|
+
* overview, so the app's start page takes its place.
|
|
140
|
+
*/
|
|
141
|
+
export function inApp(url: string): string {
|
|
142
|
+
return url.startsWith(app.basePath) ? url : app.basePath;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The app's address model; pass it to `useNav` once, in the frame. */
|
|
146
|
+
export const places = addressModel(app);
|
|
@@ -22,6 +22,10 @@ const en = {
|
|
|
22
22
|
newVersion:
|
|
23
23
|
'A new version is ready. It is used once every window of this app has been closed.',
|
|
24
24
|
menu: 'Menu',
|
|
25
|
+
notFound: 'Not found',
|
|
26
|
+
notFoundText: 'This address does not lead anywhere in this app.',
|
|
27
|
+
invalidLink: 'This link is not valid.',
|
|
28
|
+
toStart: 'To the start page',
|
|
25
29
|
};
|
|
26
30
|
|
|
27
31
|
export const texts: { readonly en: typeof en; readonly de: typeof en } = {
|
|
@@ -31,5 +35,9 @@ export const texts: { readonly en: typeof en; readonly de: typeof en } = {
|
|
|
31
35
|
newVersion:
|
|
32
36
|
'Eine neue Version ist bereit. Sie wird verwendet, sobald alle Fenster dieser App geschlossen sind.',
|
|
33
37
|
menu: 'Menü',
|
|
38
|
+
notFound: 'Nicht gefunden',
|
|
39
|
+
notFoundText: 'Diese Adresse führt nirgendwohin in dieser App.',
|
|
40
|
+
invalidLink: 'Dieser Link ist ungültig.',
|
|
41
|
+
toStart: 'Zur Startseite',
|
|
34
42
|
},
|
|
35
43
|
};
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
/* Calm: graphite on white, quiet and neutral; the default.
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
Palette roles of the theme contract of @sempods/app-ui, in light and
|
|
3
|
+
dark mode. They colour the app's components and, through the kit's bridge,
|
|
4
|
+
the SDK's. Text pairs keep 4.5:1, field boundaries and the focus ring
|
|
5
|
+
3:1; pnpm run check:theme checks every pair in both modes. */
|
|
4
6
|
:root {
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
7
|
+
--background: light-dark(#ffffff, #111315);
|
|
8
|
+
--foreground: light-dark(#1f2933, #e5e7eb);
|
|
9
|
+
--card: light-dark(#ffffff, #181b1f);
|
|
10
|
+
--card-foreground: var(--foreground);
|
|
11
|
+
--popover: light-dark(#ffffff, #1b1e23);
|
|
12
|
+
--popover-foreground: var(--foreground);
|
|
13
|
+
--primary: light-dark(#1f2933, #e5e7eb);
|
|
14
|
+
--primary-foreground: light-dark(#ffffff, #111315);
|
|
15
|
+
--secondary: light-dark(#eef0f3, #23272e);
|
|
16
|
+
--secondary-foreground: var(--foreground);
|
|
17
|
+
--muted: light-dark(#f3f4f6, #1d2026);
|
|
18
|
+
--muted-foreground: light-dark(#5f6672, #a1a7b0);
|
|
19
|
+
--accent: light-dark(#eef0f3, #262a31);
|
|
20
|
+
--accent-foreground: var(--foreground);
|
|
21
|
+
--destructive: light-dark(#b42318, #ffbcb5);
|
|
22
|
+
--border: light-dark(#e5e7eb, #2d3138);
|
|
23
|
+
--input: light-dark(#7a818c, #6b7280);
|
|
24
|
+
--ring: light-dark(#1f2933, #e5e7eb);
|
|
11
25
|
}
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
/* Fresh: clear teal and cool greys, light and open.
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
Palette roles of the theme contract of @sempods/app-ui, in light and
|
|
3
|
+
dark mode. They colour the app's components and, through the kit's bridge,
|
|
4
|
+
the SDK's. Text pairs keep 4.5:1, field boundaries and the focus ring
|
|
5
|
+
3:1; pnpm run check:theme checks every pair in both modes. */
|
|
4
6
|
:root {
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
7
|
+
--background: light-dark(#ffffff, #0e171d);
|
|
8
|
+
--foreground: light-dark(#11293a, #e1edf3);
|
|
9
|
+
--card: light-dark(#ffffff, #132029);
|
|
10
|
+
--card-foreground: var(--foreground);
|
|
11
|
+
--popover: light-dark(#ffffff, #16242e);
|
|
12
|
+
--popover-foreground: var(--foreground);
|
|
13
|
+
--primary: light-dark(#0a6680, #5ec6e4);
|
|
14
|
+
--primary-foreground: light-dark(#ffffff, #06212b);
|
|
15
|
+
--secondary: light-dark(#e4f1f6, #1a2d38);
|
|
16
|
+
--secondary-foreground: var(--foreground);
|
|
17
|
+
--muted: light-dark(#eff6f9, #15222b);
|
|
18
|
+
--muted-foreground: light-dark(#4f6070, #94a8b6);
|
|
19
|
+
--accent: light-dark(#e4f1f6, #1d313d);
|
|
20
|
+
--accent-foreground: var(--foreground);
|
|
21
|
+
--destructive: light-dark(#b42318, #ffbcb5);
|
|
22
|
+
--border: light-dark(#dce8ef, #22343f);
|
|
23
|
+
--input: light-dark(#6a7f8d, #5f7787);
|
|
24
|
+
--ring: light-dark(#0a6680, #5ec6e4);
|
|
11
25
|
}
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
/* Playful: violet and soft lilac, lively and friendly.
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
Palette roles of the theme contract of @sempods/app-ui, in light and
|
|
3
|
+
dark mode. They colour the app's components and, through the kit's bridge,
|
|
4
|
+
the SDK's. Text pairs keep 4.5:1, field boundaries and the focus ring
|
|
5
|
+
3:1; pnpm run check:theme checks every pair in both modes. */
|
|
4
6
|
:root {
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
7
|
+
--background: light-dark(#ffffff, #14111c);
|
|
8
|
+
--foreground: light-dark(#241a3a, #ece6f8);
|
|
9
|
+
--card: light-dark(#ffffff, #1b1725);
|
|
10
|
+
--card-foreground: var(--foreground);
|
|
11
|
+
--popover: light-dark(#ffffff, #1f1a2b);
|
|
12
|
+
--popover-foreground: var(--foreground);
|
|
13
|
+
--primary: light-dark(#6a3bd2, #b59cff);
|
|
14
|
+
--primary-foreground: light-dark(#ffffff, #1b1036);
|
|
15
|
+
--secondary: light-dark(#efeafb, #282138);
|
|
16
|
+
--secondary-foreground: var(--foreground);
|
|
17
|
+
--muted: light-dark(#f5f2fc, #1e1929);
|
|
18
|
+
--muted-foreground: light-dark(#5f5776, #a49cbc);
|
|
19
|
+
--accent: light-dark(#efeafb, #2c2540);
|
|
20
|
+
--accent-foreground: var(--foreground);
|
|
21
|
+
--destructive: light-dark(#b42318, #ffbcb5);
|
|
22
|
+
--border: light-dark(#e9e4f3, #2e2742);
|
|
23
|
+
--input: light-dark(#7d7596, #6f6789);
|
|
24
|
+
--ring: light-dark(#6a3bd2, #b59cff);
|
|
11
25
|
}
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
/* Warm: terracotta on cream, cosy and homely.
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
Palette roles of the theme contract of @sempods/app-ui, in light and
|
|
3
|
+
dark mode. They colour the app's components and, through the kit's bridge,
|
|
4
|
+
the SDK's. Text pairs keep 4.5:1, field boundaries and the focus ring
|
|
5
|
+
3:1; pnpm run check:theme checks every pair in both modes. */
|
|
4
6
|
:root {
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--
|
|
8
|
-
--
|
|
9
|
-
--
|
|
10
|
-
--
|
|
7
|
+
--background: light-dark(#fdfaf6, #1a1512);
|
|
8
|
+
--foreground: light-dark(#2b211b, #efe6dd);
|
|
9
|
+
--card: light-dark(#fffdfa, #211b17);
|
|
10
|
+
--card-foreground: var(--foreground);
|
|
11
|
+
--popover: light-dark(#fffdfa, #251e19);
|
|
12
|
+
--popover-foreground: var(--foreground);
|
|
13
|
+
--primary: light-dark(#9c4128, #f0a184);
|
|
14
|
+
--primary-foreground: light-dark(#ffffff, #2a120a);
|
|
15
|
+
--secondary: light-dark(#f4e9df, #30261f);
|
|
16
|
+
--secondary-foreground: var(--foreground);
|
|
17
|
+
--muted: light-dark(#f6efe7, #241d18);
|
|
18
|
+
--muted-foreground: light-dark(#6b5e53, #ab9d90);
|
|
19
|
+
--accent: light-dark(#f4e9df, #342a23);
|
|
20
|
+
--accent-foreground: var(--foreground);
|
|
21
|
+
--destructive: light-dark(#a8201a, #ffbcb5);
|
|
22
|
+
--border: light-dark(#ece2d8, #3a2f28);
|
|
23
|
+
--input: light-dark(#8a7b6e, #7a6b5f);
|
|
24
|
+
--ring: light-dark(#9c4128, #f0a184);
|
|
11
25
|
}
|
|
@@ -1,13 +1,23 @@
|
|
|
1
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
1
2
|
import react from '@vitejs/plugin-react';
|
|
2
3
|
import { defineConfig } from 'vite';
|
|
3
4
|
import { VitePWA } from 'vite-plugin-pwa';
|
|
4
5
|
import { base, pageTitle, pwa, server } from './vite.sempods.generated.ts';
|
|
5
6
|
|
|
6
7
|
// Base path, development port, page title and PWA settings come from apps.json
|
|
7
|
-
// through the generated file;
|
|
8
|
+
// through the generated file; Tailwind CSS styles the UI kit and the app. Add
|
|
9
|
+
// your own plugins and options here.
|
|
8
10
|
export default defineConfig({
|
|
9
11
|
base,
|
|
10
12
|
server,
|
|
11
13
|
preview: server,
|
|
12
|
-
|
|
14
|
+
// React, the SDK and the UI kit come to about 570 kB minified (180 kB
|
|
15
|
+
// gzip) in one chunk; Vite warns only well above that.
|
|
16
|
+
build: { chunkSizeWarningLimit: 1000 },
|
|
17
|
+
plugins: [
|
|
18
|
+
react(),
|
|
19
|
+
tailwindcss(),
|
|
20
|
+
pageTitle,
|
|
21
|
+
...(pwa ? [VitePWA(pwa)] : []),
|
|
22
|
+
],
|
|
13
23
|
});
|
package/skills/update/SKILL.md
CHANGED
|
@@ -34,7 +34,12 @@ instead.
|
|
|
34
34
|
`pnpm run update <version>`.
|
|
35
35
|
3. **Read the upgrade notes** for every version after the old one, including
|
|
36
36
|
skipped ones, in `node_modules/@sempods/apps/CHANGELOG.md`. They name what
|
|
37
|
-
the migration cannot do alone, such as changes the owner's apps need.
|
|
37
|
+
the migration cannot do alone, such as changes the owner's apps need. The
|
|
38
|
+
migration report's notes name app code that waits for a decision, for
|
|
39
|
+
example an app that still has the copied UI kit: which of its files are as
|
|
40
|
+
shipped and which the owner changed. Propose such app changes to the owner
|
|
41
|
+
separately from the tooling update; never overwrite a file the owner
|
|
42
|
+
changed.
|
|
38
43
|
4. **Decide the manual cases.** The migration applies only unambiguous
|
|
39
44
|
changes. It lists everything else in `.sempods-migration/migration.json`,
|
|
40
45
|
each with a reason, and leaves those files exactly as they are. Next to
|
package/update-policy.json
CHANGED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { pathOf, routeOf } from './routes.ts';
|
|
3
|
-
|
|
4
|
-
describe('routes', () => {
|
|
5
|
-
const routes = ['open', 'bought'] as const;
|
|
6
|
-
|
|
7
|
-
it('reads the route of an address under the app path', () => {
|
|
8
|
-
expect(routeOf('/konsum/bought', routes, '/konsum/')).toBe('bought');
|
|
9
|
-
expect(routeOf('/konsum/bought/', routes, '/konsum/')).toBe('bought');
|
|
10
|
-
expect(routeOf('/konsum/', routes, '/konsum/')).toBeUndefined();
|
|
11
|
-
expect(routeOf('/konsum/other', routes, '/konsum/')).toBeUndefined();
|
|
12
|
-
expect(routeOf('/notes/bought', routes, '/konsum/')).toBeUndefined();
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it('writes the address of a route', () => {
|
|
16
|
-
expect(pathOf('bought', '/konsum/')).toBe('/konsum/bought');
|
|
17
|
-
expect(pathOf(undefined, '/konsum/')).toBe('/konsum/');
|
|
18
|
-
});
|
|
19
|
-
});
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
useCallback,
|
|
3
|
-
useEffect,
|
|
4
|
-
useSyncExternalStore,
|
|
5
|
-
type MouseEvent,
|
|
6
|
-
} from 'react';
|
|
7
|
-
import { useApp } from '@sempods/app-sdk/react';
|
|
8
|
-
import { app } from './sempods.generated.ts';
|
|
9
|
-
|
|
10
|
-
// The app's views as addresses: the start page at the app's path and one
|
|
11
|
-
// route per entry in apps.json `routes` (for example /<id>/bought). Links,
|
|
12
|
-
// bookmarks and the back button work, and the published site answers every
|
|
13
|
-
// route without rewrites.
|
|
14
|
-
|
|
15
|
-
/** A route listed in apps.json; undefined is the start page. */
|
|
16
|
-
export type Route = (typeof app.routes)[number];
|
|
17
|
-
|
|
18
|
-
/** The route of an address; undefined for the start page and unknown paths. */
|
|
19
|
-
export function routeOf<R extends string>(
|
|
20
|
-
path: string,
|
|
21
|
-
routes: readonly R[],
|
|
22
|
-
base: string,
|
|
23
|
-
): R | undefined {
|
|
24
|
-
if (!path.startsWith(base)) return undefined;
|
|
25
|
-
const segment = path.slice(base.length).replace(/\/$/, '');
|
|
26
|
-
return routes.find((route) => route === segment);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** The address of a route; the start page is the app's path. */
|
|
30
|
-
export function pathOf(route: string | undefined, base: string): string {
|
|
31
|
-
return route === undefined ? base : `${base}${route}`;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// The address the app shows. It changes only through the functions below, so
|
|
35
|
-
// a declined navigation keeps the current view.
|
|
36
|
-
let shown = typeof location === 'object' ? location.pathname : '';
|
|
37
|
-
// The position of the shown entry in the session history, kept in its state,
|
|
38
|
-
// so a declined back or forward can return to it without a new entry.
|
|
39
|
-
let position: number =
|
|
40
|
-
typeof history === 'object' ? (history.state?.position ?? 0) : 0;
|
|
41
|
-
// Set while the router itself undoes a step, whose popstate it ignores.
|
|
42
|
-
let undoing = false;
|
|
43
|
-
const listeners = new Set<() => void>();
|
|
44
|
-
function show(path: string) {
|
|
45
|
-
shown = path;
|
|
46
|
-
for (const listener of listeners) listener();
|
|
47
|
-
}
|
|
48
|
-
function subscribe(listener: () => void) {
|
|
49
|
-
listeners.add(listener);
|
|
50
|
-
return () => void listeners.delete(listener);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Replaces the address without a new history entry. Pass it to the runtime
|
|
55
|
-
* (`replaceUrl`), so the return to the original route after a sign-in shows
|
|
56
|
-
* that route.
|
|
57
|
-
*/
|
|
58
|
-
export function replaceUrl(url: string) {
|
|
59
|
-
history.replaceState({ position }, '', url);
|
|
60
|
-
show(location.pathname);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* The current route, `go` to switch it and `link(route)`, the props of an
|
|
65
|
-
* `<a>` that switches to it. Switching, back and forward run
|
|
66
|
-
* under the SDK's leave policy: an unsaved edit asks first, and staying keeps
|
|
67
|
-
* the view, its address and the history. An unsent creation draft is not
|
|
68
|
-
* asked about; keep such input (a Composer) outside the switched views. Call it once, in the app's frame, and pass the
|
|
69
|
-
* route down.
|
|
70
|
-
*/
|
|
71
|
-
export function useRoute() {
|
|
72
|
-
const path = useSyncExternalStore(subscribe, () => shown);
|
|
73
|
-
const route: Route | undefined = routeOf(path, app.routes, app.basePath);
|
|
74
|
-
const { navigate } = useApp();
|
|
75
|
-
|
|
76
|
-
useEffect(() => {
|
|
77
|
-
const onPop = () => {
|
|
78
|
-
if (undoing) {
|
|
79
|
-
undoing = false;
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
82
|
-
const to = location.pathname;
|
|
83
|
-
const target: number = history.state?.position ?? 0;
|
|
84
|
-
void navigate(() => {
|
|
85
|
-
position = target;
|
|
86
|
-
show(to);
|
|
87
|
-
}).then((left) => {
|
|
88
|
-
// Staying: step back to the shown entry; the history stays intact.
|
|
89
|
-
if (left || target === position) return;
|
|
90
|
-
undoing = true;
|
|
91
|
-
history.go(position - target);
|
|
92
|
-
});
|
|
93
|
-
};
|
|
94
|
-
addEventListener('popstate', onPop);
|
|
95
|
-
return () => removeEventListener('popstate', onPop);
|
|
96
|
-
}, [navigate]);
|
|
97
|
-
|
|
98
|
-
// One address per view: unknown paths show the start page, and a trailing
|
|
99
|
-
// slash goes. The callback keeps its address until the runtime has read it.
|
|
100
|
-
useEffect(() => {
|
|
101
|
-
const normal = pathOf(route, app.basePath);
|
|
102
|
-
if (path !== normal && path !== `${app.basePath}callback`)
|
|
103
|
-
replaceUrl(normal + location.search + location.hash);
|
|
104
|
-
}, [path, route]);
|
|
105
|
-
|
|
106
|
-
const go = useCallback(
|
|
107
|
-
(next: Route | undefined) => {
|
|
108
|
-
if (next === route) return;
|
|
109
|
-
void navigate(() => {
|
|
110
|
-
position += 1;
|
|
111
|
-
history.pushState({ position }, '', pathOf(next, app.basePath));
|
|
112
|
-
show(location.pathname);
|
|
113
|
-
});
|
|
114
|
-
},
|
|
115
|
-
[navigate, route],
|
|
116
|
-
);
|
|
117
|
-
// Props for a link to a route: its own address, so it opens in a new tab or
|
|
118
|
-
// gets bookmarked, while a plain click switches in place through `go`
|
|
119
|
-
// instead of loading the page past the leave policy.
|
|
120
|
-
const link = useCallback(
|
|
121
|
-
(next: Route | undefined) => ({
|
|
122
|
-
href: pathOf(next, app.basePath),
|
|
123
|
-
onClick: (event: MouseEvent) => {
|
|
124
|
-
if (
|
|
125
|
-
event.button !== 0 ||
|
|
126
|
-
event.metaKey ||
|
|
127
|
-
event.ctrlKey ||
|
|
128
|
-
event.shiftKey ||
|
|
129
|
-
event.altKey
|
|
130
|
-
)
|
|
131
|
-
return;
|
|
132
|
-
event.preventDefault();
|
|
133
|
-
go(next);
|
|
134
|
-
},
|
|
135
|
-
}),
|
|
136
|
-
[go],
|
|
137
|
-
);
|
|
138
|
-
return { route, go, link };
|
|
139
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from 'react';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* A bar fixed at the bottom of the screen, in thumb reach, for the input that
|
|
5
|
-
* is always there (for example "add an item"). The page leaves room for it,
|
|
6
|
-
* it sits above the home indicator, and with the skeleton's viewport setting
|
|
7
|
-
* (interactive-widget=resizes-content) it stays above the on-screen keyboard
|
|
8
|
-
* in browsers that support that. iOS Safari overlays the keyboard instead.
|
|
9
|
-
* Render it in the frame, outside views that tabs switch: switching views
|
|
10
|
-
* does not ask about an unsent creation draft, and unmounting loses it.
|
|
11
|
-
*/
|
|
12
|
-
export function Composer({ children }: { readonly children: ReactNode }) {
|
|
13
|
-
return (
|
|
14
|
-
<div className="composer">
|
|
15
|
-
<div className="composer-content">{children}</div>
|
|
16
|
-
</div>
|
|
17
|
-
);
|
|
18
|
-
}
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from 'react';
|
|
2
|
-
import { useContexts, useSdkLocale } from '@sempods/app-sdk/react';
|
|
3
|
-
import { ChevronIcon } from './icons.tsx';
|
|
4
|
-
import { Menu } from './Menu.tsx';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The everyday switch for an app that works in one of several contexts at a
|
|
8
|
-
* time (for example lists): the app icon and the current context's name in
|
|
9
|
-
* place of the header's `<h1>`, opening a menu with the readable contexts,
|
|
10
|
-
* their access and a way to share more. The SDK's `useContexts` loads the
|
|
11
|
-
* contexts, selects a sensible one and switches under the leave policy; this
|
|
12
|
-
* component only shows them. Until a context is selected it shows the app's
|
|
13
|
-
* icon and `title` as the `<h1>`; then the `<h1>` names the app for screen
|
|
14
|
-
* readers only, outside the menu. Name things in the person's words: `label`
|
|
15
|
-
* "List", `shareMore` "Share more lists …".
|
|
16
|
-
*/
|
|
17
|
-
export function ContextSwitch({
|
|
18
|
-
icon,
|
|
19
|
-
title,
|
|
20
|
-
label,
|
|
21
|
-
texts,
|
|
22
|
-
}: {
|
|
23
|
-
readonly icon: ReactNode;
|
|
24
|
-
/** The app's name. */
|
|
25
|
-
readonly title: string;
|
|
26
|
-
/** What a context is in this app, for example "List". */
|
|
27
|
-
readonly label: string;
|
|
28
|
-
readonly texts: { readonly shareMore: string };
|
|
29
|
-
}) {
|
|
30
|
-
const {
|
|
31
|
-
contexts,
|
|
32
|
-
selected,
|
|
33
|
-
select,
|
|
34
|
-
failed,
|
|
35
|
-
retry,
|
|
36
|
-
canShare,
|
|
37
|
-
share,
|
|
38
|
-
shareProblem,
|
|
39
|
-
} = useContexts();
|
|
40
|
-
const { messages } = useSdkLocale();
|
|
41
|
-
if (!selected)
|
|
42
|
-
return (
|
|
43
|
-
<h1>
|
|
44
|
-
{icon}
|
|
45
|
-
{title}
|
|
46
|
-
</h1>
|
|
47
|
-
);
|
|
48
|
-
// A failed reload keeps the known contexts and offers to try again; a
|
|
49
|
-
// failed request to share says why below the switch.
|
|
50
|
-
const actions = [
|
|
51
|
-
...(failed
|
|
52
|
-
? [{ label: messages.controls.retry, onSelect: () => void retry() }]
|
|
53
|
-
: []),
|
|
54
|
-
...(canShare
|
|
55
|
-
? [{ label: texts.shareMore, onSelect: () => void share() }]
|
|
56
|
-
: []),
|
|
57
|
-
];
|
|
58
|
-
return (
|
|
59
|
-
<div className="context-switch-wrap">
|
|
60
|
-
<h1 className="visually-hidden">{title}</h1>
|
|
61
|
-
<Menu
|
|
62
|
-
className="context-switch"
|
|
63
|
-
align="start"
|
|
64
|
-
label={`${label}: ${selected.name}`}
|
|
65
|
-
trigger={
|
|
66
|
-
<>
|
|
67
|
-
{icon}
|
|
68
|
-
<span className="context-name">{selected.name}</span>
|
|
69
|
-
<ChevronIcon />
|
|
70
|
-
</>
|
|
71
|
-
}
|
|
72
|
-
items={[
|
|
73
|
-
...contexts.map((context) => ({
|
|
74
|
-
label: context.name,
|
|
75
|
-
// The SDK's own words, as in its management dialog.
|
|
76
|
-
hint: context.writable
|
|
77
|
-
? messages.controls.canChange
|
|
78
|
-
: messages.controls.canRead,
|
|
79
|
-
checked: context.iri === selected.iri,
|
|
80
|
-
onSelect: () => {
|
|
81
|
-
if (context.iri !== selected.iri) void select(context.iri);
|
|
82
|
-
},
|
|
83
|
-
})),
|
|
84
|
-
...(actions.length > 0 ? [null, ...actions] : []),
|
|
85
|
-
]}
|
|
86
|
-
/>
|
|
87
|
-
{shareProblem && (
|
|
88
|
-
<p role="alert" className="context-switch-problem">
|
|
89
|
-
{shareProblem}
|
|
90
|
-
</p>
|
|
91
|
-
)}
|
|
92
|
-
</div>
|
|
93
|
-
);
|
|
94
|
-
}
|