@meith/web 0.17.1 → 0.18.0

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/next.config.mjs CHANGED
@@ -4,36 +4,31 @@ import { fileURLToPath } from 'node:url'
4
4
 
5
5
  const here = path.dirname(fileURLToPath(import.meta.url))
6
6
 
7
- // Two directories up from this file's own location — correct whenever this
8
- // file sits at that fixed depth below the real workspace root: in place at
9
- // `apps/community` inside this monorepo, or materialized to `.meith/app`
10
- // inside a *board whose own directory sits two levels below its own
11
- // `node_modules`* (a create-meith scaffold with hoisted node_modules, per
12
- // docs/development.md, "Consuming the board from a workspace").
13
- //
14
- // `boards/stock` (docker/Dockerfile) is neither: it is a workspace member of
15
- // *this* monorepo's own pnpm install, which does not hoist, so its real
16
- // dependencies (including `next` itself) resolve through this repository's
17
- // root `node_modules`, two directories further up again — a fixed offset
18
- // this file cannot compute from its own path alone, because pnpm resolves
19
- // its dependencies through symlinks into a central store rather than by
20
- // nesting a workspace member two directories below everything it needs.
21
- // FORUM_WORKSPACE_ROOT lets the Dockerfile say so explicitly, without
22
- // changing the default for every other consumer this computation already
23
- // serves correctly.
7
+ /**
8
+ * Two directories up from this file's own location by default correct
9
+ * where `forum-web` materializes at `.meith/app` and where this file sits in
10
+ * `apps/community` unmaterialized. `boards/stock` and `forum-web --at-root`
11
+ * are both at other depths, and neither relies on this default:
12
+ * `FORUM_WORKSPACE_ROOT` is set for the first by that board's own scripts
13
+ * and passed on by `forum-web` in every case, so this fallback only applies
14
+ * when the file is read outside that bin entirely (see
15
+ * docs/architecture.md, "The stock board"; docs/development.md, "Consuming
16
+ * the board from a workspace").
17
+ */
24
18
  const workspaceRoot = process.env.FORUM_WORKSPACE_ROOT
25
19
  ? path.resolve(process.env.FORUM_WORKSPACE_ROOT)
26
20
  : path.join(here, '../../')
27
21
 
28
- // How many `../` this file's own directory is below workspaceRoot — 2 by
29
- // construction in the default case (see above), more when
30
- // FORUM_WORKSPACE_ROOT points further up. Used below instead of a literal
31
- // `'../../'` wherever a *relative* climb has to reach the same root, because
32
- // `outputFileTracingIncludes` (unlike `outputFileTracingRoot` and
33
- // `turbopack.root`) resolves its globs relative to this directory itself,
34
- // not against `workspaceRoot` — an absolute glob there silently matches
35
- // nothing useful once it is rejoined onto this directory downstream.
36
- const upToWorkspaceRoot = path.relative(here, workspaceRoot).split(path.sep).join('/')
22
+ /**
23
+ * The relative equivalent of `workspaceRoot`, for `outputFileTracingIncludes`
24
+ * below see docs/development.md, "Consuming the board from a workspace",
25
+ * for why that option needs a path relative to this file rather than an
26
+ * absolute one. It is `.` rather than the empty string when this file already
27
+ * sits at the workspace root, which is what `forum-web build --at-root`
28
+ * materializes (same section) — an empty prefix would make the glob below
29
+ * read as an absolute path and match nothing.
30
+ */
31
+ const upToWorkspaceRoot = path.relative(here, workspaceRoot).split(path.sep).join('/') || '.'
37
32
 
38
33
  const loadedEnvFiles = []
