@meith/web 0.26.1 → 0.28.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.
@@ -4,6 +4,7 @@ import type { TaskHealthStatus } from '@meith/tasks'
4
4
  import { cn } from '@meith/ui'
5
5
 
6
6
  import {
7
+ ApplyMigrationsForm,
7
8
  ClearCacheForm,
8
9
  PruneSessionsForm,
9
10
  PruneTokensForm,
@@ -16,6 +17,7 @@ import { PanelPage } from '@/components/shell/panel-page'
16
17
  import { adminPageContext } from '@/server/admin'
17
18
  import { getTranslator, tr } from '@/server/i18n'
18
19
  import { buildSystemHealthView } from '@/server/system-admin'
20
+ import { CODE_VERSION, pendingUpgradeNotice } from '@/server/upgrade-notice'
19
21
  import { systemFormsCopy } from '@/view/admin-panel-copy'
20
22
  import { formatTime } from '@/view/time'
21
23
 
@@ -36,7 +38,11 @@ export default async function AdminSystemPage() {
36
38
  if ((await adminPageContext()) === null) return null
37
39
 
38
40
  const now = new Date()
39
- const [translator, view] = await Promise.all([getTranslator(), buildSystemHealthView(now)])
41
+ const [translator, view, upgradeNotice] = await Promise.all([
42
+ getTranslator(),
43
+ buildSystemHealthView(now),
44
+ pendingUpgradeNotice(),
45
+ ])
40
46
  const copy = systemFormsCopy(translator)
41
47
 
42
48
  if (view === null) {
@@ -246,6 +252,28 @@ export default async function AdminSystemPage() {
246
252
  </ul>
247
253
  </section>
248
254
 
255
+ <section className={PANEL_CARD}>
256
+ <h2 className="font-heading text-lg font-semibold">
257
+ {translator.t('adminSystem.migrations')}
258
+ </h2>
259
+ <p className="text-sm">
260
+ {translator.t('adminSystem.runningVersion')} <code>{CODE_VERSION}</code>.
261
+ </p>
262
+ {upgradeNotice === null ? (
263
+ <p className="text-sm text-muted-foreground">
264
+ {translator.t('adminSystem.migrationsUpToDate')}
265
+ </p>
266
+ ) : (
267
+ <>
268
+ <p className="text-sm text-destructive">{upgradeNotice}</p>
269
+ <p className="text-sm text-muted-foreground">
270
+ {translator.t('adminSystem.migrationsHint')}
271
+ </p>
272
+ <ApplyMigrationsForm copy={copy} />
273
+ </>
274
+ )}
275
+ </section>
276
+
249
277
  <section className={PANEL_CARD}>
250
278
  <h2 className="font-heading text-lg font-semibold">
251
279
  {translator.t('adminSystem.recount')}
@@ -167,8 +167,8 @@ export default async function AdminThemesPage() {
167
167
  </p>
168
168
  <p className="text-muted-foreground">
169
169
  {translator.t('adminThemes.deployBefore')} <em>exist</em>
170
- {translator.t('adminThemes.deployAfterExist')} <code className="text-xs">pnpm add</code>{' '}
171
- {translator.t('adminThemes.deployAfterAdd')}{' '}
170
+ {translator.t('adminThemes.deployAfterExist')}{' '}
171
+ <code className="text-xs">npm install</code> {translator.t('adminThemes.deployAfterAdd')}{' '}
172
172
  <code className="text-xs">meith.config.ts</code>
173
173
  {translator.t('adminThemes.deployAfterConfig')}{' '}
174
174
  <code className="text-xs">defaultTheme</code> {translator.t('adminThemes.deployEnd')}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.26.1",
3
+ "version": "0.28.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
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,53 +43,53 @@
43
43
  "react-dom": "19.2.8",
44
44
  "tailwindcss": "^4.3.3",
45
45
  "typescript": "7.0.2",
46
- "@meith/accounts": "0.26.1",
47
- "@meith/admin": "0.26.1",
48
- "@meith/antispam": "0.26.1",
49
- "@meith/api": "0.26.1",
50
- "@meith/attachments": "0.26.1",
51
- "@meith/authorization": "0.26.1",
52
- "@meith/avatars": "0.26.1",
53
- "@meith/core": "0.26.1",
54
- "@meith/db": "0.26.1",
55
- "@meith/demo": "0.26.1",
56
- "@meith/drafts": "0.26.1",
57
- "@meith/drivers": "0.26.1",
58
- "@meith/events": "0.26.1",
59
- "@meith/forums": "0.26.1",
60
- "@meith/groups": "0.26.1",
61
- "@meith/i18n": "0.26.1",
62
- "@meith/import": "0.26.1",
63
- "@meith/install": "0.26.1",
64
- "@meith/mail": "0.26.1",
65
- "@meith/markdown": "0.26.1",
66
- "@meith/marketplace": "0.26.1",
67
- "@meith/messages": "0.26.1",
68
- "@meith/moderation": "0.26.1",
69
- "@meith/notifications": "0.26.1",
70
- "@meith/plugin-calendar": "0.26.1",
71
- "@meith/plugin-dues": "0.26.1",
72
- "@meith/plugin-kit": "0.26.1",
73
- "@meith/polls": "0.26.1",
74
- "@meith/posts": "0.26.1",
75
- "@meith/profile-fields": "0.26.1",
76
- "@meith/relations": "0.26.1",
77
- "@meith/reputation": "0.26.1",
78
- "@meith/runtime": "0.26.1",
79
- "@meith/search": "0.26.1",
80
- "@meith/settings": "0.26.1",
81
- "@meith/signatures": "0.26.1",
82
- "@meith/subscriptions": "0.26.1",
83
- "@meith/tasks": "0.26.1",
84
- "@meith/theme-clubhouse": "0.26.1",
85
- "@meith/theme-default": "0.26.1",
86
- "@meith/theme-kit": "0.26.1",
87
- "@meith/theme-midnight": "0.26.1",
88
- "@meith/theme-phasebook": "0.26.1",
89
- "@meith/theme-raidframe": "0.26.1",
90
- "@meith/threads": "0.26.1",
91
- "@meith/ui": "0.26.1",
92
- "@meith/upgrade": "0.26.1"
46
+ "@meith/accounts": "0.28.0",
47
+ "@meith/admin": "0.28.0",
48
+ "@meith/antispam": "0.28.0",
49
+ "@meith/api": "0.28.0",
50
+ "@meith/attachments": "0.28.0",
51
+ "@meith/authorization": "0.28.0",
52
+ "@meith/avatars": "0.28.0",
53
+ "@meith/core": "0.28.0",
54
+ "@meith/db": "0.28.0",
55
+ "@meith/demo": "0.28.0",
56
+ "@meith/drafts": "0.28.0",
57
+ "@meith/drivers": "0.28.0",
58
+ "@meith/events": "0.28.0",
59
+ "@meith/forums": "0.28.0",
60
+ "@meith/i18n": "0.28.0",
61
+ "@meith/groups": "0.28.0",
62
+ "@meith/import": "0.28.0",
63
+ "@meith/install": "0.28.0",
64
+ "@meith/mail": "0.28.0",
65
+ "@meith/markdown": "0.28.0",
66
+ "@meith/marketplace": "0.28.0",
67
+ "@meith/messages": "0.28.0",
68
+ "@meith/moderation": "0.28.0",
69
+ "@meith/notifications": "0.28.0",
70
+ "@meith/plugin-calendar": "0.28.0",
71
+ "@meith/plugin-dues": "0.28.0",
72
+ "@meith/plugin-kit": "0.28.0",
73
+ "@meith/polls": "0.28.0",
74
+ "@meith/posts": "0.28.0",
75
+ "@meith/profile-fields": "0.28.0",
76
+ "@meith/relations": "0.28.0",
77
+ "@meith/reputation": "0.28.0",
78
+ "@meith/runtime": "0.28.0",
79
+ "@meith/search": "0.28.0",
80
+ "@meith/settings": "0.28.0",
81
+ "@meith/signatures": "0.28.0",
82
+ "@meith/subscriptions": "0.28.0",
83
+ "@meith/tasks": "0.28.0",
84
+ "@meith/theme-clubhouse": "0.28.0",
85
+ "@meith/theme-default": "0.28.0",
86
+ "@meith/theme-kit": "0.28.0",
87
+ "@meith/theme-midnight": "0.28.0",
88
+ "@meith/theme-phasebook": "0.28.0",
89
+ "@meith/theme-raidframe": "0.28.0",
90
+ "@meith/threads": "0.28.0",
91
+ "@meith/ui": "0.28.0",
92
+ "@meith/upgrade": "0.28.0"
93
93
  },
94
94
  "scripts": {
95
95
  "dev": "next dev",
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import { useActionState } from 'react'
3
+ import { useActionState, useEffect, useState } from 'react'
4
4
 
5
5
  import type { MatrixCell, MatrixRow } from '@meith/authorization'
6
6
  import { cn, Disclosure } from '@meith/ui'
@@ -301,10 +301,19 @@ export function ForumPermissionRowForm({
301
301
  copy: Copy
302
302
  }) {
303
303
  const [state, action] = useActionState(saveForumPermissionsAction, EMPTY_STATE)
304
+ const [dirty, setDirty] = useState(false)
305
+
306
+ useEffect(() => {
307
+ if (state.notice === 'saved') setDirty(false)
308
+ }, [state])
304
309
 
305
310
  return (
306
- <Disclosure summary={row.groupTitle} aside={overridesAside(row, copy)}>
307
- <form action={action} className="flex flex-col gap-5">
311
+ <Disclosure
312
+ summary={row.groupTitle}
313
+ aside={overridesAside(row, copy)}
314
+ className="overflow-visible"
315
+ >
316
+ <form action={action} className="flex flex-col gap-5" onChange={() => setDirty(true)}>
308
317
  <FormError message={state.error} />
309
318
  <input type="hidden" name="forumId" value={forumId} />
310
319
  <input type="hidden" name="groupId" value={row.groupId} />
@@ -333,14 +342,20 @@ export function ForumPermissionRowForm({
333
342
  </section>
334
343
  ))}
335
344
 
336
- <div className="flex items-center gap-3">
337
- <SubmitButton>
345
+ <div className="sticky bottom-0 -mx-4 -mb-4 flex items-center gap-3 rounded-b-lg border-t border-border bg-card/95 px-4 py-3 supports-[backdrop-filter]:bg-card/80 supports-[backdrop-filter]:backdrop-blur">
346
+ <SubmitButton className="w-auto">
338
347
  {formatFromCopy(copy, 'adminForum.saveGroup', { group: row.groupTitle })}
339
348
  </SubmitButton>
340
- {state.notice === 'saved' && (
341
- <span className="text-xs font-medium text-moderation-approved">
342
- {fromCopy(copy, 'admin.saved')}
349
+ {dirty ? (
350
+ <span className="text-xs font-medium text-muted-foreground">
351
+ {fromCopy(copy, 'adminForum.unsavedChanges')}
343
352
  </span>
353
+ ) : (
354
+ state.notice === 'saved' && (
355
+ <span className="text-xs font-medium text-moderation-approved">
356
+ {fromCopy(copy, 'admin.saved')}
357
+ </span>
358
+ )
344
359
  )}
345
360
  </div>
346
361
  </form>
@@ -4,6 +4,7 @@ import { useActionState } from 'react'
4
4
 
5
5
  import { EMPTY_STATE } from '@/server/auth-form-state'
6
6
  import {
7
+ applyUpgradeAction,
7
8
  clearCacheAction,
8
9
  pruneSessionsAction,
9
10
  pruneTokensAction,
@@ -42,6 +43,27 @@ export function PruneSessionsForm({ prunable, copy }: { prunable: number; copy:
42
43
  )
43
44
  }
44
45
 
46
+ export function ApplyMigrationsForm({ copy }: { copy: Copy }) {
47
+ const [state, action] = useActionState(applyUpgradeAction, EMPTY_STATE)
48
+
49
+ return (
50
+ <form action={action} className="flex flex-col gap-2">
51
+ <FormError message={state.error} />
52
+ {state.notice === 'upgraded' && (
53
+ <Result>
54
+ {formatFromCopy(copy, 'adminPanel.system.upgradeApplied', {
55
+ core: state.values?.core ?? '0',
56
+ plugins: state.values?.plugins === '' ? '—' : (state.values?.plugins ?? '—'),
57
+ })}
58
+ </Result>
59
+ )}
60
+ <div>
61
+ <SubmitButton>{fromCopy(copy, 'adminPanel.system.applyMigrations')}</SubmitButton>
62
+ </div>
63
+ </form>
64
+ )
65
+ }
66
+
45
67
  export function PruneTokensForm({ copy }: { copy: Copy }) {
46
68
  const [state, action] = useActionState(pruneTokensAction, EMPTY_STATE)
47
69
 
@@ -131,7 +131,10 @@ export function MarkdownEditor({
131
131
  const element = field.current
132
132
  if (element === null) return
133
133
  element.style.height = 'auto'
134
- element.style.height = `${element.scrollHeight}px`
134
+ const max = Math.max(240, Math.round(window.innerHeight * 0.6))
135
+ const overflowing = element.scrollHeight > max
136
+ element.style.height = `${overflowing ? max : element.scrollHeight}px`
137
+ element.style.overflowY = overflowing ? 'auto' : 'hidden'
135
138
  }, [])
136
139
 
137
140
  useEffect(() => {
@@ -6,12 +6,13 @@ import { NotFoundError, ValidationError } from '@meith/core'
6
6
  import { drivers } from '@meith/drivers'
7
7
  import { msg } from '@meith/i18n'
8
8
 
9
- import { recordAdminAction, requireAdmin } from './admin'
9
+ import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
10
10
  import type { FormState } from './auth-form-state'
11
11
  import { clearableTag } from './cache-targets'
12
12
  import { formStateReporter } from './form-state-reporter'
13
13
  import { requireSearch } from './search'
14
14
  import { requireMaintenance, requireRecount } from './system-admin'
15
+ import { applyPendingUpgrade } from './upgrade-notice'
15
16
 
16
17
  const SWEEP_LIMIT = 5_000
17
18
 
@@ -89,6 +90,28 @@ export async function reindexSearchAction(): Promise<FormState> {
89
90
  }
90
91
  }
91
92
 
93
+ export async function applyUpgradeAction(): Promise<FormState> {
94
+ try {
95
+ await requireFreshAdmin()
96
+
97
+ const result = await applyPendingUpgrade()
98
+
99
+ refreshSystemScreen()
100
+ revalidatePath('/admin')
101
+ await recordAdminAction({
102
+ action: 'system.upgrade_applied',
103
+ detail: { coreMigrations: result.coreMigrations, plugins: result.plugins },
104
+ })
105
+
106
+ return {
107
+ notice: 'upgraded',
108
+ values: { core: String(result.coreMigrations), plugins: result.plugins.join(', ') },
109
+ }
110
+ } catch (err) {
111
+ return toFormState(err)
112
+ }
113
+ }
114
+
92
115
  export async function clearCacheAction(_prev: FormState, form: FormData): Promise<FormState> {
93
116
  try {
94
117
  await requireAdmin()
@@ -1,12 +1,94 @@
1
1
  import 'server-only'
2
2
 
3
- import { env, logger } from '@meith/core'
4
- import { appliedPluginMigrations, getDb, readVersion } from '@meith/db'
3
+ import { env, logger, ValidationError } from '@meith/core'
4
+ import {
5
+ appliedPluginMigrations,
6
+ applyPluginMigration,
7
+ getDb,
8
+ PostgresNavigationRepository,
9
+ readVersion,
10
+ recordVersion,
11
+ runMigrations,
12
+ } from '@meith/db'
13
+ import { msg } from '@meith/i18n'
14
+ import { pluginNavigationPlacements } from '@meith/plugin-kit'
15
+ import { runPluginLifecycle } from '@meith/runtime'
5
16
  import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
6
17
 
7
18
  import { activeDefinitions } from './plugin-host'
8
19
 
9
- export const CODE_VERSION = '0.26.1'
20
+ export const CODE_VERSION = '0.28.0'
21
+
22
+ export interface UpgradeApplied {
23
+ readonly coreMigrations: number
24
+ readonly plugins: readonly string[]
25
+ }
26
+
27
+ export async function applyPendingUpgrade(): Promise<UpgradeApplied> {
28
+ const db = getDb()
29
+ const definitions = activeDefinitions()
30
+
31
+ const applied: Record<string, readonly string[]> = {}
32
+ for (const plugin of definitions) {
33
+ applied[plugin.key] = await appliedPluginMigrations(db, plugin.key)
34
+ }
35
+
36
+ const state: UpgradeState = {
37
+ recordedVersion: (await readVersion(db, 'core')) ?? CODE_VERSION,
38
+ codeVersion: CODE_VERSION,
39
+ pendingCoreMigrations: [],
40
+ plugins: definitions.map((plugin) => ({
41
+ key: plugin.key,
42
+ version: plugin.version,
43
+ dependsOn: plugin.dependsOn ?? [],
44
+ migrationIds: (plugin.migrations ?? []).map((migration) => migration.id),
45
+ })),
46
+ appliedPluginMigrations: applied,
47
+ }
48
+
49
+ const plan = planUpgrade(state)
50
+ if (plan.refusal !== null || plan.orderFailure !== null) {
51
+ const notice = upgradeNotice(plan, state)
52
+ throw notice === null
53
+ ? new ValidationError(msg('error.app.board-cannot-be-upgraded'))
54
+ : new ValidationError(notice)
55
+ }
56
+
57
+ const fresh = new Set<string>()
58
+ for (const plugin of definitions) {
59
+ if ((await readVersion(db, `plugin:${plugin.key}`)) === null) fresh.add(plugin.key)
60
+ }
61
+
62
+ const coreMigrations = await runMigrations()
63
+
64
+ const touched: string[] = []
65
+ for (const definition of definitions) {
66
+ let changed = false
67
+ for (const migration of definition.migrations ?? []) {
68
+ const ran = await applyPluginMigration(db, definition.key, migration.id, migration.statements)
69
+ if (ran) changed = true
70
+ }
71
+ if (fresh.has(definition.key) && definition.onInstall !== undefined) {
72
+ const { ran } = await runPluginLifecycle({ db, plugin: definition, phase: 'install' })
73
+ if (ran) changed = true
74
+ }
75
+ await recordVersion(db, `plugin:${definition.key}`, definition.version)
76
+ if (changed) touched.push(definition.key)
77
+ }
78
+
79
+ await new PostgresNavigationRepository(db).syncPluginItems(
80
+ pluginNavigationPlacements(definitions).map((item) => ({
81
+ key: item.key,
82
+ href: item.href,
83
+ audience: item.audience,
84
+ parentKey: item.parentKey,
85
+ })),
86
+ )
87
+
88
+ await recordVersion(db, 'core', CODE_VERSION)
89
+
90
+ return { coreMigrations, plugins: touched }
91
+ }
10
92
 
11
93
  export async function pendingUpgradeNotice(): Promise<string | null> {
12
94
  if (env.DATA_SOURCE !== 'postgres') return null
@@ -63,6 +63,7 @@ export function forumAdminCopy(t: Translator = untranslated()): Readonly<Record<
63
63
  'adminForum.permGroup.approvals',
64
64
  'adminForum.permGroup.other',
65
65
  'adminForum.inheritsAll',
66
+ 'adminForum.unsavedChanges',
66
67
  'adminForum.copied',
67
68
  'adminForum.copyNote',
68
69
  'adminForum.topLevel',
@@ -60,6 +60,7 @@ export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record
60
60
  'adminPanel.system.jobIdHint',
61
61
  'adminPanel.system.retry',
62
62
  'adminPanel.system.nothingToIndex',
63
+ 'adminPanel.system.applyMigrations',
63
64
  ],
64
65
  t,
65
66
  ),
@@ -73,6 +74,7 @@ export function systemFormsCopy(t: Translator = untranslated()): Readonly<Record
73
74
  'adminPanel.system.indexFinished',
74
75
  'adminPanel.system.indexMore',
75
76
  'adminPanel.system.indexNext',
77
+ 'adminPanel.system.upgradeApplied',
76
78
  ],
77
79
  t,
78
80
  ),
@@ -13,7 +13,6 @@ export const GROUP_LABELS: Record<SettingGroup, string> = {
13
13
  antispam: 'Anti-spam',
14
14
  push: 'Push',
15
15
  legal: 'Legal',
16
- marketplace: 'Marketplace',
17
16
  }
18
17
 
19
18
  export const GROUP_ORDER: readonly SettingGroup[] = [
@@ -29,7 +28,6 @@ export const GROUP_ORDER: readonly SettingGroup[] = [
29
28
  'antispam',
30
29
  'push',
31
30
  'legal',
32
- 'marketplace',
33
31
  ]
34
32
 
35
33
  export const DEFAULT_SETTING_GROUP: SettingGroup = GROUP_ORDER[0]!