@meith/web 0.30.0 → 0.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meith/web",
3
- "version": "0.30.0",
3
+ "version": "0.31.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.30.0",
47
- "@meith/admin": "0.30.0",
48
- "@meith/antispam": "0.30.0",
49
- "@meith/api": "0.30.0",
50
- "@meith/attachments": "0.30.0",
51
- "@meith/authorization": "0.30.0",
52
- "@meith/avatars": "0.30.0",
53
- "@meith/core": "0.30.0",
54
- "@meith/db": "0.30.0",
55
- "@meith/demo": "0.30.0",
56
- "@meith/drafts": "0.30.0",
57
- "@meith/drivers": "0.30.0",
58
- "@meith/events": "0.30.0",
59
- "@meith/forums": "0.30.0",
60
- "@meith/groups": "0.30.0",
61
- "@meith/i18n": "0.30.0",
62
- "@meith/import": "0.30.0",
63
- "@meith/install": "0.30.0",
64
- "@meith/mail": "0.30.0",
65
- "@meith/markdown": "0.30.0",
66
- "@meith/marketplace": "0.30.0",
67
- "@meith/messages": "0.30.0",
68
- "@meith/moderation": "0.30.0",
69
- "@meith/notifications": "0.30.0",
70
- "@meith/plugin-calendar": "0.30.0",
71
- "@meith/plugin-dues": "0.30.0",
72
- "@meith/plugin-kit": "0.30.0",
73
- "@meith/polls": "0.30.0",
74
- "@meith/posts": "0.30.0",
75
- "@meith/profile-fields": "0.30.0",
76
- "@meith/relations": "0.30.0",
77
- "@meith/reputation": "0.30.0",
78
- "@meith/runtime": "0.30.0",
79
- "@meith/search": "0.30.0",
80
- "@meith/settings": "0.30.0",
81
- "@meith/signatures": "0.30.0",
82
- "@meith/subscriptions": "0.30.0",
83
- "@meith/tasks": "0.30.0",
84
- "@meith/theme-clubhouse": "0.30.0",
85
- "@meith/theme-default": "0.30.0",
86
- "@meith/theme-kit": "0.30.0",
87
- "@meith/theme-midnight": "0.30.0",
88
- "@meith/theme-phasebook": "0.30.0",
89
- "@meith/theme-raidframe": "0.30.0",
90
- "@meith/threads": "0.30.0",
91
- "@meith/ui": "0.30.0",
92
- "@meith/upgrade": "0.30.0"
46
+ "@meith/accounts": "0.31.0",
47
+ "@meith/admin": "0.31.0",
48
+ "@meith/antispam": "0.31.0",
49
+ "@meith/api": "0.31.0",
50
+ "@meith/attachments": "0.31.0",
51
+ "@meith/authorization": "0.31.0",
52
+ "@meith/avatars": "0.31.0",
53
+ "@meith/core": "0.31.0",
54
+ "@meith/db": "0.31.0",
55
+ "@meith/demo": "0.31.0",
56
+ "@meith/drafts": "0.31.0",
57
+ "@meith/drivers": "0.31.0",
58
+ "@meith/events": "0.31.0",
59
+ "@meith/forums": "0.31.0",
60
+ "@meith/groups": "0.31.0",
61
+ "@meith/i18n": "0.31.0",
62
+ "@meith/import": "0.31.0",
63
+ "@meith/install": "0.31.0",
64
+ "@meith/mail": "0.31.0",
65
+ "@meith/markdown": "0.31.0",
66
+ "@meith/marketplace": "0.31.0",
67
+ "@meith/messages": "0.31.0",
68
+ "@meith/moderation": "0.31.0",
69
+ "@meith/notifications": "0.31.0",
70
+ "@meith/plugin-calendar": "0.31.0",
71
+ "@meith/plugin-dues": "0.31.0",
72
+ "@meith/plugin-kit": "0.31.0",
73
+ "@meith/polls": "0.31.0",
74
+ "@meith/posts": "0.31.0",
75
+ "@meith/profile-fields": "0.31.0",
76
+ "@meith/relations": "0.31.0",
77
+ "@meith/reputation": "0.31.0",
78
+ "@meith/runtime": "0.31.0",
79
+ "@meith/search": "0.31.0",
80
+ "@meith/settings": "0.31.0",
81
+ "@meith/signatures": "0.31.0",
82
+ "@meith/subscriptions": "0.31.0",
83
+ "@meith/tasks": "0.31.0",
84
+ "@meith/theme-clubhouse": "0.31.0",
85
+ "@meith/theme-default": "0.31.0",
86
+ "@meith/theme-kit": "0.31.0",
87
+ "@meith/theme-midnight": "0.31.0",
88
+ "@meith/theme-phasebook": "0.31.0",
89
+ "@meith/theme-raidframe": "0.31.0",
90
+ "@meith/threads": "0.31.0",
91
+ "@meith/ui": "0.31.0",
92
+ "@meith/upgrade": "0.31.0"
93
93
  },
