| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useState |
| 4 | import { use } |
| 5 | import { useRouter } |
| 6 | import { useQuery, useMutation |
| 7 | import { Database, Copy |
| 8 | import type { LucideIcon |
| 9 | import { |
| 10 | Dialog, |
| 11 | DialogPopup, |
| 12 | DialogHeader, |
| 13 | DialogFooter, |
| 14 | DialogTitle, |
| 15 | DialogDescription, |
| 16 | DialogClose, |
| 17 | } from "@struxa/ui/components/dialog"; |
| 18 | import { useTranslations } |
| 19 | import { toast } |
| 20 | import { orpc } |
| 21 | import { authClient } |
| 22 | import Loader from "@/components/loader |
| 23 | import { formatConnectionString } |
| 24 | import { ConfirmDialog } |
| 25 | |
| 26 | type DatabaseEngineType |
| 27 | |
| 28 | function StatRow({ icon |
| 29 | return ( |
| 30 | <div className="flex flex-col border-b border-border last:border-b-0 |
| 31 | <div className="flex flex-col gap-1.5 px-4 pt-3 pb-2.5 |
| 32 | <div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-muted-foreground |
| 33 | <Icon className=" |
| 34 | {label} |
| 35 | </div> |
| 36 | {children} |
| 37 | </div> |
| 38 | </div> |
| 39 | ); |
| 40 | } |
| 41 | |
| 42 | type DbRow |
| 43 | id: string |
| 44 | database: string |
| 45 | username: string |
| 46 | password: string |
| 47 | remote: string |
| 48 | host: { |
| 49 | }; |
| 50 | |
| 51 | function DatabaseRow({ db |
| 52 | db: DbRow |
| 53 | onRotate: (id |
| 54 | onDelete: (id |
| 55 | rotating: boolean |
| 56 | }) { |
| 57 | const t = |
| 58 | const tt = |
| 59 | const [open, |
| 60 | const [visible, |
| 61 | const [pw, |
| 62 | |
| 63 | useEffect(() => |
| 64 | |
| 65 | function copy(text |
| 66 | |
| 67 | const isRedis = |
| 68 | |
| 69 | return ( |
| 70 | <div className="border-b border-border last:border-b-0 |
| 71 | <button |
| 72 | type="button" |
| 73 | onClick={() => |
| 74 | className="flex w-full items-center gap-3 px-4 py-3 hover:bg-muted/40 transition-colors text-left" |
| 75 | > |
| 76 | <ChevronRight className={ |
| 77 | <Database className=" |
| 78 | <span className="font-mono text-sm font-medium text-foreground flex-1 |
| 79 | <span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground |
| 80 | {tt(db.host |
| 81 | </span> |
| 82 | <div className="flex items-center gap-2 |
| 83 | <button |
| 84 | type="button" |
| 85 | onClick={() => |
| 86 | disabled={rotating} |
| 87 | className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-40" |
| 88 | > |
| 89 | <RefreshCw className={ |
| 90 | {rotating ? t |
| 91 | </button> |
| 92 | <button |
| 93 | type="button" |
| 94 | onClick={() => |
| 95 | className="flex items-center gap-1.5 rounded-lg border border-destructive/30 px-3 py-1 text-xs text-destructive hover:bg-destructive/10 transition-colors" |
| 96 | > |
| 97 | <Trash2 className=" |
| 98 | {t("delete") |
| 99 | </button> |
| 100 | </div> |
| 101 | </button> |
| 102 | |
| 103 | {open && ( |
| 104 | <div className="grid grid-cols-1 border-t border-border bg-muted/20 sm:grid-cols-2 |
| 105 | <div className="border-b border-border px-4 py-3 sm:border-b-0 sm:border-r |
| 106 | <p className="mb-1.5 text-xs font-medium text-muted-foreground |
| 107 | <div className="flex items-center gap-2 |
| 108 | <span className="font-mono text-sm text-foreground |
| 109 | <button type="button |
| 110 | <Copy className=" |
| 111 | </button> |
| 112 | </div> |
| 113 | </div> |
| 114 | <div className="px-4 py-3 |
| 115 | <p className="mb-1.5 text-xs font-medium text-muted-foreground |
| 116 | <div className="flex items-center gap-2 |
| 117 | <span className="font-mono text-sm text-foreground |
| 118 | <button type="button |
| 119 | {visible ? <EyeOff |
| 120 | </button> |
| 121 | <button type="button |
| 122 | <Copy className=" |
| 123 | </button> |
| 124 | </div> |
| 125 | </div> |
| 126 | <div className="border-t border-border px-4 py-3 sm:border-r |
| 127 | <p className="mb-1.5 text-xs font-medium text-muted-foreground |
| 128 | <div className="flex items-center gap-2 |
| 129 | <span className="font-mono text-sm text-foreground |
| 130 | <button type="button |
| 131 | <Copy className=" |
| 132 | </button> |
| 133 | </div> |
| 134 | </div> |
| 135 | <div className="border-t border-border px-4 py-3 |
| 136 | <p className="mb-1.5 text-xs font-medium text-muted-foreground |
| 137 | <span className="font-mono text-sm text-foreground |
| 138 | </div> |
| 139 | <div className="col-span-1 border-t border-border px-4 py-3 sm:col-span-2 |
| 140 | <p className="mb-1.5 text-xs font-medium text-muted-foreground |
| 141 | {isRedis ? t |
| 142 | </p> |
| 143 | <div className="flex items-center gap-2 |
| 144 | <span className="font-mono text-xs text-muted-foreground |
| 145 | {formatConnectionString(db.host |
| 146 | </span> |
| 147 | <button |
| 148 | type="button" |
| 149 | onClick={() => |
| 150 | copy(formatConnectionString(db |
| 151 | } |
| 152 | className="rounded p-0.5 text-muted-foreground/50 hover:bg-muted hover:text-foreground transition-colors shrink-0" |
| 153 | > |
| 154 | <Copy className=" |
| 155 | </button> |
| 156 | </div> |
| 157 | </div> |
| 158 | </div> |
| 159 | )} |
| 160 | </div> |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | export default function DatabasesPage |
| 165 | const t = |
| 166 | const tc = |
| 167 | const tt = |
| 168 | const router = |
| 169 | const { data: |
| 170 | const { id |
| 171 | const queryClient = |
| 172 | const [showCreate, |
| 173 | const [dbName, |
| 174 | const [dbType, |
| 175 | const [confirmRotateId, |
| 176 | |
| 177 | useEffect(() => |
| 178 | if (!isPending && |
| 179 | }, [isPending, |
| 180 | |
| 181 | const { data: |
| 182 | const serverId = |
| 183 | |
| 184 | const { data: |
| 185 | ...orpc.databases. |
| 186 | enabled: !!serverId, |
| 187 | }); |
| 188 | |
| 189 | const { data: |
| 190 | ...orpc.databaseHosts. |
| 191 | enabled: showCreate && |
| 192 | }); |
| 193 | |
| 194 | const createMutation = |
| 195 | const rotateMutation = |
| 196 | ...orpc.databases. |
| 197 | onSuccess: () => |
| 198 | void queryClient.invalidateQueries( |
| 199 | toast.success(tc |
| 200 | }, |
| 201 | }); |
| 202 | const deleteMutation = |
| 203 | ...orpc.databases. |
| 204 | onSuccess: () => |
| 205 | void queryClient.invalidateQueries( |
| 206 | toast.success(tc |
| 207 | }, |
| 208 | }); |
| 209 | |
| 210 | function closeCreate() { |
| 211 | setShowCreate(false); |
| 212 | setDbName(""); |
| 213 | setDbType(""); |
| 214 | } |
| 215 | |
| 216 | async function handleCreate |
| 217 | if (!serverId || |
| 218 | await createMutation.mutateAsync |
| 219 | void queryClient.invalidateQueries( |
| 220 | toast.success(tc |
| 221 | closeCreate(); |
| 222 | } |
| 223 | |
| 224 | if (isPending || |
| 225 | |
| 226 | return ( |
| 227 | <> |
| 228 | <Dialog open={ |
| 229 | <DialogPopup showCloseButton={ |
| 230 | <DialogHeader> |
| 231 | <DialogTitle>{t( |
| 232 | <DialogDescription>{t( |
| 233 | </DialogHeader> |
| 234 | <div className="flex flex-col gap-4 px-5 py-4 |
| 235 | <div> |
| 236 | <label className="mb-1.5 block text-xs font-medium text-foreground |
| 237 | <input |
| 238 | autoFocus |
| 239 | value={dbName} |
| 240 | onChange={(e) |
| 241 | placeholder="my_database" |
| 242 | className="w-full rounded-lg border border-border bg-background px-3 py-2 font-mono text-sm text-foreground outline-none placeholder:text-muted-foreground/50 focus:border-ring transition-colors" |
| 243 | onKeyDown={(e) |
| 244 | /> |
| 245 | <p className="mt-1 text-xs text-muted-foreground |
| 246 | </div> |
| 247 | <div> |
| 248 | <label className="mb-1.5 block text-xs font-medium text-foreground |
| 249 | {availableTypes.length === |
| 250 | <p className="rounded-lg border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground |
| 251 | {t("noTypesAvailable") |
| 252 | </p> |
| 253 | ) : ( |
| 254 | <div className="flex flex-wrap gap-1.5 |
| 255 | {availableTypes.map(( |
| 256 | <button |
| 257 | key={type} |
| 258 | type="button" |
| 259 | onClick={() => |
| 260 | className={`rounded-lg border px-3 py-2 text-sm transition-colors ${ |
| 261 | dbType === type |
| 262 | ? "border-ring bg-muted/40 text-foreground" |
| 263 | : "border-border text-muted-foreground hover:bg-muted/40 hover:text-foreground" |
| 264 | }`} |
| 265 | > |
| 266 | {tt(type)} |
| 267 | </button> |
| 268 | ))} |
| 269 | </div> |
| 270 | )} |
| 271 | </div> |
| 272 | </div> |
| 273 | <DialogFooter> |
| 274 | <DialogClose |
| 275 | className="rounded-lg px-4 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" |
| 276 | disabled={createMutation.isPending |
| 277 | > |
| 278 | {t("cancel") |
| 279 | </DialogClose> |
| 280 | <button |
| 281 | type="button" |
| 282 | onClick={() => |
| 283 | disabled={!dbName. |
| 284 | className="rounded-lg bg-foreground px-4 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80 disabled:opacity-40" |
| 285 | > |
| 286 | {createMutation.isPending ? |
| 287 | </button> |
| 288 | </DialogFooter> |
| 289 | </DialogPopup> |
| 290 | </Dialog> |
| 291 | |
| 292 | <ConfirmDialog |
| 293 | open={!!confirmRotateId} |
| 294 | onOpenChange={(open) |
| 295 | title={t(" |
| 296 | description={t(" |
| 297 | loading={rotateMutation.isPending |
| 298 | onConfirm={async () |
| 299 | if (!confirmRotateId || |
| 300 | await rotateMutation.mutateAsync |
| 301 | setConfirmRotateId(null); |
| 302 | }} |
| 303 | /> |
| 304 | |
| 305 | <div className="flex flex-1 flex-col gap-3 overflow-auto px-4 py-4 md:flex-row md:overflow-hidden |
| 306 | <div className="flex flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card |
| 307 | <div className="flex items-center justify-between border-b border-border px-4 py-3 |
| 308 | <p className="text-sm font-medium text-foreground |
| 309 | <button |
| 310 | type="button" |
| 311 | onClick={() => |
| 312 | className="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" |
| 313 | > |
| 314 | <Plus className=" |
| 315 | {t("newDatabase") |
| 316 | </button> |
| 317 | </div> |
| 318 | <div className="flex-1 overflow-y-auto |
| 319 | {dbPending ? ( |
| 320 | <div className="flex items-center justify-center py-12 text-sm text-muted-foreground |
| 321 | ) : databases.length |
| 322 | <div className="flex flex-col items-center justify-center py-12 gap-2 |
| 323 | <Database className=" |
| 324 | <p className="text-sm text-muted-foreground |
| 325 | <button |
| 326 | type="button" |
| 327 | onClick={() => |
| 328 | 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" |
| 329 | > |
| 330 | {t("createFirst") |
| 331 | </button> |
| 332 | </div> |
| 333 | ) : ( |
| 334 | databases.map((db |
| 335 | <DatabaseRow |
| 336 | key={db.id |
| 337 | db={db as |
| 338 | onRotate={(dbId) |
| 339 | onDelete={(dbId) |
| 340 | rotating={rotateMutation.isPending |
| 341 | /> |
| 342 | )) |
| 343 | )} |
| 344 | </div> |
| 345 | </div> |
| 346 | |
| 347 | <aside className="flex w-full shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-card md:w-[240px] |
| 348 | <div className="overflow-y-auto |
| 349 | <StatRow icon={ |
| 350 | <span className="text-xl font-bold text-foreground |
| 351 | </StatRow> |
| 352 | <StatRow icon={ |
| 353 | <span className="font-mono text-sm font-semibold text-foreground leading-snug |
| 354 | {(databases[0] |
| 355 | </span> |
| 356 | </StatRow> |
| 357 | <StatRow icon={ |
| 358 | <p className="text-xs text-muted-foreground leading-relaxed |
| 359 | {t("statNoteText") |
| 360 | </p> |
| 361 | </StatRow> |
| 362 | </div> |
| 363 | </aside> |
| 364 | </div> |
| 365 | </> |
| 366 | ); |
| 367 | } |