@meith/web 0.33.4 → 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/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/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/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/setting-groups.ts +2 -0
|
@@ -8,6 +8,7 @@ import { MailTestCard } from '@/components/admin/mail-test-card'
|
|
|
8
8
|
import { AdminSettingsForm } from '@/components/admin/settings-form'
|
|
9
9
|
import { PanelPage } from '@/components/shell/panel-page'
|
|
10
10
|
import { adminPageContext } from '@/server/admin'
|
|
11
|
+
import { destinationIsFromEnvironment } from '@/server/backup-admin'
|
|
11
12
|
import { boardUrlResolution } from '@/server/board-url'
|
|
12
13
|
import { faviconKey, faviconSrc } from '@/server/branding'
|
|
13
14
|
import { getTranslator, tr } from '@/server/i18n'
|
|
@@ -54,6 +55,8 @@ export default async function AdminSettingsPage({
|
|
|
54
55
|
|
|
55
56
|
const favicon = model.activeGroup === 'board' ? await faviconKey() : null
|
|
56
57
|
|
|
58
|
+
const backupFromEnvironment = model.activeGroup === 'backup' && destinationIsFromEnvironment()
|
|
59
|
+
|
|
57
60
|
return (
|
|
58
61
|
<PanelPage title={await tr('page.board-settings')} lede={t.t('adminSettings.lede')}>
|
|
59
62
|
<Card>
|
|
@@ -189,6 +192,12 @@ export default async function AdminSettingsPage({
|
|
|
189
192
|
</section>
|
|
190
193
|
)}
|
|
191
194
|
|
|
195
|
+
{backupFromEnvironment && (
|
|
196
|
+
<section className="rounded-lg border border-border bg-muted/40 p-4 text-sm">
|
|
197
|
+
<p>{t.t('adminSettings.backupFromEnvironment')}</p>
|
|
198
|
+
</section>
|
|
199
|
+
)}
|
|
200
|
+
|
|
192
201
|
{model.activeGroup === 'board' && (
|
|
193
202
|
<section className="flex flex-col gap-3">
|
|
194
203
|
<div className="flex flex-col gap-1">
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { serveBackupDownload } from '@/server/backup-download'
|
|
2
|
+
|
|
3
|
+
export const dynamic = 'force-dynamic'
|
|
4
|
+
|
|
5
|
+
export async function GET(
|
|
6
|
+
_request: Request,
|
|
7
|
+
context: { params: Promise<{ name: string }> },
|
|
8
|
+
): Promise<Response> {
|
|
9
|
+
const { name } = await context.params
|
|
10
|
+
return serveBackupDownload(name)
|
|
11
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
|
|
3
|
+
import type { BackupRunRecord } from '@meith/backup'
|
|
4
|
+
import { formatScheduleTime } from '@meith/backup'
|
|
5
|
+
import { cn } from '@meith/ui'
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
DeleteBackupForm,
|
|
9
|
+
RequestBackupForm,
|
|
10
|
+
TestDestinationForm,
|
|
11
|
+
} from '@/components/admin/backup-forms'
|
|
12
|
+
import { PANEL_CARD } from '@/components/shell/panel-list'
|
|
13
|
+
import { PanelPage } from '@/components/shell/panel-page'
|
|
14
|
+
import { adminPageContext } from '@/server/admin'
|
|
15
|
+
import { buildBackupAdminView } from '@/server/backup-admin'
|
|
16
|
+
import { getTranslator, tr } from '@/server/i18n'
|
|
17
|
+
import { backupFormsCopy } from '@/view/admin-panel-copy'
|
|
18
|
+
import { settingsHref } from '@/view/admin-settings'
|
|
19
|
+
import { formatBytes } from '@/view/attachments'
|
|
20
|
+
import { formatTime } from '@/view/time'
|
|
21
|
+
|
|
22
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
23
|
+
return { title: await tr('page.backups') }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const RUN_STATUS_KEYS = {
|
|
27
|
+
queued: 'adminBackups.run.queued',
|
|
28
|
+
running: 'adminBackups.run.running',
|
|
29
|
+
done: 'adminBackups.run.done',
|
|
30
|
+
incomplete: 'adminBackups.run.incomplete',
|
|
31
|
+
failed: 'adminBackups.run.failed',
|
|
32
|
+
} as const satisfies Record<BackupRunRecord['status'], string>
|
|
33
|
+
|
|
34
|
+
const RUN_TRIGGER_KEYS = {
|
|
35
|
+
manual: 'adminBackups.trigger.manual',
|
|
36
|
+
schedule: 'adminBackups.trigger.schedule',
|
|
37
|
+
upgrade: 'adminBackups.trigger.upgrade',
|
|
38
|
+
cli: 'adminBackups.trigger.cli',
|
|
39
|
+
} as const satisfies Record<BackupRunRecord['trigger'], string>
|
|
40
|
+
|
|
41
|
+
const WEEKDAY_KEYS: Readonly<Record<number, string>> = {
|
|
42
|
+
0: 'setting.backup.weekday.option.0',
|
|
43
|
+
1: 'setting.backup.weekday.option.1',
|
|
44
|
+
2: 'setting.backup.weekday.option.2',
|
|
45
|
+
3: 'setting.backup.weekday.option.3',
|
|
46
|
+
4: 'setting.backup.weekday.option.4',
|
|
47
|
+
5: 'setting.backup.weekday.option.5',
|
|
48
|
+
6: 'setting.backup.weekday.option.6',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const DOWNLOAD_LINK =
|
|
52
|
+
'inline-flex h-8 items-center justify-center rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
53
|
+
|
|
54
|
+
export default async function AdminBackupsPage({
|
|
55
|
+
searchParams,
|
|
56
|
+
}: {
|
|
57
|
+
searchParams: Promise<{ notice?: string }>
|
|
58
|
+
}) {
|
|
59
|
+
if ((await adminPageContext()) === null) return null
|
|
60
|
+
|
|
61
|
+
const now = new Date()
|
|
62
|
+
const [translator, view, query] = await Promise.all([
|
|
63
|
+
getTranslator(),
|
|
64
|
+
buildBackupAdminView(now),
|
|
65
|
+
searchParams,
|
|
66
|
+
])
|
|
67
|
+
const copy = backupFormsCopy(translator)
|
|
68
|
+
const t = translator.t.bind(translator)
|
|
69
|
+
|
|
70
|
+
if (view === null) {
|
|
71
|
+
return (
|
|
72
|
+
<PanelPage
|
|
73
|
+
title={await tr('page.backups')}
|
|
74
|
+
back={{ href: '/admin/system', label: t('adminBackups.backToSystem') }}
|
|
75
|
+
>
|
|
76
|
+
<p className="mt-2 text-sm text-muted-foreground">{t('adminBackups.sample')}</p>
|
|
77
|
+
</PanelPage>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { settings } = view
|
|
82
|
+
const available = view.capability === 'available'
|
|
83
|
+
const time = formatScheduleTime(settings.schedule)
|
|
84
|
+
const scheduleSummary =
|
|
85
|
+
settings.schedule.frequency === 'off'
|
|
86
|
+
? t('adminBackups.schedule.off')
|
|
87
|
+
: settings.schedule.frequency === 'daily'
|
|
88
|
+
? t('adminBackups.schedule.daily', { time })
|
|
89
|
+
: t('adminBackups.schedule.weekly', {
|
|
90
|
+
time,
|
|
91
|
+
weekday: t(WEEKDAY_KEYS[settings.schedule.weekday] ?? WEEKDAY_KEYS[1] ?? ''),
|
|
92
|
+
})
|
|
93
|
+
const retentionSummary =
|
|
94
|
+
(settings.retention.keepDays ?? 0) > 0
|
|
95
|
+
? t('adminBackups.retention.countAndDays', {
|
|
96
|
+
count: settings.retention.keep,
|
|
97
|
+
days: settings.retention.keepDays ?? 0,
|
|
98
|
+
})
|
|
99
|
+
: t('adminBackups.retention.count', { count: settings.retention.keep })
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<PanelPage
|
|
103
|
+
title={await tr('page.backups')}
|
|
104
|
+
back={{ href: '/admin/system', label: t('adminBackups.backToSystem') }}
|
|
105
|
+
lede={t('adminBackups.lede')}
|
|
106
|
+
gap="loose"
|
|
107
|
+
>
|
|
108
|
+
{query.notice === 'reauth' && (
|
|
109
|
+
<section role="alert" className="rounded-lg border border-border bg-muted/40 p-4 text-sm">
|
|
110
|
+
{t('adminBackups.reauth')}
|
|
111
|
+
</section>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{view.capability === 'serverless' && (
|
|
115
|
+
<section
|
|
116
|
+
role="alert"
|
|
117
|
+
className="flex flex-col gap-2 rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm"
|
|
118
|
+
>
|
|
119
|
+
<p>{t('adminBackups.serverless')}</p>
|
|
120
|
+
</section>
|
|
121
|
+
)}
|
|
122
|
+
|
|
123
|
+
<section className={cn(PANEL_CARD, 'gap-4')}>
|
|
124
|
+
<h2 className="font-heading text-lg font-semibold">{t('adminBackups.now.title')}</h2>
|
|
125
|
+
<p className="text-sm text-muted-foreground">{t('adminBackups.now.hint')}</p>
|
|
126
|
+
{view.active !== null && (
|
|
127
|
+
<p className="text-sm">
|
|
128
|
+
{view.active.status === 'running'
|
|
129
|
+
? t('adminBackups.now.running', {
|
|
130
|
+
since: formatTime(
|
|
131
|
+
view.active.startedAt ?? view.active.requestedAt,
|
|
132
|
+
now,
|
|
133
|
+
translator,
|
|
134
|
+
).label,
|
|
135
|
+
})
|
|
136
|
+
: t('adminBackups.now.queued')}
|
|
137
|
+
</p>
|
|
138
|
+
)}
|
|
139
|
+
<RequestBackupForm disabled={!available || view.active !== null} copy={copy} />
|
|
140
|
+
</section>
|
|
141
|
+
|
|
142
|
+
<section className={PANEL_CARD}>
|
|
143
|
+
<h2 className="font-heading text-lg font-semibold">{t('adminBackups.plan.title')}</h2>
|
|
144
|
+
<ul className="flex flex-col gap-1 text-sm">
|
|
145
|
+
<li>
|
|
146
|
+
<span className="text-muted-foreground">{t('adminBackups.plan.schedule')}</span>{' '}
|
|
147
|
+
{scheduleSummary}
|
|
148
|
+
{view.nextScheduled !== null && available && (
|
|
149
|
+
<span className="text-muted-foreground">
|
|
150
|
+
{' · '}
|
|
151
|
+
{t('adminBackups.plan.next', {
|
|
152
|
+
time: formatTime(view.nextScheduled, now, translator).label,
|
|
153
|
+
})}
|
|
154
|
+
</span>
|
|
155
|
+
)}
|
|
156
|
+
</li>
|
|
157
|
+
<li>
|
|
158
|
+
<span className="text-muted-foreground">{t('adminBackups.plan.retention')}</span>{' '}
|
|
159
|
+
{retentionSummary}
|
|
160
|
+
</li>
|
|
161
|
+
<li>
|
|
162
|
+
<span className="text-muted-foreground">{t('adminBackups.plan.uploads')}</span>{' '}
|
|
163
|
+
{settings.uploads === 'include'
|
|
164
|
+
? t('adminBackups.plan.uploadsIncluded')
|
|
165
|
+
: t('adminBackups.plan.uploadsSkipped')}
|
|
166
|
+
</li>
|
|
167
|
+
<li>
|
|
168
|
+
<span className="text-muted-foreground">{t('adminBackups.plan.beforeUpgrade')}</span>{' '}
|
|
169
|
+
{settings.beforeUpgrade ? t('adminBackups.plan.on') : t('adminBackups.plan.off')}
|
|
170
|
+
</li>
|
|
171
|
+
<li>
|
|
172
|
+
<span className="text-muted-foreground">{t('adminBackups.plan.ring')}</span>{' '}
|
|
173
|
+
<code className="text-xs">{view.ring}</code>
|
|
174
|
+
</li>
|
|
175
|
+
</ul>
|
|
176
|
+
<p className="text-sm text-muted-foreground">
|
|
177
|
+
<a href={settingsHref({ group: 'backup' })} className="underline">
|
|
178
|
+
{t('adminBackups.plan.change')}
|
|
179
|
+
</a>
|
|
180
|
+
</p>
|
|
181
|
+
</section>
|
|
182
|
+
|
|
183
|
+
<section className={PANEL_CARD}>
|
|
184
|
+
<h2 className="font-heading text-lg font-semibold">
|
|
185
|
+
{t('adminBackups.destination.title')}
|
|
186
|
+
</h2>
|
|
187
|
+
{view.destination.source === 'none' ? (
|
|
188
|
+
<p className="text-sm text-muted-foreground">{t('adminBackups.destination.none')}</p>
|
|
189
|
+
) : (
|
|
190
|
+
<p className="text-sm">
|
|
191
|
+
{view.destination.description === null
|
|
192
|
+
? t('adminBackups.destination.unusable')
|
|
193
|
+
: t('adminBackups.destination.shipsTo', {
|
|
194
|
+
destination: view.destination.description,
|
|
195
|
+
})}{' '}
|
|
196
|
+
<span className="text-muted-foreground">
|
|
197
|
+
{view.destination.source === 'environment'
|
|
198
|
+
? t('adminBackups.destination.fromEnvironment')
|
|
199
|
+
: t('adminBackups.destination.fromBoard')}
|
|
200
|
+
</span>
|
|
201
|
+
</p>
|
|
202
|
+
)}
|
|
203
|
+
{view.destination.problem !== null && (
|
|
204
|
+
<p className="text-sm text-destructive">{view.destination.problem}</p>
|
|
205
|
+
)}
|
|
206
|
+
{view.destination.listError !== null && (
|
|
207
|
+
<p className="text-sm text-destructive">
|
|
208
|
+
{t('adminBackups.destination.listFailed', { error: view.destination.listError })}
|
|
209
|
+
</p>
|
|
210
|
+
)}
|
|
211
|
+
{view.destination.description !== null && <TestDestinationForm copy={copy} />}
|
|
212
|
+
</section>
|
|
213
|
+
|
|
214
|
+
<section className={PANEL_CARD}>
|
|
215
|
+
<h2 className="font-heading text-lg font-semibold">{t('adminBackups.bundles.title')}</h2>
|
|
216
|
+
{view.bundles.length === 0 ? (
|
|
217
|
+
<p className="text-sm text-muted-foreground">{t('adminBackups.bundles.empty')}</p>
|
|
218
|
+
) : (
|
|
219
|
+
<ul className="flex flex-col divide-y divide-border">
|
|
220
|
+
{view.bundles.map((bundle) => (
|
|
221
|
+
<li
|
|
222
|
+
key={bundle.name}
|
|
223
|
+
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 py-3 first:pt-0 last:pb-0"
|
|
224
|
+
>
|
|
225
|
+
<span className="flex min-w-0 flex-col">
|
|
226
|
+
<span className="text-sm font-medium">
|
|
227
|
+
{bundle.takenAt === null
|
|
228
|
+
? bundle.name
|
|
229
|
+
: formatTime(bundle.takenAt, now, translator).label}
|
|
230
|
+
</span>
|
|
231
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
232
|
+
<code>{bundle.name}</code>
|
|
233
|
+
{bundle.localSize !== null &&
|
|
234
|
+
` · ${t('adminBackups.bundles.local', { size: formatBytes(bundle.localSize) })}`}
|
|
235
|
+
{bundle.remoteSize !== null &&
|
|
236
|
+
` · ${t('adminBackups.bundles.offSite', { size: formatBytes(bundle.remoteSize) })}`}
|
|
237
|
+
</span>
|
|
238
|
+
</span>
|
|
239
|
+
<span className="flex shrink-0 flex-wrap items-center gap-2">
|
|
240
|
+
<a
|
|
241
|
+
href={`/admin/system/backups/${encodeURIComponent(bundle.name)}`}
|
|
242
|
+
className={DOWNLOAD_LINK}
|
|
243
|
+
>
|
|
244
|
+
{t('adminBackups.bundles.download')}
|
|
245
|
+
</a>
|
|
246
|
+
<DeleteBackupForm name={bundle.name} copy={copy} />
|
|
247
|
+
</span>
|
|
248
|
+
</li>
|
|
249
|
+
))}
|
|
250
|
+
</ul>
|
|
251
|
+
)}
|
|
252
|
+
<p className="text-xs text-muted-foreground">{t('adminBackups.bundles.restoreHint')}</p>
|
|
253
|
+
</section>
|
|
254
|
+
|
|
255
|
+
<section className={PANEL_CARD}>
|
|
256
|
+
<h2 className="font-heading text-lg font-semibold">{t('adminBackups.runs.title')}</h2>
|
|
257
|
+
{view.runs.length === 0 ? (
|
|
258
|
+
<p className="text-sm text-muted-foreground">{t('adminBackups.runs.empty')}</p>
|
|
259
|
+
) : (
|
|
260
|
+
<ul className="flex flex-col gap-1 text-sm">
|
|
261
|
+
{view.runs.map((run) => (
|
|
262
|
+
<li key={run.id}>
|
|
263
|
+
<span
|
|
264
|
+
className={
|
|
265
|
+
run.status === 'failed' || run.status === 'incomplete'
|
|
266
|
+
? 'font-medium text-destructive'
|
|
267
|
+
: 'font-medium'
|
|
268
|
+
}
|
|
269
|
+
>
|
|
270
|
+
{t(RUN_STATUS_KEYS[run.status])}
|
|
271
|
+
</span>{' '}
|
|
272
|
+
<span className="text-muted-foreground">
|
|
273
|
+
{t(RUN_TRIGGER_KEYS[run.trigger])} ·{' '}
|
|
274
|
+
<time dateTime={run.requestedAt.toISOString()}>
|
|
275
|
+
{formatTime(run.requestedAt, now, translator).label}
|
|
276
|
+
</time>
|
|
277
|
+
{run.bundleName !== null && (
|
|
278
|
+
<>
|
|
279
|
+
{' · '}
|
|
280
|
+
<code className="text-xs">{run.bundleName}</code>
|
|
281
|
+
</>
|
|
282
|
+
)}
|
|
283
|
+
{run.sizeBytes !== null && ` · ${formatBytes(run.sizeBytes)}`}
|
|
284
|
+
{run.shipped && ` · ${t('adminBackups.runs.shipped')}`}
|
|
285
|
+
{run.skippedKeys > 0 &&
|
|
286
|
+
` · ${t('adminBackups.runs.skipped', { count: run.skippedKeys })}`}
|
|
287
|
+
</span>
|
|
288
|
+
{run.error !== null && (
|
|
289
|
+
<span className="block text-xs text-destructive">{run.error}</span>
|
|
290
|
+
)}
|
|
291
|
+
</li>
|
|
292
|
+
))}
|
|
293
|
+
</ul>
|
|
294
|
+
)}
|
|
295
|
+
</section>
|
|
296
|
+
</PanelPage>
|
|
297
|
+
)
|
|
298
|
+
}
|
|
@@ -23,6 +23,7 @@ import { formatTime } from '@/view/time'
|
|
|
23
23
|
|
|
24
24
|
const TASK_STATUS_KEYS = {
|
|
25
25
|
healthy: 'adminSystem.taskStatus.healthy',
|
|
26
|
+
running: 'adminSystem.taskStatus.running',
|
|
26
27
|
late: 'adminSystem.taskStatus.late',
|
|
27
28
|
stale: 'adminSystem.taskStatus.stale',
|
|
28
29
|
failing: 'adminSystem.taskStatus.failing',
|
|
@@ -194,7 +195,9 @@ export default async function AdminSystemPage() {
|
|
|
194
195
|
</span>
|
|
195
196
|
<span
|
|
196
197
|
className={
|
|
197
|
-
task.status === 'healthy' ||
|
|
198
|
+
task.status === 'healthy' ||
|
|
199
|
+
task.status === 'running' ||
|
|
200
|
+
task.status === 'disabled'
|
|
198
201
|
? 'shrink-0 text-xs text-muted-foreground'
|
|
199
202
|
: 'shrink-0 text-xs font-medium text-destructive'
|
|
200
203
|
}
|
package/app/install/page.tsx
CHANGED
|
@@ -16,9 +16,13 @@ import { isUsableOrigin, MAIL_PRESETS, normaliseOrigin } from '@meith/settings'
|
|
|
16
16
|
import { Alert, AlertDescription, AlertTitle, Disclosure } from '@meith/ui'
|
|
17
17
|
|
|
18
18
|
import { InstallForm } from '@/components/install/install-form'
|
|
19
|
+
import { InstallRestoreForm } from '@/components/install/restore-form'
|
|
19
20
|
import { getTranslator, tr } from '@/server/i18n'
|
|
20
21
|
import { gatherPreflight, installerIsSealed, probeMail } from '@/server/install'
|
|
21
|
-
import {
|
|
22
|
+
import { installRestoreView } from '@/server/install-restore'
|
|
23
|
+
import { formatBytes } from '@/view/attachments'
|
|
24
|
+
import { installFormCopy, installRestoreCopy } from '@/view/install-copy'
|
|
25
|
+
import { formatTime } from '@/view/time'
|
|
22
26
|
|
|
23
27
|
export async function generateMetadata(): Promise<Metadata> {
|
|
24
28
|
return { title: await tr('page.install') }
|
|
@@ -75,6 +79,8 @@ export default async function InstallPage() {
|
|
|
75
79
|
const ready = canProceed(checks)
|
|
76
80
|
const mail = await probeMail()
|
|
77
81
|
const suggestedBoardUrl = await suggestBoardUrl()
|
|
82
|
+
const restore = ready ? await installRestoreView() : null
|
|
83
|
+
const now = new Date()
|
|
78
84
|
|
|
79
85
|
return (
|
|
80
86
|
<main className="mx-auto flex w-full max-w-2xl flex-col gap-8 px-6 py-12">
|
|
@@ -87,6 +93,28 @@ export default async function InstallPage() {
|
|
|
87
93
|
|
|
88
94
|
<Preflight checks={checks} />
|
|
89
95
|
|
|
96
|
+
{restore?.possible && (
|
|
97
|
+
<InstallRestoreForm
|
|
98
|
+
candidates={restore.candidates.map((candidate) => ({
|
|
99
|
+
name: candidate.name,
|
|
100
|
+
label:
|
|
101
|
+
candidate.takenAt === null
|
|
102
|
+
? candidate.name
|
|
103
|
+
: t.t('install.restore.takenAt', {
|
|
104
|
+
time: formatTime(candidate.takenAt, now, t).label,
|
|
105
|
+
}),
|
|
106
|
+
size: formatBytes(candidate.size),
|
|
107
|
+
location:
|
|
108
|
+
candidate.location === 'server'
|
|
109
|
+
? t.t('install.restore.onServer')
|
|
110
|
+
: t.t('install.restore.offSite'),
|
|
111
|
+
}))}
|
|
112
|
+
destination={restore.destination}
|
|
113
|
+
problem={restore.problem}
|
|
114
|
+
copy={installRestoreCopy(t)}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
|
|
90
118
|
{ready && (
|
|
91
119
|
<InstallForm
|
|
92
120
|
presets={MAIL_PRESETS.map((preset) => ({
|
package/next.config.mjs
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meith/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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,54 +43,55 @@
|
|
|
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/
|
|
54
|
-
"@meith/
|
|
55
|
-
"@meith/
|
|
56
|
-
"@meith/
|
|
57
|
-
"@meith/
|
|
58
|
-
"@meith/drafts": "0.
|
|
59
|
-
"@meith/
|
|
60
|
-
"@meith/
|
|
61
|
-
"@meith/
|
|
62
|
-
"@meith/
|
|
63
|
-
"@meith/
|
|
64
|
-
"@meith/
|
|
65
|
-
"@meith/
|
|
66
|
-
"@meith/
|
|
67
|
-
"@meith/
|
|
68
|
-
"@meith/
|
|
69
|
-
"@meith/
|
|
70
|
-
"@meith/
|
|
71
|
-
"@meith/
|
|
72
|
-
"@meith/plugin-
|
|
73
|
-
"@meith/plugin-
|
|
74
|
-
"@meith/
|
|
75
|
-
"@meith/
|
|
76
|
-
"@meith/
|
|
77
|
-
"@meith/
|
|
78
|
-
"@meith/
|
|
79
|
-
"@meith/
|
|
80
|
-
"@meith/
|
|
81
|
-
"@meith/
|
|
82
|
-
"@meith/
|
|
83
|
-
"@meith/
|
|
84
|
-
"@meith/
|
|
85
|
-
"@meith/
|
|
86
|
-
"@meith/theme-
|
|
87
|
-
"@meith/theme-
|
|
88
|
-
"@meith/theme-
|
|
89
|
-
"@meith/theme-
|
|
90
|
-
"@meith/theme-
|
|
91
|
-
"@meith/
|
|
92
|
-
"@meith/
|
|
93
|
-
"@meith/
|
|
46
|
+
"@meith/accounts": "0.34.0",
|
|
47
|
+
"@meith/admin": "0.34.0",
|
|
48
|
+
"@meith/antispam": "0.34.0",
|
|
49
|
+
"@meith/api": "0.34.0",
|
|
50
|
+
"@meith/attachments": "0.34.0",
|
|
51
|
+
"@meith/authorization": "0.34.0",
|
|
52
|
+
"@meith/avatars": "0.34.0",
|
|
53
|
+
"@meith/backup": "0.34.0",
|
|
54
|
+
"@meith/board-digest": "0.34.0",
|
|
55
|
+
"@meith/core": "0.34.0",
|
|
56
|
+
"@meith/db": "0.34.0",
|
|
57
|
+
"@meith/demo": "0.34.0",
|
|
58
|
+
"@meith/drafts": "0.34.0",
|
|
59
|
+
"@meith/drivers": "0.34.0",
|
|
60
|
+
"@meith/events": "0.34.0",
|
|
61
|
+
"@meith/forums": "0.34.0",
|
|
62
|
+
"@meith/groups": "0.34.0",
|
|
63
|
+
"@meith/i18n": "0.34.0",
|
|
64
|
+
"@meith/import": "0.34.0",
|
|
65
|
+
"@meith/install": "0.34.0",
|
|
66
|
+
"@meith/mail": "0.34.0",
|
|
67
|
+
"@meith/markdown": "0.34.0",
|
|
68
|
+
"@meith/marketplace": "0.34.0",
|
|
69
|
+
"@meith/messages": "0.34.0",
|
|
70
|
+
"@meith/moderation": "0.34.0",
|
|
71
|
+
"@meith/notifications": "0.34.0",
|
|
72
|
+
"@meith/plugin-calendar": "0.34.0",
|
|
73
|
+
"@meith/plugin-dues": "0.34.0",
|
|
74
|
+
"@meith/plugin-kit": "0.34.0",
|
|
75
|
+
"@meith/polls": "0.34.0",
|
|
76
|
+
"@meith/posts": "0.34.0",
|
|
77
|
+
"@meith/profile-fields": "0.34.0",
|
|
78
|
+
"@meith/relations": "0.34.0",
|
|
79
|
+
"@meith/reputation": "0.34.0",
|
|
80
|
+
"@meith/runtime": "0.34.0",
|
|
81
|
+
"@meith/search": "0.34.0",
|
|
82
|
+
"@meith/settings": "0.34.0",
|
|
83
|
+
"@meith/signatures": "0.34.0",
|
|
84
|
+
"@meith/subscriptions": "0.34.0",
|
|
85
|
+
"@meith/tasks": "0.34.0",
|
|
86
|
+
"@meith/theme-clubhouse": "0.34.0",
|
|
87
|
+
"@meith/theme-default": "0.34.0",
|
|
88
|
+
"@meith/theme-kit": "0.34.0",
|
|
89
|
+
"@meith/theme-midnight": "0.34.0",
|
|
90
|
+
"@meith/theme-phasebook": "0.34.0",
|
|
91
|
+
"@meith/theme-raidframe": "0.34.0",
|
|
92
|
+
"@meith/threads": "0.34.0",
|
|
93
|
+
"@meith/ui": "0.34.0",
|
|
94
|
+
"@meith/upgrade": "0.34.0"
|
|
94
95
|
},
|
|
95
96
|
"scripts": {
|
|
96
97
|
"dev": "next dev",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useActionState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { EMPTY_STATE } from '@/server/auth-form-state'
|
|
6
|
+
import {
|
|
7
|
+
deleteBackupAction,
|
|
8
|
+
requestBackupAction,
|
|
9
|
+
testBackupDestinationAction,
|
|
10
|
+
} from '@/server/backup-admin-actions'
|
|
11
|
+
|
|
12
|
+
import { FormError, PendingButton, SubmitButton } from '../auth/form-controls'
|
|
13
|
+
import { ConfirmDialog } from '../shell/confirm-dialog'
|
|
14
|
+
import { type Copy, formatFromCopy, fromCopy } from '../shell/copy'
|
|
15
|
+
import { Saved } from './form-bits'
|
|
16
|
+
|
|
17
|
+
const ROW_BUTTON =
|
|
18
|
+
'inline-flex h-8 items-center justify-center rounded-md border border-destructive/30 bg-destructive/10 px-2.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
|
|
19
|
+
|
|
20
|
+
export function RequestBackupForm({ disabled, copy }: { disabled: boolean; copy: Copy }) {
|
|
21
|
+
const [state, action] = useActionState(requestBackupAction, EMPTY_STATE)
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<form action={action} className="flex flex-col gap-2">
|
|
25
|
+
<FormError message={state.error} />
|
|
26
|
+
{state.notice === 'queued' && <Saved>{fromCopy(copy, 'adminPanel.backup.queued')}</Saved>}
|
|
27
|
+
{state.notice === 'already' && (
|
|
28
|
+
<p className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
|
|
29
|
+
{fromCopy(copy, 'adminPanel.backup.already')}
|
|
30
|
+
</p>
|
|
31
|
+
)}
|
|
32
|
+
<div>
|
|
33
|
+
{disabled ? (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
disabled
|
|
37
|
+
className="inline-flex h-10 cursor-not-allowed items-center rounded-md border border-border px-4 text-sm opacity-60"
|
|
38
|
+
>
|
|
39
|
+
{fromCopy(copy, 'adminPanel.backup.now')}
|
|
40
|
+
</button>
|
|
41
|
+
) : (
|
|
42
|
+
<SubmitButton className="w-auto">{fromCopy(copy, 'adminPanel.backup.now')}</SubmitButton>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
</form>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function DeleteBackupForm({ name, copy }: { name: string; copy: Copy }) {
|
|
50
|
+
const [state, action] = useActionState(deleteBackupAction, EMPTY_STATE)
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<form action={action} className="flex flex-wrap items-center gap-2">
|
|
54
|
+
<input type="hidden" name="name" value={name} />
|
|
55
|
+
<PendingButton className={ROW_BUTTON}>
|
|
56
|
+
{fromCopy(copy, 'adminPanel.backup.delete')}
|
|
57
|
+
</PendingButton>
|
|
58
|
+
{state.notice === 'deleted' && (
|
|
59
|
+
<span className="text-xs text-muted-foreground">
|
|
60
|
+
{formatFromCopy(copy, 'adminPanel.backup.deleted', { name: state.values?.name ?? name })}
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
{state.error !== undefined && <span className="text-xs text-destructive">{state.error}</span>}
|
|
64
|
+
<ConfirmDialog confirm={state.confirm} action={action} />
|
|
65
|
+
</form>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function TestDestinationForm({ copy }: { copy: Copy }) {
|
|
70
|
+
const [state, action] = useActionState(testBackupDestinationAction, EMPTY_STATE)
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<form action={action} className="flex flex-col gap-2">
|
|
74
|
+
<FormError message={state.error} />
|
|
75
|
+
{state.notice === 'reachable' && (
|
|
76
|
+
<Saved>
|
|
77
|
+
{formatFromCopy(copy, 'adminPanel.backup.reachable', {
|
|
78
|
+
count: Number(state.values?.count ?? 0),
|
|
79
|
+
})}
|
|
80
|
+
</Saved>
|
|
81
|
+
)}
|
|
82
|
+
<div>
|
|
83
|
+
<SubmitButton className="w-auto">{fromCopy(copy, 'adminPanel.backup.test')}</SubmitButton>
|
|
84
|
+
</div>
|
|
85
|
+
</form>
|
|
86
|
+
)
|
|
87
|
+
}
|