@meith/web 0.29.1 → 0.30.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/admin/settings/page.tsx +24 -1
- package/app/brand/favicon/route.ts +18 -0
- package/app/icon.ts +16 -2
- package/app/layout.tsx +2 -0
- package/package.json +48 -48
- package/public/sw.js +2 -2
- package/src/components/admin/branding-forms.tsx +69 -1
- package/src/components/shell/install-action.tsx +86 -0
- package/src/components/shell/install-banner.tsx +29 -0
- package/src/components/shell/page-shell.tsx +18 -7
- package/src/components/shell/service-worker.tsx +12 -0
- package/src/components/shell/theme-switcher.tsx +52 -59
- package/src/server/brand-assets.ts +46 -2
- package/src/server/brand-mark.tsx +2 -2
- package/src/server/branding-actions.ts +40 -1
- package/src/server/branding.ts +33 -1
- package/src/server/install-banner-actions.ts +18 -0
- package/src/server/install-banner.ts +29 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/theme/contract.fixture.ts +2 -1
- package/src/view/admin-panel-copy.ts +16 -0
- package/src/view/install-banner.ts +3 -0
|
@@ -3,16 +3,19 @@ import { redirect } from 'next/navigation'
|
|
|
3
3
|
|
|
4
4
|
import { buttonVariants, Card, CardContent, CardFooter, cn, Input } from '@meith/ui'
|
|
5
5
|
|
|
6
|
+
import { FaviconUploadForm } from '@/components/admin/branding-forms'
|
|
6
7
|
import { MailTestCard } from '@/components/admin/mail-test-card'
|
|
7
8
|
import { AdminSettingsForm } from '@/components/admin/settings-form'
|
|
8
9
|
import { PanelPage } from '@/components/shell/panel-page'
|
|
9
10
|
import { adminPageContext } from '@/server/admin'
|
|
10
11
|
import { boardUrlResolution } from '@/server/board-url'
|
|
12
|
+
import { faviconKey, faviconSrc } from '@/server/branding'
|
|
11
13
|
import { getTranslator, tr } from '@/server/i18n'
|
|
14
|
+
import { MAX_IMAGE_BYTES } from '@/server/image-upload'
|
|
12
15
|
import { assessMailReadiness } from '@/server/mail-health'
|
|
13
16
|
import { pushReadiness } from '@/server/push'
|
|
14
17
|
import { getSettings } from '@/server/settings'
|
|
15
|
-
import { mailTestCardCopy, settingsFormCopy } from '@/view/admin-panel-copy'
|
|
18
|
+
import { faviconFormsCopy, mailTestCardCopy, settingsFormCopy } from '@/view/admin-panel-copy'
|
|
16
19
|
import { buildAdminSettingsModel, DEFAULT_SETTING_GROUP, settingsHref } from '@/view/admin-settings'
|
|
17
20
|
|
|
18
21
|
export async function generateMetadata(): Promise<Metadata> {
|
|
@@ -49,6 +52,8 @@ export default async function AdminSettingsPage({
|
|
|
49
52
|
|
|
50
53
|
const push = model.activeGroup === 'push' ? await pushReadiness() : null
|
|
51
54
|
|
|
55
|
+
const favicon = model.activeGroup === 'board' ? await faviconKey() : null
|
|
56
|
+
|
|
52
57
|
return (
|
|
53
58
|
<PanelPage title={await tr('page.board-settings')} lede={t.t('adminSettings.lede')}>
|
|
54
59
|
<Card>
|
|
@@ -184,6 +189,24 @@ export default async function AdminSettingsPage({
|
|
|
184
189
|
</section>
|
|
185
190
|
)}
|
|
186
191
|
|
|
192
|
+
{model.activeGroup === 'board' && (
|
|
193
|
+
<section className="flex flex-col gap-3">
|
|
194
|
+
<div className="flex flex-col gap-1">
|
|
195
|
+
<h2 className="font-heading text-lg font-semibold">
|
|
196
|
+
{t.t('setting.board.favicon.label')}
|
|
197
|
+
</h2>
|
|
198
|
+
<p className="text-sm text-muted-foreground">
|
|
199
|
+
{t.t('setting.board.favicon.description')}
|
|
200
|
+
</p>
|
|
201
|
+
</div>
|
|
202
|
+
<FaviconUploadForm
|
|
203
|
+
src={favicon === null ? null : faviconSrc(favicon)}
|
|
204
|
+
maxKib={MAX_IMAGE_BYTES / 1024}
|
|
205
|
+
copy={faviconFormsCopy(t)}
|
|
206
|
+
/>
|
|
207
|
+
</section>
|
|
208
|
+
)}
|
|
209
|
+
|
|
187
210
|
<AdminSettingsForm groups={model.groups} copy={settingsFormCopy(t)} />
|
|
188
211
|
</PanelPage>
|
|
189
212
|
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { drivers } from '@meith/drivers'
|
|
2
|
+
|
|
3
|
+
import { faviconKey } from '@/server/branding'
|
|
4
|
+
import { imageHeaders } from '@/server/image-upload'
|
|
5
|
+
|
|
6
|
+
export const dynamic = 'force-dynamic'
|
|
7
|
+
|
|
8
|
+
export async function GET(): Promise<Response> {
|
|
9
|
+
const key = await faviconKey()
|
|
10
|
+
if (key === null) return new Response(null, { status: 404 })
|
|
11
|
+
|
|
12
|
+
const bytes = await drivers().files.get(key)
|
|
13
|
+
if (bytes === undefined) return new Response(null, { status: 404 })
|
|
14
|
+
|
|
15
|
+
return new Response(bytes as unknown as BodyInit, {
|
|
16
|
+
headers: imageHeaders(key, bytes.byteLength),
|
|
17
|
+
})
|
|
18
|
+
}
|
package/app/icon.ts
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
buildFaviconSvg,
|
|
3
|
+
faviconDataUri,
|
|
4
|
+
loadBrandInfo,
|
|
5
|
+
loadFaviconAsset,
|
|
6
|
+
} from '@/server/brand-assets'
|
|
2
7
|
|
|
3
8
|
export const dynamic = 'force-dynamic'
|
|
4
9
|
|
|
5
10
|
export const contentType = 'image/svg+xml'
|
|
6
11
|
|
|
7
12
|
export default async function Icon(): Promise<Response> {
|
|
13
|
+
const asset = await loadFaviconAsset()
|
|
14
|
+
|
|
15
|
+
if (asset !== null && asset.type === 'image/svg+xml') {
|
|
16
|
+
return new Response(asset.bytes as unknown as BodyInit, {
|
|
17
|
+
headers: { 'Content-Type': 'image/svg+xml' },
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
8
21
|
const info = await loadBrandInfo()
|
|
22
|
+
const embedded = asset === null ? null : faviconDataUri(asset)
|
|
9
23
|
|
|
10
|
-
return new Response(buildFaviconSvg(info), {
|
|
24
|
+
return new Response(buildFaviconSvg(info, embedded), {
|
|
11
25
|
headers: { 'Content-Type': 'image/svg+xml' },
|
|
12
26
|
})
|
|
13
27
|
}
|
package/app/layout.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { CopyProvider } from '@/components/shell/copy'
|
|
|
10
10
|
import { CrashNoticeProvider } from '@/components/shell/crash-notice'
|
|
11
11
|
import { DemoBanner } from '@/components/shell/demo-banner'
|
|
12
12
|
import { GroupNameStyle } from '@/components/shell/group-name-style'
|
|
13
|
+
import { ServiceWorkerRegistrar } from '@/components/shell/service-worker'
|
|
13
14
|
import { ThemeRuntimeStyle } from '@/components/shell/theme-runtime-style'
|
|
14
15
|
import { TimezoneProbe } from '@/components/shell/timezone-probe'
|
|
15
16
|
import { crashNotice } from '@/server/error-notice'
|
|
@@ -104,6 +105,7 @@ export default async function RootLayout({
|
|
|
104
105
|
<ThemeRuntimeStyle />
|
|
105
106
|
<GroupNameStyle />
|
|
106
107
|
<TimezoneProbe />
|
|
108
|
+
<ServiceWorkerRegistrar />
|
|
107
109
|
</head>
|
|
108
110
|
<body className="font-sans antialiased">
|
|
109
111
|
<DemoBanner />
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.1",
|
|
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.
|
|
47
|
-
"@meith/admin": "0.
|
|
48
|
-
"@meith/antispam": "0.
|
|
49
|
-
"@meith/api": "0.
|
|
50
|
-
"@meith/attachments": "0.
|
|
51
|
-
"@meith/authorization": "0.
|
|
52
|
-
"@meith/avatars": "0.
|
|
53
|
-
"@meith/core": "0.
|
|
54
|
-
"@meith/db": "0.
|
|
55
|
-
"@meith/
|
|
56
|
-
"@meith/
|
|
57
|
-
"@meith/drivers": "0.
|
|
58
|
-
"@meith/events": "0.
|
|
59
|
-
"@meith/
|
|
60
|
-
"@meith/
|
|
61
|
-
"@meith/i18n": "0.
|
|
62
|
-
"@meith/import": "0.
|
|
63
|
-
"@meith/install": "0.
|
|
64
|
-
"@meith/mail": "0.
|
|
65
|
-
"@meith/markdown": "0.
|
|
66
|
-
"@meith/marketplace": "0.
|
|
67
|
-
"@meith/messages": "0.
|
|
68
|
-
"@meith/moderation": "0.
|
|
69
|
-
"@meith/notifications": "0.
|
|
70
|
-
"@meith/plugin-calendar": "0.
|
|
71
|
-
"@meith/plugin-dues": "0.
|
|
72
|
-
"@meith/plugin-kit": "0.
|
|
73
|
-
"@meith/polls": "0.
|
|
74
|
-
"@meith/posts": "0.
|
|
75
|
-
"@meith/profile-fields": "0.
|
|
76
|
-
"@meith/relations": "0.
|
|
77
|
-
"@meith/reputation": "0.
|
|
78
|
-
"@meith/runtime": "0.
|
|
79
|
-
"@meith/search": "0.
|
|
80
|
-
"@meith/settings": "0.
|
|
81
|
-
"@meith/signatures": "0.
|
|
82
|
-
"@meith/subscriptions": "0.
|
|
83
|
-
"@meith/tasks": "0.
|
|
84
|
-
"@meith/theme-clubhouse": "0.
|
|
85
|
-
"@meith/theme-default": "0.
|
|
86
|
-
"@meith/theme-kit": "0.
|
|
87
|
-
"@meith/theme-midnight": "0.
|
|
88
|
-
"@meith/theme-phasebook": "0.
|
|
89
|
-
"@meith/theme-raidframe": "0.
|
|
90
|
-
"@meith/threads": "0.
|
|
91
|
-
"@meith/ui": "0.
|
|
92
|
-
"@meith/upgrade": "0.
|
|
46
|
+
"@meith/accounts": "0.30.1",
|
|
47
|
+
"@meith/admin": "0.30.1",
|
|
48
|
+
"@meith/antispam": "0.30.1",
|
|
49
|
+
"@meith/api": "0.30.1",
|
|
50
|
+
"@meith/attachments": "0.30.1",
|
|
51
|
+
"@meith/authorization": "0.30.1",
|
|
52
|
+
"@meith/avatars": "0.30.1",
|
|
53
|
+
"@meith/core": "0.30.1",
|
|
54
|
+
"@meith/db": "0.30.1",
|
|
55
|
+
"@meith/drafts": "0.30.1",
|
|
56
|
+
"@meith/demo": "0.30.1",
|
|
57
|
+
"@meith/drivers": "0.30.1",
|
|
58
|
+
"@meith/events": "0.30.1",
|
|
59
|
+
"@meith/groups": "0.30.1",
|
|
60
|
+
"@meith/forums": "0.30.1",
|
|
61
|
+
"@meith/i18n": "0.30.1",
|
|
62
|
+
"@meith/import": "0.30.1",
|
|
63
|
+
"@meith/install": "0.30.1",
|
|
64
|
+
"@meith/mail": "0.30.1",
|
|
65
|
+
"@meith/markdown": "0.30.1",
|
|
66
|
+
"@meith/marketplace": "0.30.1",
|
|
67
|
+
"@meith/messages": "0.30.1",
|
|
68
|
+
"@meith/moderation": "0.30.1",
|
|
69
|
+
"@meith/notifications": "0.30.1",
|
|
70
|
+
"@meith/plugin-calendar": "0.30.1",
|
|
71
|
+
"@meith/plugin-dues": "0.30.1",
|
|
72
|
+
"@meith/plugin-kit": "0.30.1",
|
|
73
|
+
"@meith/polls": "0.30.1",
|
|
74
|
+
"@meith/posts": "0.30.1",
|
|
75
|
+
"@meith/profile-fields": "0.30.1",
|
|
76
|
+
"@meith/relations": "0.30.1",
|
|
77
|
+
"@meith/reputation": "0.30.1",
|
|
78
|
+
"@meith/runtime": "0.30.1",
|
|
79
|
+
"@meith/search": "0.30.1",
|
|
80
|
+
"@meith/settings": "0.30.1",
|
|
81
|
+
"@meith/signatures": "0.30.1",
|
|
82
|
+
"@meith/subscriptions": "0.30.1",
|
|
83
|
+
"@meith/tasks": "0.30.1",
|
|
84
|
+
"@meith/theme-clubhouse": "0.30.1",
|
|
85
|
+
"@meith/theme-default": "0.30.1",
|
|
86
|
+
"@meith/theme-kit": "0.30.1",
|
|
87
|
+
"@meith/theme-midnight": "0.30.1",
|
|
88
|
+
"@meith/theme-phasebook": "0.30.1",
|
|
89
|
+
"@meith/theme-raidframe": "0.30.1",
|
|
90
|
+
"@meith/threads": "0.30.1",
|
|
91
|
+
"@meith/ui": "0.30.1",
|
|
92
|
+
"@meith/upgrade": "0.30.1"
|
|
93
93
|
},
|
|
94
94
|
"scripts": {
|
|
95
95
|
"dev": "next dev",
|
package/public/sw.js
CHANGED
|
@@ -44,8 +44,8 @@ self.addEventListener('push', (event) => {
|
|
|
44
44
|
body: typeof payload.body === 'string' ? payload.body : '',
|
|
45
45
|
tag: typeof payload.id === 'number' ? `meith-${payload.id}` : 'meith',
|
|
46
46
|
data: { href: targetUrl(payload.href) },
|
|
47
|
-
icon: '/
|
|
48
|
-
badge: '/icon-
|
|
47
|
+
icon: '/brand/icon-192.png',
|
|
48
|
+
badge: '/brand/icon-192.png',
|
|
49
49
|
timestamp: Date.now(),
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -4,7 +4,12 @@ import { useActionState } from 'react'
|
|
|
4
4
|
|
|
5
5
|
import { PANEL_CARD } from '@/components/shell/panel-list'
|
|
6
6
|
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
removeFaviconAction,
|
|
9
|
+
removeLogoAction,
|
|
10
|
+
saveFaviconAction,
|
|
11
|
+
saveLogoAction,
|
|
12
|
+
} from '@/server/branding-actions'
|
|
8
13
|
|
|
9
14
|
import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
|
|
10
15
|
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
@@ -20,6 +25,69 @@ export interface LogoSlot {
|
|
|
20
25
|
const GHOST =
|
|
21
26
|
'inline-flex h-8 items-center justify-center rounded-md border border-border px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
22
27
|
|
|
28
|
+
export function FaviconUploadForm({
|
|
29
|
+
src,
|
|
30
|
+
maxKib,
|
|
31
|
+
copy,
|
|
32
|
+
}: {
|
|
33
|
+
src: string | null
|
|
34
|
+
maxKib: number
|
|
35
|
+
copy: Copy
|
|
36
|
+
}) {
|
|
37
|
+
const [saved, saveAction] = useActionState(saveFaviconAction, EMPTY_STATE)
|
|
38
|
+
const [removed, removeAction] = useActionState(removeFaviconAction, EMPTY_STATE)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className={PANEL_CARD}>
|
|
42
|
+
<FormError message={saved.error ?? removed.error} />
|
|
43
|
+
<Saved when={saved.notice === 'saved'}>{fromCopy(copy, 'adminPanel.favicon.saved')}</Saved>
|
|
44
|
+
<Saved when={removed.notice === 'removed'}>
|
|
45
|
+
{fromCopy(copy, 'adminPanel.favicon.removed')}
|
|
46
|
+
</Saved>
|
|
47
|
+
|
|
48
|
+
<div className="flex min-h-16 items-center justify-center rounded-md border border-border bg-card p-3">
|
|
49
|
+
{src === null ? (
|
|
50
|
+
<span className="text-xs text-muted-foreground">
|
|
51
|
+
{fromCopy(copy, 'adminPanel.favicon.nothing')}
|
|
52
|
+
</span>
|
|
53
|
+
) : (
|
|
54
|
+
<img src={src} alt="" className="size-8 object-contain" />
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<form action={saveAction} className="flex flex-col gap-2">
|
|
59
|
+
<label htmlFor="favicon" className="sr-only">
|
|
60
|
+
{fromCopy(copy, 'adminPanel.branding.upload')}
|
|
61
|
+
</label>
|
|
62
|
+
<input
|
|
63
|
+
id="favicon"
|
|
64
|
+
type="file"
|
|
65
|
+
name="favicon"
|
|
66
|
+
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
|
67
|
+
required
|
|
68
|
+
className="w-full text-xs file:mr-3 file:rounded-md file:border file:border-border file:bg-background file:px-3 file:py-1.5 file:text-xs file:font-medium"
|
|
69
|
+
/>
|
|
70
|
+
<p className="text-xs text-muted-foreground">
|
|
71
|
+
{formatFromCopy(copy, 'adminPanel.favicon.formats', { maxKib })}
|
|
72
|
+
</p>
|
|
73
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
74
|
+
<span className="min-w-32">
|
|
75
|
+
<SubmitButton>{fromCopy(copy, 'adminPanel.branding.upload')}</SubmitButton>
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
</form>
|
|
79
|
+
|
|
80
|
+
{src !== null && (
|
|
81
|
+
<form action={removeAction}>
|
|
82
|
+
<PendingButton showWorking className={GHOST}>
|
|
83
|
+
{fromCopy(copy, 'admin.remove')}
|
|
84
|
+
</PendingButton>
|
|
85
|
+
</form>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
23
91
|
export function LogoUploadForm({
|
|
24
92
|
slot,
|
|
25
93
|
maxKib,
|
|
@@ -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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { cspNonce } from '@/server/nonce'
|
|
2
|
+
|
|
3
|
+
const REGISTER = `(function(){try{
|
|
4
|
+
if(!('serviceWorker' in navigator))return;
|
|
5
|
+
var go=function(){navigator.serviceWorker.register('/sw.js',{scope:'/'}).catch(function(){});};
|
|
6
|
+
if(document.readyState==='complete')go();
|
|
7
|
+
else window.addEventListener('load',go,{once:true});
|
|
8
|
+
}catch(e){}})();`
|
|
9
|
+
|
|
10
|
+
export async function ServiceWorkerRegistrar() {
|
|
11
|
+
return <script nonce={await cspNonce()} dangerouslySetInnerHTML={{ __html: REGISTER }} />
|
|
12
|
+
}
|
|
@@ -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
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
{
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
81
|
+
<Icon />
|
|
82
|
+
<span className="sr-only">{label}</span>
|
|
59
83
|
</PendingButton>
|
|
60
|
-
|
|
61
|
-
)}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
}
|
|
@@ -6,7 +6,7 @@ import { colourToHex } from '@meith/theme-kit'
|
|
|
6
6
|
|
|
7
7
|
import { BOARD_TITLE } from '@/view/shell'
|
|
8
8
|
|
|
9
|
-
import { logoKey } from './branding'
|
|
9
|
+
import { faviconKey, logoKey } from './branding'
|
|
10
10
|
import { contentTypeFor } from './image-upload'
|
|
11
11
|
import { getSettings } from './settings'
|
|
12
12
|
import { getBoardThemeStyle } from './theme-runtime'
|
|
@@ -33,6 +33,8 @@ export interface BrandInfo {
|
|
|
33
33
|
|
|
34
34
|
const EMBEDDABLE_LOGO_TYPES = new Set(['image/png', 'image/jpeg'])
|
|
35
35
|
|
|
36
|
+
const EMBEDDABLE_FAVICON_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'])
|
|
37
|
+
|
|
36
38
|
const MAX_EMBEDDED_LOGO_BYTES = 192 * 1024
|
|
37
39
|
|
|
38
40
|
const DESCRIPTION_LIMIT = 160
|
|
@@ -104,6 +106,40 @@ export async function loadBrandInfo(): Promise<BrandInfo> {
|
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
|
|
109
|
+
export interface FaviconAsset {
|
|
110
|
+
readonly type: string
|
|
111
|
+
readonly bytes: Uint8Array
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function loadFaviconAsset(): Promise<FaviconAsset | null> {
|
|
115
|
+
try {
|
|
116
|
+
const key = await faviconKey()
|
|
117
|
+
if (key === null) return null
|
|
118
|
+
|
|
119
|
+
const type = contentTypeFor(key)
|
|
120
|
+
const bytes = await drivers().files.get(key)
|
|
121
|
+
if (bytes === undefined || bytes.byteLength === 0) return null
|
|
122
|
+
if (bytes.byteLength > MAX_EMBEDDED_LOGO_BYTES) return null
|
|
123
|
+
|
|
124
|
+
return { type, bytes: new Uint8Array(bytes) }
|
|
125
|
+
} catch {
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function faviconDataUri(asset: FaviconAsset): string | null {
|
|
131
|
+
if (!EMBEDDABLE_FAVICON_TYPES.has(asset.type)) return null
|
|
132
|
+
return `data:${asset.type};base64,${Buffer.from(asset.bytes).toString('base64')}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function loadMarkDataUri(): Promise<string | null> {
|
|
136
|
+
const asset = await loadFaviconAsset()
|
|
137
|
+
if (asset !== null && EMBEDDABLE_LOGO_TYPES.has(asset.type)) {
|
|
138
|
+
return `data:${asset.type};base64,${Buffer.from(asset.bytes).toString('base64')}`
|
|
139
|
+
}
|
|
140
|
+
return loadLogoDataUri('light')
|
|
141
|
+
}
|
|
142
|
+
|
|
107
143
|
export async function loadLogoDataUri(scheme: BrandScheme): Promise<string | null> {
|
|
108
144
|
try {
|
|
109
145
|
const key = (await logoKey(scheme)) ?? (await logoKey(scheme === 'light' ? 'dark' : 'light'))
|
|
@@ -131,7 +167,15 @@ function escapeXml(value: string): string {
|
|
|
131
167
|
.replaceAll("'", ''')
|
|
132
168
|
}
|
|
133
169
|
|
|
134
|
-
export function buildFaviconSvg(info: BrandInfo): string {
|
|
170
|
+
export function buildFaviconSvg(info: BrandInfo, embedded?: string | null): string {
|
|
171
|
+
if (embedded !== undefined && embedded !== null && embedded !== '') {
|
|
172
|
+
return [
|
|
173
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">`,
|
|
174
|
+
`<image href="${escapeXml(embedded)}" width="64" height="64" preserveAspectRatio="xMidYMid meet"/>`,
|
|
175
|
+
`</svg>`,
|
|
176
|
+
].join('')
|
|
177
|
+
}
|
|
178
|
+
|
|
135
179
|
const label = escapeXml(info.initials)
|
|
136
180
|
return [
|
|
137
181
|
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">`,
|
|
@@ -3,7 +3,7 @@ import 'server-only'
|
|
|
3
3
|
import { ImageResponse } from 'next/og'
|
|
4
4
|
import type { ReactElement } from 'react'
|
|
5
5
|
|
|
6
|
-
import { type BrandInfo, loadBrandInfo, loadLogoDataUri } from './brand-assets'
|
|
6
|
+
import { type BrandInfo, loadBrandInfo, loadLogoDataUri, loadMarkDataUri } from './brand-assets'
|
|
7
7
|
|
|
8
8
|
interface MarkOptions {
|
|
9
9
|
readonly size: number
|
|
@@ -94,7 +94,7 @@ function socialElement(info: BrandInfo, logo: string | null): ReactElement {
|
|
|
94
94
|
|
|
95
95
|
export async function renderBrandMark(size: number, maskable = false): Promise<ImageResponse> {
|
|
96
96
|
const info = await loadBrandInfo()
|
|
97
|
-
const logo = await
|
|
97
|
+
const logo = await loadMarkDataUri()
|
|
98
98
|
|
|
99
99
|
return new ImageResponse(markElement(info, logo, { size, maskable }), {
|
|
100
100
|
width: size,
|
|
@@ -5,7 +5,15 @@ import { msg } from '@meith/i18n'
|
|
|
5
5
|
|
|
6
6
|
import { recordAdminAction, requireAdmin } from './admin'
|
|
7
7
|
import type { FormState } from './auth-form-state'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
FAVICON_FIELD,
|
|
10
|
+
isLogoScheme,
|
|
11
|
+
LOGO_FIELD,
|
|
12
|
+
removeFavicon,
|
|
13
|
+
removeLogo,
|
|
14
|
+
saveFavicon,
|
|
15
|
+
saveLogo,
|
|
16
|
+
} from './branding'
|
|
9
17
|
import { formStateReporter } from './form-state-reporter'
|
|
10
18
|
|
|
11
19
|
const toFormState = formStateReporter('branding', 'logo write failed')
|
|
@@ -51,3 +59,34 @@ export async function removeLogoAction(_prev: FormState, form: FormData): Promis
|
|
|
51
59
|
return toFormState(err)
|
|
52
60
|
}
|
|
53
61
|
}
|
|
62
|
+
|
|
63
|
+
export async function saveFaviconAction(_prev: FormState, form: FormData): Promise<FormState> {
|
|
64
|
+
try {
|
|
65
|
+
await requireAdmin()
|
|
66
|
+
|
|
67
|
+
const file = form.get(FAVICON_FIELD)
|
|
68
|
+
if (!(file instanceof File) || file.size === 0) {
|
|
69
|
+
throw new ValidationError(msg('error.app.choose-image-first'))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await saveFavicon(file)
|
|
73
|
+
await recordAdminAction({ action: 'branding.favicon_saved', detail: { bytes: file.size } })
|
|
74
|
+
|
|
75
|
+
return { notice: 'saved' }
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return toFormState(err)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function removeFaviconAction(_prev: FormState, _form: FormData): Promise<FormState> {
|
|
82
|
+
try {
|
|
83
|
+
await requireAdmin()
|
|
84
|
+
|
|
85
|
+
await removeFavicon()
|
|
86
|
+
await recordAdminAction({ action: 'branding.favicon_removed', detail: {} })
|
|
87
|
+
|
|
88
|
+
return { notice: 'removed' }
|
|
89
|
+
} catch (err) {
|
|
90
|
+
return toFormState(err)
|
|
91
|
+
}
|
|
92
|
+
}
|
package/src/server/branding.ts
CHANGED
|
@@ -5,7 +5,14 @@ import { cache } from 'react'
|
|
|
5
5
|
import { CacheTags } from '@meith/core'
|
|
6
6
|
import { getDb, PostgresSettingsRepository } from '@meith/db'
|
|
7
7
|
import { drivers } from '@meith/drivers'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
faviconPath,
|
|
10
|
+
isFaviconKey,
|
|
11
|
+
isLogoKey,
|
|
12
|
+
type LogoScheme,
|
|
13
|
+
logoPath,
|
|
14
|
+
saveSettings,
|
|
15
|
+
} from '@meith/settings'
|
|
9
16
|
|
|
10
17
|
import { forgetImage, storeImage } from './image-upload'
|
|
11
18
|
import { getSettings } from './settings'
|
|
@@ -53,8 +60,33 @@ export async function logoKey(scheme: LogoScheme): Promise<string | null> {
|
|
|
53
60
|
return isLogoKey(key) ? key : null
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
export const FAVICON_FIELD = 'favicon'
|
|
64
|
+
|
|
65
|
+
export async function saveFavicon(file: File): Promise<void> {
|
|
66
|
+
const key = await storeImage('board', 'favicon', file)
|
|
67
|
+
|
|
68
|
+
const previous = (await getSettings()).get('board.favicon')
|
|
69
|
+
await writeSetting('board.favicon', key)
|
|
70
|
+
|
|
71
|
+
if (previous !== key) await forgetImage(previous)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function removeFavicon(): Promise<void> {
|
|
75
|
+
const previous = (await getSettings()).get('board.favicon')
|
|
76
|
+
await writeSetting('board.favicon', '')
|
|
77
|
+
|
|
78
|
+
await forgetImage(previous)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function faviconKey(): Promise<string | null> {
|
|
82
|
+
const key = (await getSettings()).get('board.favicon')
|
|
83
|
+
return isFaviconKey(key) ? key : null
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
export const logoSrc = logoPath
|
|
57
87
|
|
|
88
|
+
export const faviconSrc = faviconPath
|
|
89
|
+
|
|
58
90
|
export interface BoardLogo {
|
|
59
91
|
readonly src: string
|
|
60
92
|
readonly darkSrc: string | null
|
|
@@ -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.
|
|
19
|
+
export const CODE_VERSION = '0.30.1'
|
|
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: {
|
|
@@ -180,6 +180,22 @@ export function brandingFormsCopy(
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
export function faviconFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
|
|
184
|
+
return {
|
|
185
|
+
...adminSharedCopy(t),
|
|
186
|
+
...copyFor(
|
|
187
|
+
[
|
|
188
|
+
'adminPanel.favicon.saved',
|
|
189
|
+
'adminPanel.favicon.removed',
|
|
190
|
+
'adminPanel.favicon.nothing',
|
|
191
|
+
'adminPanel.branding.upload',
|
|
192
|
+
],
|
|
193
|
+
t,
|
|
194
|
+
),
|
|
195
|
+
...patternCopy(['adminPanel.favicon.formats'], t),
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
183
199
|
export function badgeFormsCopy(t: Translator = untranslated()): Readonly<Record<string, string>> {
|
|
184
200
|
return {
|
|
185
201
|
...adminSharedCopy(t),
|