94
94
  "scripts": {
95
95
  "dev": "next dev",
@@ -0,0 +1,86 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useState } from 'react'
4
+
5
+ import { INSTALL_BANNER_COOKIE, INSTALL_BANNER_COOKIE_MAX_AGE } from '@/view/install-banner'
6
+
7
+ interface InstallPrompt extends Event {
8
+ prompt(): Promise<void>
9
+ readonly userChoice: Promise<{ readonly outcome: 'accepted' | 'dismissed' }>
10
+ }
11
+
12
+ interface CookieStoreLike {
13
+ set(init: {
14
+ readonly name: string
15
+ readonly value: string
16
+ readonly path: string
17
+ readonly expires: number
18
+ readonly sameSite: 'lax'
19
+ }): Promise<void>
20
+ }
21
+
22
+ function isInstallPrompt(event: Event): event is InstallPrompt {
23
+ return 'prompt' in event && 'userChoice' in event
24
+ }
25
+
26
+ const BUTTON =
27
+ 'inline-flex h-8 items-center rounded-md border border-border bg-background px-3 text-xs font-medium text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
28
+
29
+ export function InstallAction({ label, how }: { label: string; how: string }) {
30
+ const [pending, setPending] = useState<InstallPrompt | null>(null)
31
+
32
+ useEffect(() => {
33
+ const capture = (event: Event) => {
34
+ if (!isInstallPrompt(event)) return
35
+ event.preventDefault()
36
+ setPending(event)
37
+ }
38
+ const installed = () => setPending(null)
39
+
40
+ window.addEventListener('beforeinstallprompt', capture)
41
+ window.addEventListener('appinstalled', installed)
42
+ return () => {
43
+ window.removeEventListener('beforeinstallprompt', capture)
44
+ window.removeEventListener('appinstalled', installed)
45
+ }
46
+ }, [])
47
+
48
+ if (pending !== null) {
49
+ const install = () => {
50
+ const prompt = pending
51
+ setPending(null)
52
+ prompt
53
+ .prompt()
54
+ .then(() => prompt.userChoice)
55
+ .then((choice) => {
56
+ if (choice.outcome !== 'accepted') return
57
+ const store = (window as { cookieStore?: CookieStoreLike }).cookieStore
58
+ return store?.set({
59
+ name: INSTALL_BANNER_COOKIE,
60
+ value: '1',
61
+ path: '/',
62
+ expires: Date.now() + INSTALL_BANNER_COOKIE_MAX_AGE * 1000,
63
+ sameSite: 'lax',
64
+ })
65
+ })
66
+ .catch(() => {})
67
+ }
68
+
69
+ return (
70
+ <button type="button" onClick={install} className={BUTTON}>
71
+ {label}
72
+ </button>
73
+ )
74
+ }
75
+
76
+ return (
77
+ <details>
78
+ <summary className={`${BUTTON} cursor-pointer list-none [&::-webkit-details-marker]:hidden`}>
79
+ {label}
80
+ </summary>
81
+ <p className="absolute inset-x-0 bottom-full z-10 mb-2 rounded-md border border-border bg-card p-3 text-xs text-muted-foreground shadow-lg">
82
+ {how}
83
+ </p>
84
+ </details>
85
+ )
86
+ }
@@ -0,0 +1,29 @@
1
+ import { PendingButton } from '@/components/auth/form-controls'
2
+ import { InstallAction } from '@/components/shell/install-action'
3
+ import { BOARD_MEASURE } from '@/components/shell/measure'
4
+ import type { InstallBannerModel } from '@/server/install-banner'
5
+ import { dismissInstallBannerAction } from '@/server/install-banner-actions'
6
+
7
+ export function InstallBanner({ message, how, installLabel, dismissLabel }: InstallBannerModel) {
8
+ return (
9
+ <div className="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-card text-sm text-foreground shadow-lg md:hidden [@media(display-mode:standalone)]:hidden">
10
+ <div
11
+ className={`${BOARD_MEASURE} relative flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]`}
12
+ >
13
+ <p>{message}</p>
14
+
15
+ <div className="flex items-center gap-3">
16
+ <InstallAction label={installLabel} how={how} />
17
+ <form action={dismissInstallBannerAction}>
18
+ <PendingButton
19
+ showWorking
20
+ className="text-xs text-muted-foreground hover:text-foreground"
21
+ >
22
+ {dismissLabel}
23
+ </PendingButton>
24
+ </form>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ )
29
+ }
@@ -3,6 +3,7 @@ import { currentRequestId } from '@meith/core/logger'
3
3
  import { requireSlot, slotCopy } from '@meith/theme-kit'
