@meith/web 0.17.1 → 0.17.2

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.
@@ -1,5 +1,7 @@
1
1
  import type { NextRequest } from 'next/server'
2
2
 
3
+ import { readCappedBody } from '@meith/marketplace'
4
+
3
5
  import { requireAdmin } from '@/server/admin'
4
6
  import { marketplaceScreenshotUrl } from '@/server/marketplace-admin'
5
7
 
@@ -46,13 +48,14 @@ export async function GET(request: NextRequest): Promise<Response> {
46
48
  const upstream = await fetch(url, {
47
49
  signal: controller.signal,
48
50
  headers: { accept: 'image/png' },
51
+ redirect: 'manual',
49
52
  })
50
53
  if (!upstream.ok) return fail(502, 'The catalog host did not answer with the screenshot.')
51
54
 
52
- const bytes = await upstream.arrayBuffer()
53
- if (bytes.byteLength > MAX_BYTES) return fail(502, 'The screenshot was larger than expected.')
55
+ const bytes = await readCappedBody(upstream, MAX_BYTES)
56
+ if (bytes === null) return fail(502, 'The screenshot was larger than expected.')
54
57
 
55
- return new Response(bytes, {
58
+ return new Response(bytes.buffer as ArrayBuffer, {
56
59
  headers: {
57
60
  'Content-Type': 'image/png',
58
61
  'X-Content-Type-Options': 'nosniff',
@@ -30,9 +30,12 @@ export default async function AdminForumPage({ params }: { params: Promise<{ id:
30
30
  const options = await repository?.readOptions(forum.id)
31
31
  if (options === null || options === undefined || repository === null) notFound()
32
32
 
33
- const t = await getTranslator()
34
- const moderators = await repository.listModerators(forum.id)
35
- const groups = (await repository.listGroups()).map((group) => ({
33
+ const [t, moderators, groupRows] = await Promise.all([
34
+ getTranslator(),
35
+ repository.listModerators(forum.id),
36
+ repository.listGroups(),
37
+ ])
38
+ const groups = groupRows.map((group) => ({
36
39
  groupId: group.id,
37
40
  title: group.title,
38
41
  }))
@@ -31,9 +31,9 @@ export default async function AdminSettingsPage({
31
31
  if (query.group === undefined && query.q === undefined) {
32
32
  redirect(settingsHref({ group: DEFAULT_SETTING_GROUP, advanced: query.advanced === '1' }))
33
33
  }
34
- const t = await getTranslator()
34
+ const [t, snapshot] = await Promise.all([getTranslator(), getSettings()])
35
35
  const model = buildAdminSettingsModel({
36
- snapshot: await getSettings(),
36
+ snapshot,
37
37
  query: query.q,
38
38
  group: query.group,
39
39
  advanced: query.advanced === '1',
@@ -36,9 +36,8 @@ export default async function AdminSystemPage() {
36
36
  if ((await adminPageContext()) === null) return null
37
37
 
38
38
  const now = new Date()
39
- const translator = await getTranslator()
39
+ const [translator, view] = await Promise.all([getTranslator(), buildSystemHealthView(now)])
40
40
  const copy = systemFormsCopy(translator)
41
- const view = await buildSystemHealthView(now)
42
41
 
43
42
  if (view === null) {
44
43
  return (
@@ -24,10 +24,13 @@ export async function generateMetadata(): Promise<Metadata> {
24
24
  export default async function AdminThemesPage() {
25
25
  if ((await adminPageContext()) === null) return null
26
26
 
27
- const themes = await themeListing()
28
- const [lightKey, darkKey] = await Promise.all([logoKey('light'), logoKey('dark')])
27
+ const [themes, lightKey, darkKey, translator] = await Promise.all([
28
+ themeListing(),
29
+ logoKey('light'),
30
+ logoKey('dark'),
31
+ getTranslator(),
32
+ ])
29
33
  const now = new Date()
30
- const translator = await getTranslator()
31
34
  const brandingCopy = brandingFormsCopy(translator)
32
35
  const stateCopy = themeStateCopy(translator)
33
36
 
package/bin/forum-web.mjs CHANGED
@@ -65,24 +65,20 @@ const packageRoot = join(here, '..')
65
65
  const workspaceRoot = process.cwd()
66
66
  const appDir = join(workspaceRoot, '.meith', 'app')
67
67
 
68
- // `next dev|build` runs with `.meith/app` as its own cwd (see `run()` below),
69
- // so a relative FORUM_WORKSPACE_ROOT / FORUM_ALIASES_FROM — the shape
70
- // boards/stock/package.json's scripts write, relative to *this* process's
71
- // own cwd (the board's own directory) — would resolve against the wrong
72
- // directory if left for next.config.mjs to resolve itself. Rewriting them to
73
- // absolute paths here, before spawning, means both env vars mean the same
74
- // thing regardless of which process reads them.
75
68
  for (const name of ['FORUM_WORKSPACE_ROOT', 'FORUM_ALIASES_FROM']) {
76
69
  if (process.env[name]) process.env[name] = resolve(workspaceRoot, process.env[name])
77
70
  }
78
71
 
79
- // Files this package ships (see its `files` allowlist) that belong inside
80
- // the materialized app. Board files (community.config.ts, board.plugins.json,
81
- // community.plugins.ts) are never copied they are read in place, from the
82
- // workspace, through the generated tsconfig instead.
72
+ /**
73
+ * Files this package ships (see its `files` allowlist) that belong inside the
74
+ * materialized app. Board files (community.config.ts, board.plugins.json,
75
+ * community.plugins.ts) are never copied — they are read in place, from the
76
+ * workspace, through the generated tsconfig instead.
77
+ */
83
78
  const APP_ENTRIES = [
84
79
  'app',
85
80
  'src',
81
+ 'public',
86
82
  'next.config.mjs',
87
83
  'postcss.config.mjs',
88
84
  'components.json',
@@ -136,7 +132,10 @@ function rewriteGlobalsCssSourcePaths() {
136
132
  * materialized app's own generated tsconfig, rebased to be relative to
137
133
  * `.meith/app` — the same aliases apps/community's own tsconfig.json
138
134
  * hand-maintains for exactly this reason. Unset (the default, and the only
139
- * path a real external board ever takes), this is a no-op.
135
+ * path a real external board ever takes), this is a no-op. `@board/config`
136
+ * and `@board/plugins` are excluded from the copy — they are this
137
+ * workspace's own seam, wired below to *this* board's files, never to
138
+ * whatever apps/community's own tsconfig happens to alias them to.
140
139
  */
141
140
  function monorepoAliases() {
142
141
  const configFile = process.env.FORUM_ALIASES_FROM
@@ -148,15 +147,20 @@ function monorepoAliases() {
148
147
 
149
148
  const aliases = {}
150
149
  for (const [alias, targets] of Object.entries(paths)) {
151
- // `@board/config`/`@board/plugins` are this workspace's own seam, wired
152
- // below to *this* board's files — never to whatever apps/community's own
153
- // tsconfig happens to alias them to.
154
150
  if (alias === '@board/config' || alias === '@board/plugins') continue
155
151
  aliases[alias] = targets.map((target) => toPosixRelative(appDir, join(sourceDir, target)))
156
152
  }
157
153
  return aliases
158
154
  }
159
155
 
156
+ /**
157
+ * Replaces each shipped entry (`APP_ENTRIES`) but never `rm -rf`s the whole
158
+ * `.meith/app` directory: `.next` lives there too once a build has run, and
159
+ * `forum-web start` needs that build still on disk after this same function
160
+ * re-materializes the sources ahead of launching the standalone server. The
161
+ * `next-env.d.ts` it writes only needs to exist, not be complete — next
162
+ * regenerates it with the right content on first run.
163
+ */
160
164
  function materialize() {
161
165
  const boardConfig = join(workspaceRoot, 'community.config.ts')
162
166
  const boardPlugins = join(workspaceRoot, 'community.plugins.ts')
@@ -168,10 +172,6 @@ function materialize() {
168
172
  )
169
173
  }
170
174
 
171
- // Replace each shipped entry, but never `rm -rf` the whole directory:
172
- // `.next` lives here too once a build has run, and `forum-web start` needs
173
- // that build still on disk after this same function re-materializes the
174
- // sources ahead of launching the standalone server.
175
175
  mkdirSync(appDir, { recursive: true })
176
176
 
177
177
  for (const entry of APP_ENTRIES) {
@@ -213,20 +213,20 @@ function materialize() {
213
213
 
214
214
  writeFileSync(join(appDir, 'tsconfig.json'), `${JSON.stringify(tsconfig, null, 2)}\n`)
215
215
 
216
- // next dev/build reads its own next-env.d.ts if present; an empty one is
217
- // enough, and next regenerates it with the right content on first run.
218
216
  writeFileSync(
219
217
  join(appDir, 'next-env.d.ts'),
220
218
  '/// <reference types="next" />\n/// <reference types="next/image-types/global" />\n',
221
219
  )
222
220
  }
223
221
 
222
+ /**
223
+ * Resolved from this package's own directory rather than the workspace root:
224
+ * `next` is `@meith/web`'s dependency, not necessarily the workspace
225
+ * manifest's, and resolving from here finds it either way — hoisted to the
226
+ * workspace root (npm, yarn classic) or nested under this package's own
227
+ * `node_modules` (pnpm's default, non-hoisted layout).
228
+ */
224
229
  function resolveNextBin() {
225
- // Resolved from this package's own directory rather than the workspace
226
- // root: `next` is @meith/web's dependency, not necessarily the workspace
227
- // manifest's, and resolving from here finds it either way — hoisted to
228
- // the workspace root (npm, yarn classic) or nested under this package's
229
- // own node_modules (pnpm's default, non-hoisted layout).
230
230
  const require = createRequire(join(packageRoot, 'package.json'))
231
231
  try {
232
232
  return require.resolve('next/dist/bin/next')
@@ -235,9 +235,32 @@ function resolveNextBin() {
235
235
  }
236
236
  }
237
237
 
238
- function run(executable, args, cwd) {
238
+ function standaloneAppDir() {
239
+ return join(appDir, '.next', 'standalone', relative(workspaceRoot, appDir))
240
+ }
241
+
242
+ function stageStandaloneAssets() {
243
+ const targetAppDir = standaloneAppDir()
244
+
245
+ const staticTarget = join(targetAppDir, '.next', 'static')
246
+ rmSync(staticTarget, { recursive: true, force: true })
247
+ cpSync(join(appDir, '.next', 'static'), staticTarget, { recursive: true })
248
+
249
+ const publicSource = join(appDir, 'public')
250
+ const publicTarget = join(targetAppDir, 'public')
251
+ rmSync(publicTarget, { recursive: true, force: true })
252
+ if (existsSync(publicSource)) {
253
+ cpSync(publicSource, publicTarget, { recursive: true })
254
+ }
255
+ }
256
+
257
+ function run(executable, args, cwd, onSuccess) {
239
258
  const child = spawn(executable, args, { cwd, stdio: 'inherit' })
240
- child.on('exit', (code, signal) => process.exit(code ?? (signal ? 1 : 0)))
259
+ child.on('exit', (code, signal) => {
260
+ const exitCode = code ?? (signal ? 1 : 0)
261
+ if (exitCode === 0 && onSuccess) onSuccess()
262
+ process.exit(exitCode)
263
+ })
241
264
  child.on('error', (error) => fail(error.message))
242
265
  }
243
266
 
@@ -252,20 +275,20 @@ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.me
252
275
  materialize()
253
276
 
254
277
  if (command === 'start') {
255
- // `next.config.mjs` sets `output: 'standalone'`, and a standalone build is
256
- // run from its own traced server.js, not `next start` (see docker/Dockerfile
257
- // and docker/entrypoint.sh, which run the image's board the same way). The
258
- // tracing root is the workspace root (see the module comment on why
259
- // `.meith/app` sits exactly two levels below it), so the standalone bundle
260
- // preserves that same relative path down to the app directory.
278
+ const targetAppDir = standaloneAppDir()
261
279
  const standaloneRoot = join(appDir, '.next', 'standalone')
262
- const serverScript = join(standaloneRoot, relative(workspaceRoot, appDir), 'server.js')
280
+ const serverScript = join(targetAppDir, 'server.js')
263
281
  if (!existsSync(serverScript)) {
264
282
  fail(`no standalone build at ${serverScript} — run "forum-web build" first.`)
265
283
  }
266
284
  run(process.execPath, [serverScript, ...rest], standaloneRoot)
267
285
  } else {
268
286
  const nextBin = resolveNextBin()
269
- run(process.execPath, [nextBin, command, ...rest], appDir)
287
+ run(
288
+ process.execPath,
289
+ [nextBin, command, ...rest],
290
+ appDir,
291
+ command === 'build' ? stageStandaloneAssets : undefined,
292
+ )
270
293
  }
271
294
  }
package/next.config.mjs CHANGED
@@ -4,35 +4,22 @@ 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 at
9
+ * the depth every consumer places it at except `boards/stock`, which
10
+ * `FORUM_WORKSPACE_ROOT` overrides (see docs/architecture.md, "The stock
11
+ * board"; docs/development.md, "Consuming the board from a workspace").
12
+ */
24
13
  const workspaceRoot = process.env.FORUM_WORKSPACE_ROOT
25
14
  ? path.resolve(process.env.FORUM_WORKSPACE_ROOT)
26
15
  : path.join(here, '../../')
27
16
 
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.
17
+ /**
18
+ * The relative equivalent of `workspaceRoot`, for `outputFileTracingIncludes`
19
+ * below see docs/development.md, "Consuming the board from a workspace",
20
+ * for why that option needs a path relative to this file rather than an
21
+ * absolute one.
22
+ */
36
23
  const upToWorkspaceRoot = path.relative(here, workspaceRoot).split(path.sep).join('/')
37
24
 
38
25
  const loadedEnvFiles = []
@@ -52,6 +39,10 @@ const nextConfig = {
52
39
  poweredByHeader: false,
53
40
  distDir: process.env.FORUM_DIST_DIR ?? '.next',
54
41
 
42
+ experimental: {
43
+ turbopackFileSystemCacheForDev: false,
44
+ },
45
+
55
46
  serverExternalPackages: [
56
47
  '@aws-sdk/client-s3',
57
48
  '@aws-sdk/s3-request-presigner',
@@ -62,23 +53,11 @@ const nextConfig = {
62
53
  'nodemailer',
63
54
  ],
64
55
  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.
56
+ /** See docs/development.md, "Consuming the board from a workspace", for why this is set. */
72
57
  turbopack: {
73
58
  root: workspaceRoot,
74
59
  },
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.
60
+ /** Works around a gap in Next's own tracing see docs/development.md, "Consuming the board from a workspace". */
82
61
  outputFileTracingIncludes: {
83
62
  '/**': [
84
63
  `${upToWorkspaceRoot}/node_modules/.pnpm/@swc+helpers@0.5.23/node_modules/@swc/helpers/**/*`,
@@ -87,24 +66,12 @@ const nextConfig = {
87
66
  images: {
88
67
  unoptimized: true,
89
68
  },
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".
69
+ /**
70
+ * Every `@meith/*` package this app's dependency graph reaches, not just the
71
+ * ones apps/community imports directly see docs/development.md,
72
+ * "Consuming the board from a workspace", and docs/release.md, "They ship
73
+ * TypeScript source, deliberately".
74
+ */
108
75
  transpilePackages: [
109
76
  '@meith/accounts',
110
77
  '@meith/admin',
@@ -152,12 +119,6 @@ const nextConfig = {
152
119
  '@meith/threads',
153
120
  '@meith/ui',
154
121
  '@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
122
  '@meith/web',
162
123
  ],
163
124
  async headers() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.17.1",
3
+ "version": "0.17.2",
4
4
  "description": "The board itself: the Next.js app, and the forum-web bin that materializes it into an external board workspace.",
5
5
  "license": "LGPL-3.0-or-later",
6
6
  "repository": {
@@ -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/admin": "0.17.2",
47
+ "@meith/antispam": "0.17.2",
48
+ "@meith/attachments": "0.17.2",
49
+ "@meith/api": "0.17.2",
50
+ "@meith/authorization": "0.17.2",
51
+ "@meith/accounts": "0.17.2",
52
+ "@meith/avatars": "0.17.2",
53
+ "@meith/core": "0.17.2",
54
+ "@meith/db": "0.17.2",
55
+ "@meith/events": "0.17.2",
56
+ "@meith/demo": "0.17.2",
57
+ "@meith/drivers": "0.17.2",
58
+ "@meith/forums": "0.17.2",
59
+ "@meith/groups": "0.17.2",
60
+ "@meith/i18n": "0.17.2",
61
+ "@meith/drafts": "0.17.2",
62
+ "@meith/mail": "0.17.2",
63
+ "@meith/install": "0.17.2",
64
+ "@meith/markdown": "0.17.2",
65
+ "@meith/import": "0.17.2",
66
+ "@meith/marketplace": "0.17.2",
67
+ "@meith/moderation": "0.17.2",
68
+ "@meith/notifications": "0.17.2",
69
+ "@meith/messages": "0.17.2",
70
+ "@meith/polls": "0.17.2",
71
+ "@meith/plugin-dues": "0.17.2",
72
+ "@meith/posts": "0.17.2",
73
+ "@meith/plugin-kit": "0.17.2",
74
+ "@meith/profile-fields": "0.17.2",
75
+ "@meith/runtime": "0.17.2",
76
+ "@meith/search": "0.17.2",
77
+ "@meith/relations": "0.17.2",
78
+ "@meith/reputation": "0.17.2",
79
+ "@meith/signatures": "0.17.2",
80
+ "@meith/settings": "0.17.2",
81
+ "@meith/subscriptions": "0.17.2",
82
+ "@meith/tasks": "0.17.2",
83
+ "@meith/theme-clubhouse": "0.17.2",
84
+ "@meith/theme-default": "0.17.2",
85
+ "@meith/theme-kit": "0.17.2",
86
+ "@meith/theme-midnight": "0.17.2",
87
+ "@meith/theme-phasebook": "0.17.2",
88
+ "@meith/theme-raidframe": "0.17.2",
89
+ "@meith/threads": "0.17.2",
90
+ "@meith/ui": "0.17.2",
91
+ "@meith/upgrade": "0.17.2"
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' }
@@ -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.17.2'
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 {