Ghost

struxadotcloud/struxa

public
Fork0
CodeIssues0Pull requests0
1branch
struxaappswebsrcapp(panel)servers[id]backupspage.tsx
fix(web): drop icon from gdrive dropdown item2c06b712 months ago396 lines · 17.5 KB
1"use client";
2
3import { useEffect, useState
4import { use }
5import { useRouter }
6import { useQuery, useMutation
7import { Archive, HardDrive
8import type { LucideIcon
9import {
10 Dialog,
11 DialogPopup,
12 DialogHeader,
13 DialogFooter,
14 DialogTitle,
15 DialogDescription,
16 DialogClose,
17} from "@struxa/ui/components/dialog";
18import {
19 DropdownMenu,
20 DropdownMenuTrigger,
21 DropdownMenuContent,
22 DropdownMenuItem,
23} from "@struxa/ui/components/dropdown-menu";
24import { Tooltip, TooltipTrigger
25import { useTranslations }
26import { GoogleIcon }
27import { toast }
28import { orpc }
29import { authClient }
30import Loader from "@/components/loader
31
32function fmtBytes(bytes:
33 if (bytes ===
34 if (bytes <
35 if (bytes <
36 return `${(bytes
37}
38
39function fmtDate(d:
40 if (!d)
41 return new Date
42}
43
44function StatRow({ icon
45 return (
46 <div className="flex flex-col border-b border-border last:border-b-0
47 <div className="flex flex-col gap-1.5 px-4 pt-3 pb-2.5
48 <div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground
49 <Icon className="
50 {label}
51 </div>
52 {children}
53 </div>
54 </div>
55 );
56}
57
58function StatusDot({ backup
59 if (backup.completedAt
60 if (backup.isSuccessful
61 return <span className
62}
63
64export default function BackupsPage
65 const t =
66 const tc =
67 const router =
68 const { data:
69 const { id
70 const queryClient =
71 const [showCreate,
72 const [createName,
73 const [destination,
74 const [deleteTarget,
75 const [restoreTarget,
76
77 useEffect(() =>
78 if (!isPending &&
79 }, [isPending,
80
81 const { data:
82 const serverId =
83
84 const { data:
85 ...orpc.backups.
86 enabled: !!serverId,
87 });
88
89 const { data:
90 ...orpc.backups.
91 enabled: !!serverId,
92 });
93 const s3PublicDownloads =
94 const googleDriveConfigured =
95 const googleDriveConnected =
96
97 const createMutation =
98 const deleteMutation =
99 ...orpc.backups.
100 onSuccess: () =>
101 void queryClient.invalidateQueries(
102 toast.success(tc
103 },
104 });
105 const downloadMutation =
106 ...orpc.backups.
107 onSuccess: ({ url
108 });
109 const restoreMutation =
110 ...orpc.backups.
111 onSuccess: () =>
112 setRestoreTarget(null);
113 toast.success(t
114 },
115 });
116
117 function closeCreate() {
118 setShowCreate(false);
119 setCreateName("");
120 setDestination("node");
121 }
122
123 async function handleCreate
124 if (!serverId ||
125 await createMutation.mutateAsync
126 void queryClient.invalidateQueries(
127 toast.success(tc
128 closeCreate();
129 }
130
131 if (isPending ||
132
133 const totalBytes =
134 const lastBackup =
135
136 return (
137 <>
138 <Dialog open={
139 <DialogPopup showCloseButton={
140 <DialogHeader>
141 <DialogTitle>{t(
142 <DialogDescription>
143 {t("restoreDescription",
144 </DialogDescription>
145 </DialogHeader>
146 <DialogFooter>
147 <DialogClose
148 className="rounded-lg px-4 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
149 disabled={restoreMutation.isPending
150 >
151 {t("cancel")
152 </DialogClose>
153 <button
154 type="button"
155 disabled={restoreMutation.isPending
156 onClick={() =>
157 if (!serverId ||
158 restoreMutation.mutate({
159 }}
160 className="rounded-lg bg-foreground px-4 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80 disabled:opacity-40"
161 >
162 {restoreMutation.isPending ?
163 </button>
164 </DialogFooter>
165 </DialogPopup>
166 </Dialog>
167
168 <Dialog open={
169 <DialogPopup showCloseButton={
170 <DialogHeader>
171 <DialogTitle>{t(
172 <DialogDescription>
173 {t("deleteDescription",
174 </DialogDescription>
175 </DialogHeader>
176 <DialogFooter>
177 <DialogClose
178 className="rounded-lg px-4 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
179 disabled={deleteMutation.isPending
180 >
181 {t("cancel")
182 </DialogClose>
183 <button
184 type="button"
185 disabled={deleteMutation.isPending
186 onClick={() =>
187 if (!serverId ||
188 deleteMutation.mutate(
189 { serverId, backupId:
190 { onSuccess: ()
191 );
192 }}
193 className="rounded-lg bg-destructive px-4 py-1.5 text-xs font-medium text-destructive-foreground transition-opacity hover:opacity-80 disabled:opacity-40"
194 >
195 {deleteMutation.isPending ?
196 </button>
197 </DialogFooter>
198 </DialogPopup>
199 </Dialog>
200
201 <Dialog open={
202 <DialogPopup showCloseButton={
203 <DialogHeader>
204 <DialogTitle>{t(
205 <DialogDescription>{t(
206 </DialogHeader>
207 <div className="px-5 py-4
208 <label className="mb-1.5 block text-xs font-medium text-foreground
209 <input
210 autoFocus
211 value={createName}
212 onChange={(e)
213 placeholder="my-backup"
214 className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground/50 focus:border-ring transition-colors"
215 onKeyDown={(e)
216 if (e.key
217 if (e.key
218 }}
219 />
220 </div>
221 <DialogFooter>
222 <DialogClose
223 className="rounded-lg px-4 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
224 disabled={createMutation.isPending
225 >
226 {t("cancel")
227 </DialogClose>
228 <button
229 type="button"
230 onClick={() =>
231 disabled={!createName.
232 className="rounded-lg bg-foreground px-4 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80 disabled:opacity-40"
233 >
234 {createMutation.isPending ?
235 </button>
236 </DialogFooter>
237 </DialogPopup>
238 </Dialog>
239
240 <div className="flex flex-1 flex-col gap-3 overflow-auto px-4 py-4 md:flex-row md:overflow-hidden
241 <div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card
242 <div className="flex items-center justify-between border-b border-border px-4 py-3
243 <p className="text-sm font-medium text-foreground
244 {googleDriveConfigured ? (
245 <DropdownMenu>
246 <DropdownMenuTrigger className="
247 {t("createBackup")
248 <ChevronDown className="
249 </DropdownMenuTrigger>
250 <DropdownMenuContent style={
251 <DropdownMenuItem
252 className="cursor-pointer whitespace-nowrap"
253 onClick={() =>
254 setDestination("node");
255 setShowCreate(true);
256 }}
257 >
258 {t("createBackup")
259 </DropdownMenuItem>
260 <DropdownMenuItem
261 className="cursor-pointer whitespace-nowrap"
262 onClick={() =>
263 if (!googleDriveConnected) {
264 router.push("/account
265 return;
266 }
267 setDestination("gdrive");
268 setShowCreate(true);
269 }}
270 >
271 {t("backupToGDrive")
272 </DropdownMenuItem>
273 </DropdownMenuContent>
274 </DropdownMenu>
275 ) : (
276 <button
277 type="button"
278 onClick={() =>
279 className="rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
280 >
281 {t("createBackup")
282 </button>
283 )}
284 </div>
285 {backups.some((
286 <div className="border-b border-border bg-muted/30 px-4 py-2
287 <p className="text-xs text-muted-foreground
288 </div>
289 )}
290 <div className="flex flex-1 flex-col overflow-x-auto
291 <div className="min-w-[500px] flex flex-1 flex-col
292 <div className="grid grid-cols-[28px_1fr_100px_200px_100px] border-b border-border bg-muted/40 px-4 py-2.5 shrink-0
293 <span />
294 <span className="text-xs font-medium text-muted-foreground
295 <span className="text-xs font-medium text-muted-foreground
296 <span className="text-xs font-medium text-muted-foreground
297 <span />
298 </div>
299 <div className="flex-1 overflow-y-auto
300 {backupsPending ? (
301 <div className="flex items-center justify-center py-12 text-sm text-muted-foreground
302 ) : backups.length
303 <div className="flex flex-col items-center justify-center py-12 gap-2
304 <Archive className="
305 <p className="text-sm text-muted-foreground
306 <button
307 type="button"
308 onClick={() =>
309 className="mt-1 rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
310 >
311 {t("createFirst")
312 </button>
313 </div>
314 ) : (
315 backups.map((backup
316 const isLast =
317 return (
318 <div
319 key={backup.id
320 className={`grid grid-cols-[28px_1fr_100px_200px_100px] items-center px-4 py-3 transition-colors hover:bg-muted/40 ${!
321 >
322 <StatusDot backup={
323 <span className="flex min-w-0 items-center gap-1.5 pr-4
324 <span className="font-mono text-sm text-foreground truncate
325 {backup.disk ===
326 <Tooltip>
327 <TooltipTrigger className="
328 <GoogleIcon className="
329 </TooltipTrigger>
330 <TooltipPopup>{t(
331 </Tooltip>
332 )}
333 </span>
334 <span className="text-sm text-muted-foreground
335 <span className="text-xs text-muted-foreground
336 <div className="flex items-center gap-2 justify-end pr-1
337 {backup.isSuccessful &&
338 <>
339 {(backup.disk
340 <button
341 type="button"
342 onClick={() =>
343 className="rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-blue-500 transition-colors"
344 title={t("
345 >
346 <Download className="
347 </button>
348 )}
349 <button
350 type="button"
351 onClick={() =>
352 className="rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-blue-500 transition-colors"
353 title={t("
354 >
355 <RotateCcw className="
356 </button>
357 </>
358 )}
359 <button
360 type="button"
361 disabled={backup.isLocked
362 onClick={() =>
363 className="rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-destructive disabled:opacity-30 transition-colors"
364 title={t("
365 >
366 <Trash2 className="
367 </button>
368 </div>
369 </div>
370 );
371 })
372 )}
373 </div>
374 </div>
375 </div>
376 </div>
377
378 <aside className="flex w-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-card md:w-[220px]
379 <div className="overflow-y-auto
380 <StatRow icon={
381 <span className="text-xl font-bold text-foreground
382 </StatRow>
383 <StatRow icon={
384 <span className="text-xl font-bold text-foreground
385 </StatRow>
386 <StatRow icon={
387 <span className="text-sm font-semibold text-foreground leading-snug
388 {lastBackup ? fmtDate
389 </span>
390 </StatRow>
391 </div>
392 </aside>
393 </div>
394 </>
395 );
396}
}
from
"
react
"
;
from
"
react
"
;
from
"
next/navigation
"
;
,
useQueryClient
}
from
"
@tanstack/react-query
"
;
,
Clock
,
RotateCcw
,
Trash2
,
Download
,
ChevronDown
}
from
"
lucide-react
"
;
}
from
"
lucide-react
"
;
,
TooltipPopup
}
from
"
@struxa/ui/components/tooltip
"
;
from
"
next-intl
"
;
from
"
@/components/google-icon
"
;
from
"
sonner
"
;
from
"
@/utils/orpc
"
;
from
"
@/lib/auth-client
"
;
"
;
number
)
:
string
{
0
)
return
"
—
"
;
1024
*
1024
)
return
`
${
(
bytes
/
1024
)
.
toFixed
(
1
)
}
KB
`
;
1024
*
1024
*
1024
)
return
`
${
(
bytes
/
(
1024
*
1024
))
.
toFixed
(
1
)
}
MB
`
;
/
(
1024
*
1024
*
1024
))
.
toFixed
(
2
)
}
GB
`
;
Date
|
string
|
null
)
:
string
{
return
"
—
"
;
(
d
)
.
toLocaleString
()
;
:
Icon
,
label
,
children
}
:
{
icon
:
LucideIcon
;
label
:
string
;
children
:
React
.
ReactNode
})
{
"
>
"
>
"
>
h-3 w-3
"
/>
}
:
{
backup
:
{
isSuccessful
:
boolean
;
completedAt
:
Date
|
string
|
null
}
})
{
===
null
)
return
<
span
className
=
"
h-2 w-2 shrink-0 rounded-full bg-amber-500 animate-pulse
"
/>
;
)
return
<
span
className
=
"
h-2 w-2 shrink-0 rounded-full bg-green-500
"
/>
;
=
"
h-2 w-2 shrink-0 rounded-full bg-destructive
"
/>
;
(
{
params
}
:
{
params
:
Promise
<
{
id
:
string
}
>
})
{
useTranslations
(
"
panel.backups
"
)
;
useTranslations
(
"
common
"
)
;
useRouter
()
;
session
,
isPending
}
=
authClient
.
useSession
()
;
}
=
use
(
params
)
;
useQueryClient
()
;
setShowCreate
]
=
useState
(
false
)
;
setCreateName
]
=
useState
(
""
)
;
setDestination
]
=
useState
<
"
node
"
|
"
gdrive
"
>
(
"
node
"
)
;
setDeleteTarget
]
=
useState
<
{
id
:
string
;
name
:
string
}
|
null
>
(
null
)
;
setRestoreTarget
]
=
useState
<
{
id
:
string
;
name
:
string
}
|
null
>
(
null
)
;
{
!
session
)
router
.
replace
(
"
/login
"
)
;
session
,
router
])
;
server
}
=
useQuery
(
orpc
.
servers
.
get
.
queryOptions
(
{
input
:
{
id
}
}
))
;
server
?.
id
;
backups
=
[]
,
isPending
:
backupsPending
}
=
useQuery
(
{
list
.
queryOptions
(
{
input
:
{
serverId
:
serverId
??
""
}
}
)
,
downloadAvailability
}
=
useQuery
(
{
getDownloadAvailability
.
queryOptions
(
{
input
:
{
serverId
:
serverId
??
""
}
}
)
,
downloadAvailability
?.
s3PublicDownloads
??
false
;
downloadAvailability
?.
googleDriveConfigured
??
false
;
downloadAvailability
?.
googleDriveConnected
??
false
;
useMutation
(
orpc
.
backups
.
create
.
mutationOptions
())
;
useMutation
(
{
delete
.
mutationOptions
()
,
{
orpc
.
backups
.
list
.
queryOptions
(
{
input
:
{
serverId
:
serverId
??
""
}
}
))
;
(
"
deleted
"
))
;
useMutation
(
{
getDownloadUrl
.
mutationOptions
()
,
}
)
=>
window
.
open
(
url
,
"
_blank
"
)
,
useMutation
(
{
restore
.
mutationOptions
()
,
{
(
"
restoreSuccess
"
))
;
()
{
!
createName
.
trim
())
return
;
(
{
serverId
,
name
:
createName
.
trim
()
,
destination
}
)
;
orpc
.
backups
.
list
.
queryOptions
(
{
input
:
{
serverId
}
}
))
;
(
"
created
"
))
;
!
session
)
return
<
Loader
/>
;
backups
.
reduce
(
(
acc
,
b
)
=>
acc
+
Number
(
b
.
bytes
??
0
)
,
0
)
;
backups
[
0
]
;
!!
restoreTarget
}
onOpenChange
=
{
(
open
)
=>
{
if
(
!
open
)
setRestoreTarget
(
null
)
;
}
}
>
false
}
>
"
restoreTitle
"
)
}
</
DialogTitle
>
{
name
:
restoreTarget
?.
name
??
""
}
)
}
}
}
}
{
!
restoreTarget
)
return
;
serverId
,
backupId
:
restoreTarget
.
id
}
)
;
t
(
"
restoring
"
)
:
t
(
"
restoreBackup
"
)
}
!!
deleteTarget
}
onOpenChange
=
{
(
open
)
=>
{
if
(
!
open
)
setDeleteTarget
(
null
)
;
}
}
>
false
}
>
"
deleteTitle
"
)
}
</
DialogTitle
>
{
name
:
deleteTarget
?.
name
??
""
}
)
}
}
}
}
{
!
deleteTarget
)
return
;
deleteTarget
.
id
}
,
=>
setDeleteTarget
(
null
)
}
,
t
(
"
deleting
"
)
:
t
(
"
deleteBackup
"
)
}
showCreate
}
onOpenChange
=
{
(
open
)
=>
{
if
(
!
open
)
closeCreate
()
;
}
}
>
false
}
>
"
createTitle
"
)
}
</
DialogTitle
>
"
createDescription
"
)
}
</
DialogDescription
>
"
>
"
>
{
t
(
"
nameLabel
"
)
}
</
label
>
=>
setCreateName
(
e
.
target
.
value
)
}
=>
{
===
"
Enter
"
)
void
handleCreate
()
;
===
"
Escape
"
)
closeCreate
()
;
}
}
void
handleCreate
()
}
trim
()
||
createMutation
.
isPending
}
t
(
"
creating
"
)
:
t
(
"
createBackup
"
)
}
"
>
"
>
"
>
"
>
{
t
(
"
sectionTitle
"
)
}
</
p
>
flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80
"
>
}
h-3 w-3
"
/>
{
minWidth
:
"
14rem
"
}
}
>
{
}
{
"
)
;
}
setShowCreate
(
true
)
}
}
b
)
=>
b
.
disk
===
"
s3
"
)
&&
!
s3PublicDownloads
&&
(
"
>
"
>
{
t
(
"
downloadsDisabledHint
"
)
}
</
p
>
"
>
"
>
"
>
"
>
{
t
(
"
colName
"
)
}
</
span
>
"
>
{
t
(
"
colSize
"
)
}
</
span
>
"
>
{
t
(
"
colCreated
"
)
}
</
span
>
"
>
"
>
{
t
(
"
loading
"
)
}
</
div
>
===
0
?
(
"
>
h-8 w-8 text-muted-foreground/30
"
/>
"
>
{
t
(
"
empty
"
)
}
</
p
>
setShowCreate
(
true
)
}
}
,
i
)
=>
{
i
===
backups
.
length
-
1
;
}
isLast
?
"
border-b border-border
"
:
""
}
`
}
backup
}
/>
"
>
"
>
{
backup
.
name
}
</
span
>
"
google-drive
"
&&
(
flex shrink-0 items-center
"
>
h-3.5 w-3.5
"
/>
"
gdriveBadgeHint
"
)
}
</
TooltipPopup
>
"
>
{
fmtBytes
(
Number
(
backup
.
bytes
??
0
))
}
</
span
>
"
>
{
fmtDate
(
backup
.
createdAt
)
}
</
span
>
"
>
(
!==
"
s3
"
||
s3PublicDownloads
)
&&
(
serverId
&&
downloadMutation
.
mutate
(
{
serverId
,
backupId
:
backup
.
id
}
)
}
download
"
)
}
h-3.5 w-3.5
"
/>
setRestoreTarget
(
{
id
:
backup
.
id
,
name
:
backup
.
name
}
)
}
restore
"
)
}
h-3.5 w-3.5
"
/>
}
setDeleteTarget
(
{
id
:
backup
.
id
,
name
:
backup
.
name
}
)
}
delete
"
)
}
h-3.5 w-3.5
"
/>
"
>
"
>
Archive
}
label
=
{
t
(
"
statBackups
"
)
}
>
"
>
{
backups
.
length
}
</
span
>
HardDrive
}
label
=
{
t
(
"
statTotalSize
"
)
}
>
"
>
{
fmtBytes
(
totalBytes
)
}
</
span
>
Clock
}
label
=
{
t
(
"
statLastBackup
"
)
}
>
"
>
(
lastBackup
.
createdAt
)
:
"
—
"
}