4
4
 
5
5
  import { LogoutForm } from '@/components/account/logout-form'
6
+ import { InstallBanner } from '@/components/shell/install-banner'
6
7
  import { NotificationMenu } from '@/components/shell/notification-menu'
7
8
  import { OnboardingBanner } from '@/components/shell/onboarding-banner'
8
9
  import { ThemeSwitcher } from '@/components/shell/theme-switcher'
@@ -10,6 +11,7 @@ import { avatarsFor } from '@/server/avatars'
10
11
  import { currentLogo } from '@/server/branding'
11
12
  import { getContainer } from '@/server/container'
12
13
  import { getTranslator } from '@/server/i18n'
14
+ import { installBanner } from '@/server/install-banner'
13
15
  import { legalFooterLinks } from '@/server/legal'
14
16
  import { unreadMessageCount } from '@/server/messages'
15
17
  import { boardNavigation } from '@/server/navigation'
@@ -119,6 +121,7 @@ export async function PageShell({ actor, children }: { actor: Actor; children: R
119
121
  const jump = built === null ? null : await filterView('view.forum-jump', built, pluginContext)
120
122
 
121
123
  const onboarding = await onboardingBanner(actor).catch(() => null)
124
+ const install = await installBanner().catch(() => null)
122
125
 
123
126
  const translator = await getTranslator()
124
127
 
@@ -151,17 +154,25 @@ export async function PageShell({ actor, children }: { actor: Actor; children: R
151
154
 
152
155
  {await boardRegion('header.notice', actor)}
153
156
 
157
+ {install !== null && <InstallBanner {...install} />}
154
158
  {onboarding !== null && <OnboardingBanner {...onboarding} />}
155
159
 
156
160
  {children}
157
161
 
158
- {jump !== null && jump.forums.length > 0 && (
159
- <ForumJump {...jump} copy={slotCopy(theme, 'ForumJump', translator)} />
160
- )}
161
-
162
- <ThemeSwitcher />
163
-
164
- <Footer {...footerModel} copy={slotCopy(theme, 'Footer', translator)} />
162
+ <Footer
163
+ {...footerModel}
164
+ regions={{
165
+ controls: (
166
+ <>
167
+ {jump !== null && jump.forums.length > 0 && (
168
+ <ForumJump {...jump} copy={slotCopy(theme, 'ForumJump', translator)} />
169
+ )}
170
+ <ThemeSwitcher />
171
+ </>
172
+ ),
173
+ }}
174
+ copy={slotCopy(theme, 'Footer', translator)}
175
+ />
165
176
  </Shell>
166
177
  )
167
178
  }
@@ -1,6 +1,5 @@
1
1
  import { PendingButton } from '@/components/auth/form-controls'
2
2
  import { MonitorIcon, MoonIcon, SunIcon } from '@/components/shell/appearance-icons'
3
- import { BOARD_MEASURE } from '@/components/shell/measure'
4
3
  import { setAppearanceAction } from '@/server/appearance-actions'
5
4
  import { getTranslator } from '@/server/i18n'
6
5
  import { currentColourScheme, currentThemeKey } from '@/server/theme'
@@ -31,67 +30,61 @@ export async function ThemeSwitcher() {
31
30
  ])
32
31
 
33
32
  return (
34
- <div className="border-t border-border bg-card text-card-foreground">
35
- <div
36
- className={`${BOARD_MEASURE} flex flex-wrap items-center justify-end gap-x-4 gap-y-2 py-2`}
37
- >
38
- <section
39
- aria-label={t.t('appearance.section')}
40
- className="flex flex-wrap items-center justify-end gap-x-4 gap-y-2"
33
+ <section
34
+ aria-label={t.t('appearance.section')}
35
+ className="flex flex-wrap items-center justify-end gap-x-4 gap-y-2"
36
+ >
37
+ {choices.length > 1 && (
38
+ <form action={setAppearanceAction} className="flex items-center gap-2">
39
+ <label htmlFor="appearance-theme" className="text-xs text-muted-foreground">
40
+ {t.t('appearance.theme')}
41
+ </label>
42
+ <select id="appearance-theme" name="theme" defaultValue={theme} className={CONTROL}>
43
+ {choices.map((choice) => (
44
+ <option key={choice.key} value={choice.key}>
45
+ {choice.title}
46
+ </option>
47
+ ))}
48
+ </select>
49
+ <PendingButton
50
+ showWorking
51
+ className="h-8 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
52
+ >
53
+ {t.t('appearance.apply')}
54
+ </PendingButton>
55
+ </form>
56
+ )}
57
+
58
+ <form action={setAppearanceAction}>
59
+ <span
60
+ role="group"
61
+ aria-label={t.t('appearance.schemeGroup')}
62
+ className="inline-flex items-center overflow-hidden rounded-md border border-border text-muted-foreground"
41
63
  >
42
- {choices.length > 1 && (
43
- <form action={setAppearanceAction} className="flex items-center gap-2">
44
- <label htmlFor="appearance-theme" className="text-xs text-muted-foreground">
45
- {t.t('appearance.theme')}
46
- </label>
47
- <select id="appearance-theme" name="theme" defaultValue={theme} className={CONTROL}>
48
- {choices.map((choice) => (
49
- <option key={choice.key} value={choice.key}>
50
- {choice.title}
51
- </option>
52
- ))}
53
- </select>
64
+ {COLOUR_SCHEMES.map((option) => {
65
+ const Icon = SCHEME_ICON[option]
66
+ const label = t.t(SCHEME_LABEL_KEY[option])
67
+ const selected = scheme === option
68
+ return (
54
69
  <PendingButton
55
- showWorking
56
- className="h-8 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
70
+ key={option}
71
+ name="scheme"
72
+ value={option}
73
+ title={label}
74
+ aria-pressed={selected}
75
+ className={`inline-flex h-8 items-center justify-center border-border px-2.5 transition-colors [&:not(:first-child)]:border-l focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${
76
+ selected
77
+ ? 'bg-primary text-primary-foreground'
78
+ : 'bg-background hover:bg-accent hover:text-accent-foreground'
79
+ }`}
57
80
  >
58
- {t.t('appearance.apply')}
81
+ <Icon />
82
+ <span className="sr-only">{label}</span>
59
83
  </PendingButton>
60
- </form>
61
- )}
62
-
63
- <form action={setAppearanceAction}>
64
- <span
65
- role="group"
66
- aria-label={t.t('appearance.schemeGroup')}
67
- className="inline-flex items-center overflow-hidden rounded-md border border-border text-muted-foreground"
68
- >
69
- {COLOUR_SCHEMES.map((option) => {
70
- const Icon = SCHEME_ICON[option]
71
- const label = t.t(SCHEME_LABEL_KEY[option])
72
- const selected = scheme === option
73
- return (
74
- <PendingButton
75
- key={option}
76
- name="scheme"
77
- value={option}
78
- title={label}
79
- aria-pressed={selected}
80
- className={`inline-flex h-8 items-center justify-center border-border px-2.5 transition-colors [&:not(:first-child)]:border-l focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring ${
81
- selected
82
- ? 'bg-primary text-primary-foreground'
83
- : 'bg-background hover:bg-accent hover:text-accent-foreground'
84
- }`}
85
- >
86
- <Icon />
87
- <span className="sr-only">{label}</span>
88
- </PendingButton>
89
- )
90
- })}
91
- </span>
92
- </form>
93
- </section>
94
- </div>
95
- </div>
84
+ )
85
+ })}
86
+ </span>
87
+ </form>
88
+ </section>
96
89
  )
97
90
  }