39
34
  for (const name of ['.env.local', '.env']) {
@@ -55,6 +50,7 @@ const nextConfig = {
55
50
  serverExternalPackages: [
56
51
  '@aws-sdk/client-s3',
57
52
  '@aws-sdk/s3-request-presigner',
53
+ '@vercel/blob',
58
54
  'postgres',
59
55
  '@jsquash/jpeg',
60
56
  '@jsquash/png',
@@ -62,49 +58,26 @@ const nextConfig = {
62
58
  'nodemailer',
63
59
  ],
64
60
  outputFileTracingRoot: workspaceRoot,
65
- // Turbopack infers a project root by walking up for a lockfile, and
66
- // otherwise stops at this app's own directory — which for a materialized
67
- // app (apps/community/bin/forum-web.mjs) is two directories below the
68
- // real one. Left unset, Turbopack's own Node.js transform pool (postcss,
69
- // for instance) cannot see the invoking workspace's node_modules at all,
70
- // and fails with "Cannot find module" for a dependency that plain Node
71
- // resolution finds without trouble.
61
+ /** See docs/development.md, "Consuming the board from a workspace", for why this is set. */
72
62
  turbopack: {
73
63
  root: workspaceRoot,
74
64
  },
75
- // The output-file tracer only follows the CJS half of @swc/helpers' dual
76
- // package and misses the `esm/` variant next's own require-hook resolves at
77
- // runtime, so the standalone build ships a `@swc/helpers` directory missing
78
- // its esm/ half. next resolves the package from its own nested pnpm store
79
- // entry (node_modules/.pnpm/next@…/node_modules/@swc/helpers), which is a
80
- // symlink into node_modules/.pnpm/@swc+helpers@0.5.23/node_modules/@swc/helpers
81
- // — so that is the path that has to be complete, not the app's own copy.
65
+ /** Works around a gap in Next's own tracing see docs/development.md, "Consuming the board from a workspace". */
82
66
  outputFileTracingIncludes: {
83
67
  '/**': [
84
68
  `${upToWorkspaceRoot}/node_modules/.pnpm/@swc+helpers@0.5.23/node_modules/@swc/helpers/**/*`,
69
+ `${upToWorkspaceRoot}/node_modules/@swc/helpers/**/*`,
85
70
  ],
86
71
  },
87
72
  images: {
88
73
  unoptimized: true,
89
74
  },
90
- // Every `@meith/*` package this app's dependency graph reaches — not just
91
- // the ones apps/community imports directly. All of them ship TypeScript
92
- // source with no build step (see docs/release.md, "They ship TypeScript
93
- // source, deliberately"), and inside this monorepo every one of them is
94
- // resolved through a tsconfig path alias straight to its source file,
95
- // bypassing node_modules — which is why this list used to be a small,
96
- // seemingly arbitrary subset: only the packages some other resolution path
97
- // happened to touch via node_modules ever needed it here.
98
- //
99
- // A materialized workspace's own generated tsconfig carries no such alias
100
- // map (see apps/community/bin/forum-web.mjs) — only `@board/config` and
101
- // `@board/plugins`, the seam itself. Every other `@meith/*` specifier
102
- // resolves the ordinary way once this package is npm-installed, which
103
- // means every one of them needs this same source-compilation treatment,
104
- // or the build fails with "Unknown module type" on its `src/index.ts`.
105
- // Computed by walking this app's own imports plus every `@meith/*`
106
- // package's own "dependencies" transitively — see docs/development.md,
107
- // "Consuming the board from a workspace".
75
+ /**
76
+ * Every `@meith/*` package this app's dependency graph reaches, not just the
77
+ * ones apps/community imports directly see docs/development.md,
78
+ * "Consuming the board from a workspace", and docs/release.md, "They ship
79
+ * TypeScript source, deliberately".
80
+ */
108
81
  transpilePackages: [
109
82
  '@meith/accounts',
110
83
  '@meith/admin',
@@ -152,12 +125,6 @@ const nextConfig = {
152
125
  '@meith/threads',
153
126
  '@meith/ui',
154
127
  '@meith/upgrade',
155
- // Only reached through the `@meith/web/config` subpath, and only from a
156
- // materialized workspace's own community.config.ts — inside this
157
- // monorepo apps/community never imports itself by package name. Real
158
- // once npm resolves this package into another workspace's node_modules,
159
- // so it needs the same source-compilation treatment as everything else
160
- // in this list.
161
128
  '@meith/web',
162
129
  ],
163
130
  async headers() {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
- "license": "LGPL-3.0-or-later",
5
+ "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/meith-dev/meith.git",
@@ -18,6 +18,7 @@
18
18
  "bin",
19
19
  "app",
20
20
  "src",
21
+ "public",
21
22
  "next.config.mjs",
22
23
  "postcss.config.mjs",
23
24
  "components.json",
@@ -42,52 +43,52 @@
42
43
  "typescript": "7.0.2",
43
44
  "@types/react": "^19",
44
45
  "@types/react-dom": "^19",
45
- "@meith/accounts": "0.17.1",
46
- "@meith/admin": "0.17.1",
47
- "@meith/antispam": "0.17.1",
48
- "@meith/api": "0.17.1",
49
- "@meith/attachments": "0.17.1",
50
- "@meith/authorization": "0.17.1",
51
- "@meith/avatars": "0.17.1",
52
- "@meith/core": "0.17.1",
53
- "@meith/db": "0.17.1",
54
- "@meith/demo": "0.17.1",
55
- "@meith/drafts": "0.17.1",
56
- "@meith/groups": "0.17.1",
57
- "@meith/events": "0.17.1",
58
- "@meith/drivers": "0.17.1",
59
- "@meith/forums": "0.17.1",
60
- "@meith/i18n": "0.17.1",
61
- "@meith/install": "0.17.1",
62
- "@meith/mail": "0.17.1",
63
- "@meith/markdown": "0.17.1",
64
- "@meith/import": "0.17.1",
65
- "@meith/messages": "0.17.1",
66
- "@meith/marketplace": "0.17.1",
67
- "@meith/notifications": "0.17.1",
68
- "@meith/moderation": "0.17.1",
69
- "@meith/plugin-dues": "0.17.1",
70
- "@meith/plugin-kit": "0.17.1",
71
- "@meith/posts": "0.17.1",
72
- "@meith/profile-fields": "0.17.1",
73
- "@meith/polls": "0.17.1",
74
- "@meith/relations": "0.17.1",
75
- "@meith/search": "0.17.1",
76
- "@meith/reputation": "0.17.1",
77
- "@meith/runtime": "0.17.1",
78
- "@meith/settings": "0.17.1",
79
- "@meith/signatures": "0.17.1",
80
- "@meith/subscriptions": "0.17.1",
81
- "@meith/tasks": "0.17.1",
82
- "@meith/theme-clubhouse": "0.17.1",
83
- "@meith/theme-kit": "0.17.1",
84
- "@meith/theme-default": "0.17.1",
85
- "@meith/theme-midnight": "0.17.1",
86
- "@meith/theme-phasebook": "0.17.1",
87
- "@meith/theme-raidframe": "0.17.1",
88
- "@meith/ui": "0.17.1",
89
- "@meith/threads": "0.17.1",
90
- "@meith/upgrade": "0.17.1"
46
+ "@meith/accounts": "0.18.0",
47
+ "@meith/antispam": "0.18.0",
48
+ "@meith/api": "0.18.0",
49
+ "@meith/authorization": "0.18.0",
50
+ "@meith/attachments": "0.18.0",
51
+ "@meith/avatars": "0.18.0",
52
+ "@meith/admin": "0.18.0",
53
+ "@meith/db": "0.18.0",
54
+ "@meith/demo": "0.18.0",
55
+ "@meith/drafts": "0.18.0",
56
+ "@meith/drivers": "0.18.0",
57
+ "@meith/core": "0.18.0",
58
+ "@meith/events": "0.18.0",
59
+ "@meith/forums": "0.18.0",
60
+ "@meith/groups": "0.18.0",
61
+ "@meith/install": "0.18.0",
62
+ "@meith/i18n": "0.18.0",
63
+ "@meith/mail": "0.18.0",
64
+ "@meith/import": "0.18.0",
65
+ "@meith/markdown": "0.18.0",
66
+ "@meith/marketplace": "0.18.0",
67
+ "@meith/messages": "0.18.0",
68
+ "@meith/moderation": "0.18.0",
69
+ "@meith/plugin-dues": "0.18.0",
70
+ "@meith/notifications": "0.18.0",
71
+ "@meith/plugin-kit": "0.18.0",
72
+ "@meith/relations": "0.18.0",
73
+ "@meith/posts": "0.18.0",
74
+ "@meith/reputation": "0.18.0",
75
+ "@meith/polls": "0.18.0",
76
+ "@meith/runtime": "0.18.0",
77
+ "@meith/profile-fields": "0.18.0",
78
+ "@meith/search": "0.18.0",
79
+ "@meith/settings": "0.18.0",
80
+ "@meith/subscriptions": "0.18.0",
81
+ "@meith/signatures": "0.18.0",
82
+ "@meith/tasks": "0.18.0",
83
+ "@meith/theme-clubhouse": "0.18.0",
84
+ "@meith/theme-kit": "0.18.0",
85
+ "@meith/theme-default": "0.18.0",
86
+ "@meith/theme-phasebook": "0.18.0",
87
+ "@meith/theme-midnight": "0.18.0",
88
+ "@meith/ui": "0.18.0",
89
+ "@meith/threads": "0.18.0",
90
+ "@meith/theme-raidframe": "0.18.0",
91
+ "@meith/upgrade": "0.18.0"
91
92
  },
92
93
  "scripts": {
93
94
  "dev": "next dev",
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
Binary file
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
package/public/sw.js ADDED
@@ -0,0 +1,81 @@
1
+ const FALLBACK_HREF = '/notifications'
2
+
3
+ function readPayload(event) {
4
+ if (!event.data) return null
5
+
6
+ try {
7
+ const payload = event.data.json()
8
+ if (payload === null || typeof payload !== 'object') return null
9
+ if (typeof payload.title !== 'string' || payload.title === '') return null
10
+ return payload
11
+ } catch (_error) {
12
+ return null
13
+ }
14
+ }
15
+
16
+ function fallbackUrl() {
17
+ return new URL(FALLBACK_HREF, self.location.origin).href
18
+ }
19
+
20
+ function targetUrl(href) {
21
+ const wanted = typeof href === 'string' && href !== '' ? href : FALLBACK_HREF
22
+
23
+ try {
24
+ const url = new URL(wanted, self.location.origin)
25
+ return url.origin === self.location.origin ? url.href : fallbackUrl()
26
+ } catch (_error) {
27
+ return fallbackUrl()
28
+ }
29
+ }
30
+
31
+ self.addEventListener('install', () => {
32
+ self.skipWaiting()
33
+ })
34
+
35
+ self.addEventListener('activate', (event) => {
36
+ event.waitUntil(self.clients.claim())
37
+ })
38
+
39
+ self.addEventListener('push', (event) => {
40
+ const payload = readPayload(event)
41
+ if (payload === null) return
42
+
43
+ const options = {
44
+ body: typeof payload.body === 'string' ? payload.body : '',
45
+ tag: typeof payload.id === 'number' ? `meith-${payload.id}` : 'meith',
46
+ data: { href: targetUrl(payload.href) },
47
+ icon: '/apple-icon.png',
48
+ badge: '/icon-light-32x32.png',
49
+ timestamp: Date.now(),
50
+ }
51
+
52
+ if (typeof payload.badge === 'number' && 'setAppBadge' in navigator) {
53
+ navigator.setAppBadge(payload.badge).catch(() => {})
54
+ }
55
+
56
+ event.waitUntil(self.registration.showNotification(payload.title, options))
57
+ })
58
+
59
+ self.addEventListener('notificationclick', (event) => {
60
+ event.notification.close()
61
+
62
+ const href = targetUrl(event.notification.data && event.notification.data.href)
63
+
64
+ event.waitUntil(
65
+ self.clients
66
+ .matchAll({ type: 'window', includeUncontrolled: true })
67
+ .then((clients) => {
68
+ for (const client of clients) {
69
+ if (client.url === href && 'focus' in client) return client.focus()
70
+ }
71
+
72
+ const open = clients[0]
73
+ if (open !== undefined && 'navigate' in open) {
74
+ return open.focus().then(() => open.navigate(href))
75
+ }
76
+
77
+ return self.clients.openWindow(href)
78
+ })
79
+ .catch(() => self.clients.openWindow(href)),
80
+ )
81
+ })
@@ -62,16 +62,16 @@ export async function saveAdminSettingsAction(
62
62
  if (result.changed.length > 0) {
63
63
  const tags = [CacheTags.settings(), ...result.invalidates]
64
64
  await drivers().cache.invalidateTags(tags)
65
- for (const tag of tags) await emitEvent('cache.invalidated', { tag }, {})
66
-
67
65
  revalidatePath('/admin/settings')
68
66
 
69
- await emitEvent('settings.saved', { keys: result.changed }, { adminId: admin.session.userId })
70
-
71
- await recordAdminAction({
72
- action: 'settings.changed',
73
- detail: { keys: result.changed },
74
- })
67
+ await Promise.all([
68
+ ...tags.map((tag) => emitEvent('cache.invalidated', { tag }, {})),
69
+ emitEvent('settings.saved', { keys: result.changed }, { adminId: admin.session.userId }),
70
+ recordAdminAction({
71
+ action: 'settings.changed',
72
+ detail: { keys: result.changed },
73
+ }),
74
+ ])
75
75
  }
76
76
 
77
77
  return { notice: result.changed.length === 0 ? 'unchanged' : 'saved' }
@@ -77,14 +77,10 @@ export function requirePromotionRules(): PostgresPromotionRepository {
77
77
  return repository
78
78
  }
79
79
 
80
- export async function previewPromotions(limit = 500): Promise<PromotionRunResult | null> {
80
+ export async function previewPromotions(): Promise<PromotionRunResult | null> {
81
81
  if (getContainer().dataSource !== 'postgres') return null
82
82
 
83
- const service = new PromotionService({
84
- promotions: new PostgresPromotionRepository(getDb()),
85
- guards: defaultPromotionGuards(),
86
- })
87
- return service.preview(limit)
83
+ return promotionService().preview()
88
84
  }
89
85
 
90
86
  export function promotionService(): PromotionService {
@@ -15,6 +15,7 @@ import {
15
15
  getDb,
16
16
  isInstalled,
17
17
  markInstalled,
18
+ migrationUrl,
18
19
  PostgresAdminRepository,
19
20
  PostgresForumRepository,
20
21
  PostgresSettingsRepository,
@@ -126,7 +127,7 @@ async function forgetCachedBoard(): Promise<void> {
126
127
  export type InstallRun = { readonly sealed: true } | { readonly report: readonly StepOutcome[] }
127
128
 
128
129
  export async function runInstall(input: InstallInput): Promise<InstallRun> {
129
- const url = env.DATABASE_URL ?? ''
130
+ const url = migrationUrl(env)
130
131
 
131
132
  try {
132
133
  const run = await withInstallLock(url, async (): Promise<InstallRun> => {
@@ -6,7 +6,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
6
6
 
7
7
  import { activeDefinitions } from './plugin-host'
8
8
 
9
- export const CODE_VERSION = '0.17.1'
9
+ export const CODE_VERSION = '0.18.0'
10
10
 
11
11
  export async function pendingUpgradeNotice(): Promise<string | null> {
12
12
  if (env.DATA_SOURCE !== 'postgres') return null
@@ -179,26 +179,26 @@ export async function banMemberAction(_prev: FormState, form: FormData): Promise
179
179
  ...(expiresAt === undefined ? {} : { expiresAt }),
180
180
  })
181
181
 
182
- await emitEvent(
183
- 'user.banned',
184
- { userId: id, expiresAt: expiresAt?.toISOString() ?? null },
185
- { moderatorId: context.session.userId, reason: trimmedText(form, 'reason') || null },
186
- )
187
-
188
182
  refreshMemberScreens()
189
- await recordAdminAction({
190
- action: 'user.banned',
191
- detail: { userId: id, days: days === '' ? null : Number(days) },
192
- })
193
183
 
194
- return {
195
- notice: 'banned',
196
- undo: await issueAdminUndo({
184
+ const [, undo] = await Promise.all([
185
+ emitEvent(
186
+ 'user.banned',
187
+ { userId: id, expiresAt: expiresAt?.toISOString() ?? null },
188
+ { moderatorId: context.session.userId, reason: trimmedText(form, 'reason') || null },
189
+ ),
190
+ issueAdminUndo({
197
191
  actorUserId: context.session.userId,
198
192
  operation: 'user.ban',
199
193
  snapshot: { userId: id },
200
194
  }),
201
- }
195
+ recordAdminAction({
196
+ action: 'user.banned',
197
+ detail: { userId: id, days: days === '' ? null : Number(days) },
198
+ }),
199
+ ])
200
+
201
+ return { notice: 'banned', undo }
202
202
  } catch (err) {
203
203
  return toFormState(err)
204
204
  }
@@ -556,14 +556,16 @@ export async function liftBanAction(_prev: FormState, form: FormData): Promise<F
556
556
 
557
557
  await banService().lift(id)
558
558
 
559
- await emitEvent(
560
- 'user.unbanned',
561
- { userId: id, expired: false },
562
- { moderatorId: context.session.userId, reason: null },
563
- )
564
-
565
559
  refreshMemberScreens()
566
- await recordAdminAction({ action: 'user.ban_lifted', detail: { userId: id } })
560
+
561
+ await Promise.all([
562
+ emitEvent(
563
+ 'user.unbanned',
564
+ { userId: id, expired: false },
565
+ { moderatorId: context.session.userId, reason: null },
566
+ ),
567
+ recordAdminAction({ action: 'user.ban_lifted', detail: { userId: id } }),
568
+ ])
567
569
 
568
570
  return { notice: 'lifted' }
569
571
  } catch (err) {
@@ -118,13 +118,14 @@ export async function buildMemberView(userId: number): Promise<MemberView | null
118
118
 
119
119
  const prefix = member.lastIpPrefix ?? member.registrationIpPrefix ?? ''
120
120
 
121
- return {
122
- member,
123
- secondaryGroupIds: await repository.readSecondaryGroups(userId),
124
- groups: await repository.listGroups(),
125
- activeBan: await bans.findActive(userId),
126
- sharedNetwork: await repository.sharingIpPrefix(prefix, userId),
127
- }
121
+ const [secondaryGroupIds, groups, activeBan, sharedNetwork] = await Promise.all([
122
+ repository.readSecondaryGroups(userId),
123
+ repository.listGroups(),
124
+ bans.findActive(userId),
125
+ repository.sharingIpPrefix(prefix, userId),
126
+ ])
127
+
128
+ return { member, secondaryGroupIds, groups, activeBan, sharedNetwork }
128
129
  }
129
130
 
130
131
  export function requireUserBulk(): PostgresUserBulkRepository {