@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.
- package/app/(board)/thread/[slug]/page.tsx +1 -0
- package/app/admin/settings/page.tsx +9 -0
- package/app/admin/system/backups/[name]/route.ts +11 -0
- package/app/admin/system/backups/page.tsx +298 -0
- package/app/admin/system/page.tsx +4 -1
- package/app/install/page.tsx +29 -1
- package/next.config.mjs +1 -0
- package/package.json +50 -49
- package/src/components/admin/backup-forms.tsx +87 -0
- package/src/components/install/restore-form.tsx +154 -0
- package/src/components/moderation/thread-surgery-form.tsx +6 -5
- package/src/components/moderation/thread-tools-form.tsx +80 -57
- package/src/components/shell/header-peek-enhancer.tsx +58 -0
- package/src/components/shell/page-shell.tsx +2 -0
- package/src/server/backup-admin-actions.ts +115 -0
- package/src/server/backup-admin.ts +157 -0
- package/src/server/backup-download.ts +66 -0
- package/src/server/install-restore-actions.ts +40 -0
- package/src/server/install-restore.ts +186 -0
- package/src/server/upgrade-notice.ts +1 -1
- package/src/styles/globals.css +3 -3
- package/src/view/admin-nav.ts +1 -0
- package/src/view/admin-panel-copy.ts +16 -0
- package/src/view/install-copy.ts +35 -0
- package/src/view/navigation.ts +16 -7
- package/src/view/setting-groups.ts +2 -0
|
@@ -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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
+
}
|