@meith/web 0.33.3 → 0.34.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.
@@ -0,0 +1,154 @@
1
+ 'use client'
2
+
3
+ import { useActionState } from 'react'
4
+
5
+ import {
6
+ Alert,
7
+ AlertDescription,
8
+ AlertTitle,
9
+ Card,
10
+ CardContent,
11
+ CardDescription,
12
+ CardHeader,
13
+ CardTitle,
14
+ } from '@meith/ui'
15
+ import { Button } from '@meith/ui/button'
16
+
17
+ import { useFocusOnFail } from '@/components/auth/form-controls'
18
+ import { type Copy, formatFromCopy, fromCopy } from '@/components/shell/copy'
19
+ import { type InstallRestoreState, installRestoreAction } from '@/server/install-restore-actions'
20
+
21
+ const EMPTY: InstallRestoreState = {}
22
+
23
+ export interface RestoreCandidateView {
24
+ readonly name: string
25
+ readonly label: string
26
+ readonly size: string
27
+ readonly location: string
28
+ }
29
+
30
+ export function InstallRestoreForm({
31
+ candidates,
32
+ destination,
33
+ problem,
34
+ copy,
35
+ }: {
36
+ candidates: readonly RestoreCandidateView[]
37
+ destination: string | null
38
+ problem: string | null
39
+ copy: Copy
40
+ }) {
41
+ const [state, submit, pending] = useActionState(installRestoreAction, EMPTY)
42
+ const errorRef = useFocusOnFail<HTMLDivElement>(state.error !== undefined)
43
+
44
+ if (state.outcome !== undefined) {
45
+ const { outcome } = state
46
+ return (
47
+ <Card>
48
+ <CardHeader>
49
+ <CardTitle>{fromCopy(copy, 'install.restore.doneTitle')}</CardTitle>
50
+ <CardDescription>
51
+ {formatFromCopy(copy, 'install.restore.doneDetail', {
52
+ bundle: outcome.bundle,
53
+ version: outcome.version,
54
+ posts: outcome.posts,
55
+ })}
56
+ </CardDescription>
57
+ </CardHeader>
58
+ <CardContent className="flex flex-col gap-3 text-sm">
59
+ {outcome.migrationsApplied > 0 && (
60
+ <p>
61
+ {formatFromCopy(copy, 'install.restore.migrated', {
62
+ count: outcome.migrationsApplied,
63
+ })}
64
+ </p>
65
+ )}
66
+ {outcome.uploads === 'none' && <p>{fromCopy(copy, 'install.restore.noUploads')}</p>}
67
+ {outcome.skippedKeys > 0 && (
68
+ <p className="text-destructive">
69
+ {formatFromCopy(copy, 'install.restore.skipped', { count: outcome.skippedKeys })}
70
+ </p>
71
+ )}
72
+ <p>{fromCopy(copy, 'install.restore.next')}</p>
73
+ <p>
74
+ <a href="/login" className="underline">
75
+ {fromCopy(copy, 'install.restore.signIn')}
76
+ </a>
77
+ </p>
78
+ </CardContent>
79
+ </Card>
80
+ )
81
+ }
82
+
83
+ return (
84
+ <Card aria-labelledby="install-restore">
85
+ <CardHeader>
86
+ <CardTitle id="install-restore">{fromCopy(copy, 'install.restore.title')}</CardTitle>
87
+ <CardDescription>{fromCopy(copy, 'install.restore.hint')}</CardDescription>
88
+ </CardHeader>
89
+ <CardContent className="flex flex-col gap-4">
90
+ {problem !== null && (
91
+ <Alert tone="warning">
92
+ <AlertDescription>{problem}</AlertDescription>
93
+ </Alert>
94
+ )}
95
+ {candidates.length === 0 ? (
96
+ <p className="text-sm text-muted-foreground">
97
+ {destination === null
98
+ ? fromCopy(copy, 'install.restore.noneLocal')
99
+ : formatFromCopy(copy, 'install.restore.noneAnywhere', { destination })}
100
+ </p>
101
+ ) : (
102
+ <form action={submit} className="flex flex-col gap-4">
103
+ {state.error !== undefined && (
104
+ <Alert tone="error" ref={errorRef} tabIndex={-1}>
105
+ <AlertDescription>
106
+ <AlertTitle>{fromCopy(copy, 'install.restore.notRestored')}</AlertTitle>{' '}
107
+ {state.error}
108
+ </AlertDescription>
109
+ </Alert>
110
+ )}
111
+ <fieldset className="flex flex-col gap-2">
112
+ <legend className="text-sm font-medium">
113
+ {fromCopy(copy, 'install.restore.pick')}
114
+ </legend>
115
+ {candidates.map((candidate, index) => (
116
+ <label key={candidate.name} className="flex items-start gap-2 text-sm">
117
+ <input
118
+ type="radio"
119
+ name="bundle"
120
+ value={candidate.name}
121
+ defaultChecked={index === 0}
122
+ className="mt-1 size-4"
123
+ />
124
+ <span className="flex min-w-0 flex-col">
125
+ <span className="font-medium">{candidate.label}</span>
126
+ <span className="truncate text-xs text-muted-foreground">
127
+ <code>{candidate.name}</code> · {candidate.size} · {candidate.location}
128
+ </span>
129
+ </span>
130
+ </label>
131
+ ))}
132
+ </fieldset>
133
+ <label className="flex items-start gap-2 text-sm">
134
+ <input type="checkbox" name="confirm" value="1" className="mt-1 size-4" />
135
+ <span>{fromCopy(copy, 'install.restore.confirm')}</span>
136
+ </label>
137
+ <div className="flex flex-wrap items-center gap-3">
138
+ <Button type="submit" variant="secondary" size="lg" disabled={pending}>
139
+ {pending
140
+ ? fromCopy(copy, 'install.restore.restoring')
141
+ : fromCopy(copy, 'install.restore.submit')}
142
+ </Button>
143
+ <p className="text-xs text-muted-foreground">
144
+ {pending
145
+ ? fromCopy(copy, 'install.restore.pendingNote')
146
+ : fromCopy(copy, 'install.restore.idleNote')}
147
+ </p>
148
+ </div>
149
+ </form>
150
+ )}
151
+ </CardContent>
152
+ </Card>
153
+ )
154
+ }
@@ -11,7 +11,8 @@ import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
11
11
  const BUTTON =