@@ -0,0 +1,18 @@
1
+ 'use server'
2
+
3
+ import { cookies } from 'next/headers'
4
+
5
+ import { INSTALL_BANNER_COOKIE, INSTALL_BANNER_COOKIE_MAX_AGE } from '@/view/install-banner'
6
+
7
+ import { redirectToCurrentPath } from './redirect-back'
8
+
9
+ export async function dismissInstallBannerAction(): Promise<void> {
10
+ ;(await cookies()).set(INSTALL_BANNER_COOKIE, '1', {
11
+ path: '/',
12
+ maxAge: INSTALL_BANNER_COOKIE_MAX_AGE,
13
+ sameSite: 'lax',
14
+ httpOnly: false,
15
+ })
16
+
17
+ await redirectToCurrentPath()
18
+ }
@@ -0,0 +1,29 @@
1
+ import 'server-only'
2
+
3
+ import { cookies } from 'next/headers'
4
+
5
+ import { INSTALL_BANNER_COOKIE } from '@/view/install-banner'
6
+
7
+ import { getTranslator } from './i18n'
8
+ import { getSettings } from './settings'
9
+
10
+ export interface InstallBannerModel {
11
+ readonly message: string
12
+ readonly how: string
13
+ readonly installLabel: string
14
+ readonly dismissLabel: string
15
+ }
16
+
17
+ export async function installBanner(): Promise<InstallBannerModel | null> {
18
+ const settings = await getSettings()
19
+ if (!settings.get('board.install_banner')) return null
20
+ if ((await cookies()).get(INSTALL_BANNER_COOKIE)?.value === '1') return null
21
+
22
+ const t = await getTranslator()
23
+ return {
24
+ message: t.t('installBanner.message', { board: settings.get('board.name') }),
25
+ how: t.t('installBanner.how'),
26
+ installLabel: t.t('installBanner.install'),
27
+ dismissLabel: t.t('installBanner.dismiss'),
28
+ }
29
+ }
@@ -16,7 +16,7 @@ import { planUpgrade, type UpgradeState, upgradeNotice } from '@meith/upgrade'
16
16
 
17
17
  import { activeDefinitions } from './plugin-host'
18
18
 
19
- export const CODE_VERSION = '0.30.0'
19
+ export const CODE_VERSION = '0.31.0'
20
20
 
21
21
  export interface UpgradeApplied {
22
22
  readonly plugins: readonly string[]
@@ -180,8 +180,9 @@ export const SLOT_FIXTURES: { readonly [K in SlotName]?: SlotFixture<K> } = {
180
180
  links: [{ label: 'Contact', href: '/contact' }],
181
181
  timezoneLabel: 'Europe/London',
182
182
  poweredBy: { label: 'Powered by Meith', href: 'https://meith.dev' },
183
+ regions: { controls: region('footer-controls') },
183
184
  },
184
- requires: ['Europe/London', '/contact'],
185
+ requires: ['Europe/London', '/contact', region('footer-controls')],
185
186
  },
186
187
 
187
188
  Notice: {
@@ -0,0 +1,3 @@
1
+ export const INSTALL_BANNER_COOKIE = 'meith_install_banner'
2
+
3
+ export const INSTALL_BANNER_COOKIE_MAX_AGE = 60 * 60 * 24 * 365