12
12
  'inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
13
13
  const FIELD =
14
- 'h-8 rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
14
+ 'h-8 min-w-0 max-w-full rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
15
+ const LABEL = 'flex min-w-0 max-w-full items-center text-xs'
15
16
 
16
17
  export interface SplitPoint {
17
18
  readonly id: number
@@ -39,9 +40,9 @@ export function ThreadSurgeryForm({
39
40
  <form action={splitAction} className="flex flex-col gap-2">
40
41
  <div className="flex flex-wrap items-center gap-2">
41
42
  <input type="hidden" name="threadId" value={threadId} />
42
- <label className="flex items-center gap-2 text-xs">
43
+ <label className={LABEL}>
43
44
  <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.fromSr')}</span>
44
- <select name="fromPostId" className={FIELD}>
45
+ <select name="fromPostId" className={`${FIELD} sm:max-w-64`}>
45
46
  {splitPoints.map((point) => (
46
47
  <option key={point.id} value={point.id}>
47
48
  {formatFromCopy(copy, 'moderationForm.surgery.splitPoint', {
@@ -52,7 +53,7 @@ export function ThreadSurgeryForm({
52
53
  ))}
53
54
  </select>
54
55
  </label>
55
- <label className="flex items-center gap-2 text-xs">
56
+ <label className={LABEL}>
56
57
  <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.titleSr')}</span>
57
58
  <input
58
59
  type="text"
@@ -76,7 +77,7 @@ export function ThreadSurgeryForm({
76
77
  <form action={mergeAction} className="flex flex-col gap-2">
77
78
  <div className="flex flex-wrap items-center gap-2">
78
79
  <input type="hidden" name="threadId" value={threadId} />
79
- <label className="flex items-center gap-2 text-xs">
80
+ <label className={LABEL}>
80
81
  <span className="sr-only">{fromCopy(copy, 'moderationForm.surgery.mergeSr')}</span>
81
82
  <input
82
83
  type="number"
@@ -11,6 +11,27 @@ import { type Copy, fromCopy } from '../shell/copy'
11
11
 
12
12
  const BUTTON =
13
13
  'inline-flex h-8 items-center rounded-md border border-border px-3 text-xs font-medium hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
14
+ const SELECT =
15
+ 'h-8 min-w-0 max-w-full rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
16
+ const SUMMARY =
17
+ 'flex cursor-pointer list-none items-center gap-2 rounded-lg px-4 py-3 text-xs font-medium text-muted-foreground select-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring [&::-webkit-details-marker]:hidden'
18
+
19
+ function Chevron() {
20
+ return (
21
+ <svg
22
+ aria-hidden="true"
23
+ viewBox="0 0 12 12"
24
+ className="size-3 shrink-0 transition-transform group-open/tools:rotate-90"
25
+ fill="none"
26
+ stroke="currentColor"
27
+ strokeWidth="1.5"
28
+ strokeLinecap="round"
29
+ strokeLinejoin="round"
30
+ >
31
+ <path d="m4.5 3 3 3-3 3" />
32
+ </svg>
33
+ )
34
+ }
14
35
 
15
36
  export interface MoveOption {
16
37
  readonly id: number
@@ -25,6 +46,7 @@ export function ThreadToolsForm({
25
46
  moveTargets,
26
47
  heading,
27
48
  copy,
49
+ open = false,
28
50
  children,
29
51
  }: {
30
52
  threadId: number
@@ -34,74 +56,75 @@ export function ThreadToolsForm({
34
56
  moveTargets: readonly MoveOption[]
35
57
  heading: string
36
58
  copy: Copy
59
+ open?: boolean
37
60
  children?: React.ReactNode
38
61
  }) {
39
62
  const [state, action] = useActionState(threadToolAction, EMPTY_STATE)
40
63
 
41
64
  return (
42
- <section
43
- aria-label={heading}
44
- className="flex flex-col gap-2 rounded-lg border border-border bg-secondary px-4 py-3"
45
- >
46
- <div className="flex flex-wrap items-center gap-3">
47
- <span className="text-xs font-medium text-muted-foreground">{heading}</span>
48
-
49
- <form action={action} className="flex flex-wrap items-center gap-3">
50
- <input type="hidden" name="threadId" value={threadId} />
65
+ <section aria-label={heading} className="rounded-lg border border-border bg-secondary">
66
+ <details className="group/tools" open={open || state.error !== undefined ? true : undefined}>
67
+ <summary className={SUMMARY}>
68
+ <Chevron />
69
+ {heading}
70
+ </summary>
51
71
 
52
- {rights.lock && (
53
- <PendingButton name="tool" value={isLocked ? 'unlock' : 'lock'} className={BUTTON}>
54
- {isLocked
55
- ? fromCopy(copy, 'moderationForm.tool.unlock')
56
- : fromCopy(copy, 'moderationForm.tool.lock')}
57
- </PendingButton>
58
- )}
59
- {rights.stick && (
60
- <PendingButton name="tool" value={isSticky ? 'unstick' : 'stick'} className={BUTTON}>
61
- {isSticky
62
- ? fromCopy(copy, 'moderationForm.tool.unpin')
63
- : fromCopy(copy, 'moderationForm.tool.pin')}
64
- </PendingButton>
65
- )}
66
- {rights.delete && (
67
- <PendingButton
68
- name="tool"
69
- value="delete"
70
- className={`${BUTTON} border-destructive/40 text-destructive`}
71
- >
72
- {fromCopy(copy, 'moderationForm.tool.deleteThread')}
73
- </PendingButton>
74
- )}
72
+ <div className="flex flex-col gap-2 px-4 pb-3">
73
+ <form action={action} className="flex flex-wrap items-center gap-2">
74
+ <input type="hidden" name="threadId" value={threadId} />
75
75
 
76
- {rights.move && moveTargets.length > 0 && (
77
- <span className="flex items-center gap-2">
78
- <label className="flex items-center gap-2 text-xs">
79
- <span className="sr-only">{fromCopy(copy, 'moderationForm.moveTo')}</span>
80
- <select
81
- name="toForumId"
82
- className="h-8 rounded-md border border-border bg-background px-2 text-xs focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
83
- >
84
- {moveTargets.map((forum) => (
85
- <option key={forum.id} value={forum.id}>
86
- {forum.title}
87
- </option>
88
- ))}
89
- </select>
90
- </label>
91
- <PendingButton name="tool" value="move" className={BUTTON}>
92
- {fromCopy(copy, 'moderationForm.tool.move')}
76
+ {rights.lock && (
77
+ <PendingButton name="tool" value={isLocked ? 'unlock' : 'lock'} className={BUTTON}>
78
+ {isLocked
79
+ ? fromCopy(copy, 'moderationForm.tool.unlock')
80
+ : fromCopy(copy, 'moderationForm.tool.lock')}
81
+ </PendingButton>
82
+ )}
83
+ {rights.stick && (
84
+ <PendingButton name="tool" value={isSticky ? 'unstick' : 'stick'} className={BUTTON}>
85
+ {isSticky
86
+ ? fromCopy(copy, 'moderationForm.tool.unpin')
87
+ : fromCopy(copy, 'moderationForm.tool.pin')}
93
88
  </PendingButton>
94
- <PendingButton name="tool" value="copy" className={BUTTON}>
95
- {fromCopy(copy, 'moderationForm.tool.copy')}
89
+ )}
90
+ {rights.delete && (
91
+ <PendingButton
92
+ name="tool"
93
+ value="delete"
94
+ className={`${BUTTON} border-destructive/40 text-destructive`}
95
+ >
96
+ {fromCopy(copy, 'moderationForm.tool.deleteThread')}
96
97
  </PendingButton>
97
- </span>
98
- )}
99
- </form>
98
+ )}
99
+
100
+ {rights.move && moveTargets.length > 0 && (
101
+ <span className="flex min-w-0 max-w-full basis-full flex-wrap items-center gap-2 sm:basis-auto">
102
+ <label className="flex min-w-0 max-w-full flex-1 items-center text-xs sm:flex-none">
103
+ <span className="sr-only">{fromCopy(copy, 'moderationForm.moveTo')}</span>
104
+ <select name="toForumId" className={`${SELECT} w-full sm:w-auto sm:max-w-56`}>
105
+ {moveTargets.map((forum) => (
106
+ <option key={forum.id} value={forum.id}>
107
+ {forum.title}
108
+ </option>
109
+ ))}
110
+ </select>
111
+ </label>
112
+ <PendingButton name="tool" value="move" className={BUTTON}>
113
+ {fromCopy(copy, 'moderationForm.tool.move')}
114
+ </PendingButton>
115
+ <PendingButton name="tool" value="copy" className={BUTTON}>
116
+ {fromCopy(copy, 'moderationForm.tool.copy')}
117
+ </PendingButton>
118
+ </span>
119
+ )}
120
+ </form>
121
+
122
+ {children}
100
123
 
101
- {children}
102
- </div>
124
+ <FormError message={state.error} />
125
+ </div>
126
+ </details>
103
127
 
104
- <FormError message={state.error} />
105
128
  <ConfirmDialog confirm={state.confirm} action={action} />
106
129
  </section>
107
130
  )
@@ -0,0 +1,58 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+
5
+ const PEEKING_HEADER = '[data-header-peek]'
6
+
7
+ const SETTLE = 8
8
+
9
+ function peek(header: HTMLElement): () => void {
10
+ let last = window.scrollY
11
+ let ticking = false
12
+
13
+ function settle() {
14
+ ticking = false
15
+ const y = Math.max(0, window.scrollY)
16
+ const delta = y - last
17
+
18
+ if (y <= header.offsetHeight) {
19
+ header.removeAttribute('data-peek')
20
+ } else if (delta > SETTLE) {
21
+ header.setAttribute('data-peek', '')
22
+ } else if (delta < -SETTLE) {
23
+ header.removeAttribute('data-peek')
24
+ } else {
25
+ return
26
+ }
27
+ last = y
28
+ }
29
+
30
+ function onScroll() {
31
+ if (ticking) return
32
+ ticking = true
33
+ window.requestAnimationFrame(settle)
34
+ }
35
+
36
+ function onFocusIn() {
37
+ header.removeAttribute('data-peek')
38
+ }
39
+
40
+ window.addEventListener('scroll', onScroll, { passive: true })
41
+ header.addEventListener('focusin', onFocusIn)
42
+
43
+ return () => {
44
+ window.removeEventListener('scroll', onScroll)
45
+ header.removeEventListener('focusin', onFocusIn)
46
+ header.removeAttribute('data-peek')
47
+ }
48
+ }
49
+
50
+ export function HeaderPeekEnhancer() {
51
+ useEffect(() => {
52
+ const header = document.querySelector<HTMLElement>(PEEKING_HEADER)
53
+ if (header === null) return
54
+ return peek(header)
55
+ }, [])
56
+
57
+ return null
58
+ }
@@ -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 { HeaderPeekEnhancer } from '@/components/shell/header-peek-enhancer'
6
7
  import { InstallBanner } from '@/components/shell/install-banner'
7
8
  import { NavDisclosureEnhancer } from '@/components/shell/nav-disclosure-enhancer'
8
9
  import { NotificationMenu } from '@/components/shell/notification-menu'
@@ -154,6 +155,7 @@ export async function PageShell({ actor, children }: { actor: Actor; children: R
154
155
  </Header>
155
156
 
156
157
  <NavDisclosureEnhancer />
158
+ <HeaderPeekEnhancer />
157
159
 
158
160
  {await boardRegion('header.notice', actor)}
159
161
 
@@ -0,0 +1,115 @@
1
+ 'use server'
2
+
3
+ import { rm } from 'node:fs/promises'
4
+
5
+ import { revalidatePath } from 'next/cache'
6
+
7
+ import { isBundleName } from '@meith/backup'
8
+ import { ValidationError } from '@meith/core'
9
+ import { msg } from '@meith/i18n'
10
+
11
+ import { recordAdminAction, requireAdmin, requireFreshAdmin } from './admin'
12
+ import type { FormState } from './auth-form-state'
13
+ import {
14
+ backupsAvailable,
15
+ currentBackupSettings,
16
+ destinationFor,
17
+ localBundlePath,
18
+ requireBackupRuns,
19
+ } from './backup-admin'
20
+ import { requireConfirmation } from './confirm'
21
+ import { formStateReporter } from './form-state-reporter'
22
+ import { tr } from './i18n'
23
+
24
+ const toFormState = formStateReporter('backup-admin', 'backup action failed')
25
+
26
+ const BACKUPS_PATH = '/admin/system/backups'
27
+
28
+ function refreshBackupsScreen(): void {
29
+ revalidatePath(BACKUPS_PATH)
30
+ }
31
+
32
+ export async function requestBackupAction(): Promise<FormState> {
33
+ try {
34
+ const admin = await requireAdmin()
35
+ if (backupsAvailable() !== 'available') {
36
+ throw new ValidationError(msg('error.app.backups-not-on-this-deployment'))
37
+ }
38
+
39
+ const { queued } = await requireBackupRuns().enqueue({
40
+ trigger: 'manual',
41
+ requestedByUserId: admin.session.userId,
42
+ now: new Date(),
43
+ })
44
+
45
+ refreshBackupsScreen()
46
+ if (queued) await recordAdminAction({ action: 'backup.requested' })
47
+ return { notice: queued ? 'queued' : 'already' }
48
+ } catch (err) {
49
+ return toFormState(err)
50
+ }
51
+ }
52
+
53
+ function bundleNameFrom(form: FormData): string {
54
+ const raw = form.get('name')
55
+ const name = typeof raw === 'string' ? raw.trim() : ''
56
+ if (!isBundleName(name)) throw new ValidationError(msg('error.app.not-a-backup-bundle-name'))
57
+ return name
58
+ }
59
+
60
+ export async function deleteBackupAction(_prev: FormState, form: FormData): Promise<FormState> {
61
+ try {
62
+ await requireFreshAdmin()
63
+ const name = bundleNameFrom(form)
64
+
65
+ const confirm = requireConfirmation(form, await tr('adminBackups.confirm.delete'))
66
+ if (confirm !== null) return confirm
67
+
68
+ const local = await localBundlePath(name)
69
+ if (local !== null) await rm(local, { force: true })
70
+
71
+ let remote = false
72
+ const destination = destinationFor(await currentBackupSettings())
73
+ if (destination !== undefined) {
74
+ const listed = await destination.list()
75
+ if (listed.some((bundle) => bundle.name === name)) {
76
+ await destination.delete(name)
77
+ remote = true
78
+ }
79
+ }
80
+
81
+ if (local === null && !remote) throw new ValidationError(msg('error.app.no-such-bundle'))
82
+
83
+ refreshBackupsScreen()
84
+ await recordAdminAction({
85
+ action: 'backup.deleted',
86
+ detail: { name, local: local !== null, remote },
87
+ })
88
+ return { notice: 'deleted', values: { name } }
89
+ } catch (err) {
90
+ return toFormState(err)
91
+ }
92
+ }
93
+
94
+ export async function testBackupDestinationAction(): Promise<FormState> {
95
+ try {
96
+ await requireAdmin()
97
+ const settings = await currentBackupSettings()
98
+ if (settings.destination.problem !== null) {
99
+ throw new ValidationError(settings.destination.problem)
100
+ }
101
+ const destination = destinationFor(settings)
102
+ if (destination === undefined) {
103
+ throw new ValidationError(msg('error.app.no-off-site-destination'))
104
+ }
105
+
106
+ const bundles = await destination.list()
107
+ await recordAdminAction({
108
+ action: 'backup.destination_tested',
109
+ detail: { count: bundles.length },
110
+ })
111
+ return { notice: 'reachable', values: { count: String(bundles.length) } }
112
+ } catch (err) {
113
+ return toFormState(err)
114
+ }
115
+ }