Enhance character and tierlist management: add error handling for character creation and editing; implement action logging for character updates and deletions; introduce new tierlist type creation and editing pages; update dependencies and Docker configuration.

This commit is contained in:
2026-01-25 03:59:31 +07:00 Unverified
parent c617479e0d
commit 327879c62f
13 changed files with 598 additions and 77 deletions
+12 -2
View File
@@ -27,6 +27,8 @@ import { characters, element as elementEnum, versions } from "@/lib/db/schema";
export default async function CharacterEditPage({
params,
}: PageProps<"/admin/char/[id]">) {
if (!(await adminCheck())) redirect("/login");
const { id: charId } = await params;
const [versionList, [char]] = await Promise.all([
db.select().from(versions).orderBy(desc(versions.id)),
@@ -82,10 +84,16 @@ export default async function CharacterEditPage({
await db.update(characters).set(data).where(eq(characters.id, charId));
} catch (e) {
console.error(e);
return { error: "Failed to update character in database." };
const err = e as Error & { cause: { detail: string; message: string } };
return {
error:
err?.cause?.detail ||
err?.cause?.message ||
"Failed to update character in database.",
};
}
actionLog(`Edited character ${charId}`, data);
await actionLog(`Edited character ${charId}`, data);
revalidatePath("/admin/char");
return { toast: "Character saved." };
@@ -97,6 +105,8 @@ export default async function CharacterEditPage({
await db.delete(characters).where(eq(characters.id, charId));
await actionLog(`Deleted character ${charId}`, char);
revalidatePath("/admin/char");
return { toast: "Character deleted.", close: true };
}
+8 -2
View File
@@ -82,10 +82,16 @@ export default async function CharacterCreatePage() {
await db.insert(characters).values(data);
} catch (e) {
console.error(e);
return { error: "Failed to update character in database." };
const err = e as Error & { cause: { detail: string; message: string } };
return {
error:
err?.cause?.detail ||
err?.cause?.message ||
"Failed to update character in database.",
};
}
actionLog(`Created character ${data.id}`, data);
await actionLog(`Created character ${data.id}`, data);
revalidatePath("/admin/char");
return { toast: "Character created successfully.", close: true };
@@ -0,0 +1,139 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { eq } from "drizzle-orm";
import { SaveIcon, Trash2 } from "lucide-react";
import { revalidatePath } from "next/cache";
import { notFound, redirect } from "next/navigation";
import type { TypedFormData } from "@/app/(ui)/rubgram/type";
import { CdnChooser } from "@/components/chooser";
import {
FormAction,
FormInput,
FormProvider,
FormRow,
} from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistTypes } from "@/lib/db/schema";
export default async function CharacterCreatePage({
params,
}: PageProps<"/admin/tl/ver/[type]/edit">) {
if (!(await adminCheck())) redirect("/login");
const { type: typeId } = await params;
const [type] = await db
.select()
.from(tierlistTypes)
.where(eq(tierlistTypes.id, typeId));
if (!type) notFound();
async function submit(
form: TypedFormData<{
name: string;
id: string;
order: string;
image: string;
}>,
) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["name", "id", "order"] as const) {
if (!form.get(field)) {
return { error: `Field "${field}" is required.` };
}
}
let data: typeof tierlistTypes.$inferInsert;
try {
data = {
id: form.get("id")!,
name: form.get("name")!,
image: form.get("image")!,
order: parseInt(form.get("order")!, 10),
};
await db
.update(tierlistTypes)
.set(data)
.where(eq(tierlistTypes.id, typeId));
} catch (e) {
console.error(e);
const err = e as Error & { cause: { detail: string; message: string } };
return {
error:
err?.cause?.detail || err?.cause?.message || "Failed to update type.",
};
}
await actionLog(`Updated tierlist type ${data.id}`, data);
revalidatePath("/admin/tl/ver");
return { toast: "Type saved successfully." };
}
async function deleteType() {
"use server";
if (!(await adminCheck())) redirect("/login");
await db.delete(tierlistTypes).where(eq(tierlistTypes.id, typeId));
await actionLog(`Deleted tierlist type ${typeId}`, type);
revalidatePath("/admin/char");
return { toast: "Type deleted.", close: true };
}
return (
<ModalBase title="Create Tierlist Type">
<FormProvider id="tl-type-create" onSubmit={submit} values={type}>
<FormRow>
<FormInput name="name" label="Name">
<Input placeholder="Abyss" autoFocus />
</FormInput>
<FormInput name="id" label="ID" subLabel="(change = break)">
<Input placeholder="aby" disabled />
</FormInput>
</FormRow>
<FormInput name="order" label="Order">
<Input placeholder="1" />
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<Button asChild variant="destructive">
<FormAction type="action" action={deleteType}>
<Trash2 />
Delete
</FormAction>
</Button>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Saving...
</>
}
>
<SaveIcon />
Save
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
@@ -0,0 +1,6 @@
"use client";
export function genId({ name }: { name: string; id: string }) {
const generated = name.toLowerCase().slice(0, 3).replace(/[^a-z0-9]+/g, "");
return { id: generated };
}
@@ -0,0 +1,109 @@
import { sql } from "drizzle-orm";
import { PlusIcon } from "lucide-react";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import type { TypedFormData } from "@/app/(ui)/rubgram/type";
import { CdnChooser } from "@/components/chooser";
import { FormAction, FormInput, FormProvider } from "@/components/form";
import { ModalBase } from "@/components/modal";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { actionLog } from "@/lib/api";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { tierlistTypes } from "@/lib/db/schema";
import { genId } from "./overrides";
export default async function CharacterCreatePage() {
const [{ maxOrder }] = await db
.select({ maxOrder: sql<number>`MAX(${tierlistTypes.order})` })
.from(tierlistTypes);
async function submit(
form: TypedFormData<{
name: string;
id: string;
order: string;
image: string;
}>,
) {
"use server";
if (!(await adminCheck())) redirect("/login");
for (const field of ["name", "id", "order"] as const) {
if (!form.get(field)) {
return { error: `Field "${field}" is required.` };
}
}
let data: typeof tierlistTypes.$inferInsert;
try {
data = {
id: form.get("id")!,
name: form.get("name")!,
image: form.get("image")!,
order: parseInt(form.get("order")!, 10),
};
await db.insert(tierlistTypes).values(data);
} catch (e) {
console.error(e);
const err = e as Error & { cause: { detail: string; message: string } };
return {
error:
err?.cause?.detail || err?.cause?.message || "Failed to create type.",
};
}
await actionLog(`Created tierlist type ${data.id}`, data);
revalidatePath("/admin/tl/ver");
return { toast: "Type created successfully.", close: true };
}
return (
<ModalBase title="Create Tierlist Type">
<FormProvider id="tl-type-create" onSubmit={submit}>
<div className="flex gap-2">
<div className="flex flex-col gap-2 grow">
<FormInput name="name" override={genId} label="Name">
<Input placeholder="Abyss" autoFocus />
</FormInput>
</div>
<div className="flex flex-col gap-2 grow">
<FormInput name="id" label="ID">
<Input placeholder="aby" />
</FormInput>
</div>
</div>
<FormInput name="order" label="Order">
<Input placeholder="1" defaultValue={maxOrder + 10} />
</FormInput>
<FormInput name="image" label="Image" subLabel="optional">
<CdnChooser />
</FormInput>
<DialogFooter>
<FormAction type="clear" asChild>
<Button variant="outline">Clear</Button>
</FormAction>
<Button asChild>
<FormAction
type="submit"
loading={
<>
<Spinner />
Creating...
</>
}
>
<PlusIcon />
Create
</FormAction>
</Button>
</DialogFooter>
</FormProvider>
</ModalBase>
);
}
+150 -57
View File
@@ -1,11 +1,16 @@
import { eq, sql } from "drizzle-orm";
import {
ArrowLeftRight,
Badge,
BadgeDollarSign,
BookUser,
Columns3Cog,
Computer,
Database,
ExternalLink,
GitGraph,
Grid3X3,
IdCard,
ListTree,
MoreHorizontal,
Package,
@@ -17,7 +22,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import type { ReactNode } from "react";
import { type ReactNode, Suspense } from "react";
import { SimpleTooltip } from "@/components/tooltip";
import {
Dialog,
@@ -30,6 +35,10 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -50,7 +59,7 @@ import {
import { NavUser } from "@/components/user";
import { adminCheck } from "@/lib/auth";
import { db } from "@/lib/db";
import { versions } from "@/lib/db/schema";
import { tierlistTypes, tierlistVersions, versions } from "@/lib/db/schema";
import { SidebarLink, VersionCreateDialogForm } from "./client";
export default async function AdminLayout({
@@ -66,12 +75,6 @@ export default async function AdminLayout({
.select()
.from(versions)
.catch(() => [{ id: "..", name: "Error Fetching List." }]);
const health: {
database: boolean;
enka: boolean;
amber: boolean;
red: boolean;
} = await fetch("http://localhost:3000/api/health").then((r) => r.json());
return (
<SidebarProvider>
@@ -96,55 +99,17 @@ export default async function AdminLayout({
<SidebarContent>
<SidebarGroup className="pb-0">
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>
<div className="flex justify-between w-full">
<div className="flex gap-1 [&>svg]:size-4 [&>svg]:shrink-0">
{/* Database */}
<SimpleTooltip text="Database">
<Database
className={
health.database
? "text-emerald-400"
: "text-red-400"
}
/>
</SimpleTooltip>{" "}
{/* Enka */}
<SimpleTooltip text="Enka Network API">
<UserRoundSearch
className={
health.enka ? "text-emerald-400" : "text-red-400"
}
/>
</SimpleTooltip>{" "}
{/* Amber */}
<SimpleTooltip text="Project Amber">
<BookUser
className={
health.amber ? "text-emerald-400" : "text-red-400"
}
/>
</SimpleTooltip>
{/* Cache */}
<SimpleTooltip text="Redis Cache/SSE">
<ArrowLeftRight
className={
health.red ? "text-emerald-400" : "text-red-400"
}
/>
</SimpleTooltip>
</div>
<span>
{Object.values(health).some((x) => !x)
? `${{ database: "ฐานข้อมูล", enka: "Enka ", amber: "Amber ", red: "SSE " }[Object.entries(health).find(([, v]) => !v)![0]]}มีปัญหา`
: "ปกติทุกอย่าง"}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<Suspense
fallback={
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>Loading...</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
}
>
<HealthStatus />
</Suspense>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
@@ -219,6 +184,20 @@ export default async function AdminLayout({
<SidebarGroupLabel>Global</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<Suspense
fallback={
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton disabled>
<ExternalLink />
Admin Pages
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
}
>
<AdminShortcuts />
</Suspense>
<SidebarMenuItem>
<SidebarLink href="/admin/char">
<SquareUserRound />
@@ -257,4 +236,118 @@ export default async function AdminLayout({
);
}
async function HealthStatus() {
const health: {
database: boolean;
enka: boolean;
amber: boolean;
red: boolean;
} = await fetch("http://localhost:3000/api/health").then((r) => r.json());
return (
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton>
<div className="flex justify-between w-full">
<div className="flex gap-1 [&>svg]:size-4 [&>svg]:shrink-0">
{/* Database */}
<SimpleTooltip text="Database">
<Database
className={
health.database ? "text-emerald-400" : "text-red-400"
}
/>
</SimpleTooltip>{" "}
{/* Enka */}
<SimpleTooltip text="Enka Network API">
<UserRoundSearch
className={health.enka ? "text-emerald-400" : "text-red-400"}
/>
</SimpleTooltip>{" "}
{/* Amber */}
<SimpleTooltip text="Project Amber">
<BookUser
className={health.amber ? "text-emerald-400" : "text-red-400"}
/>
</SimpleTooltip>
{/* Cache */}
<SimpleTooltip text="Redis Cache/SSE">
<ArrowLeftRight
className={health.red ? "text-emerald-400" : "text-red-400"}
/>
</SimpleTooltip>
</div>
<span>
{Object.values(health).some((x) => !x)
? `${{ database: "ฐานข้อมูล", enka: "Enka ", amber: "Amber ", red: "SSE " }[Object.entries(health).find(([, v]) => !v)![0]]}มีปัญหา`
: "ปกติทุกอย่าง"}
</span>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
);
}
async function AdminShortcuts() {
const versions = await db
.select({
name: sql<string>`${tierlistTypes.name} || ' ' || ${tierlistVersions.name}`.as(
"name",
),
url: sql<string>`${tierlistVersions.type} || '/' || ${tierlistVersions.id}`,
})
.from(tierlistVersions)
.orderBy(tierlistTypes.order, tierlistVersions.order)
.innerJoin(tierlistTypes, eq(tierlistTypes.id, tierlistVersions.type))
.catch(() => []);
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton>
<ExternalLink />
Admin Pages
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="rounded-lg"
side="right"
align="start"
>
<DropdownMenuItem asChild>
<Link href="/artifact/admin">
<IdCard />
Artifact
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/rubgram/admin">
<BadgeDollarSign />
Rubgram
</Link>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Grid3X3 />
Tierlist
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{versions.map((v) => (
<DropdownMenuItem key={v.url} asChild>
<Link href={`/tl/${v.url}/admin`}>{v.name}</Link>
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}
export const dynamic = "force-dynamic";
+3 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react";
import { ChevronDown, ChevronRight, FormIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { stringify } from "yaml";
import SearchBox, { type Filters, type ParsedQuery } from "@/components/search";
@@ -30,7 +30,7 @@ export default function AuditLogViewer({
const filters: Filters = {
user: () => users.map((u) => u.email),
has: () => ["artifact", "tierlist", "rubgram", "file"],
has: () => ["artifact", "tierlist", "rubgram", "character", "file"],
};
const [query, setQuery] = useState<ParsedQuery>();
@@ -178,7 +178,7 @@ export default function AuditLogViewer({
{log.text}{" "}
{!isExpanded && log.details ? (
<SimpleTooltip text="Click to see details">
<CircleAlert className="opacity-80 size-4" />
<FormIcon className="opacity-80 size-4" />
</SimpleTooltip>
) : (
""
+2 -2
View File
@@ -21,7 +21,7 @@ export async function toggleEnka(state: boolean) {
.values({ enka: state })
.onConflictDoUpdate({ target: settings.id, set: { enka: state } });
actionLog("Changed a settings", { enka: state });
await actionLog("Changed a settings", { enka: state });
revalidatePath("/admin/settings");
revalidatePath("/artifact");
}
@@ -30,6 +30,6 @@ export async function syncAmber() {
const res = await promisify(exec)("bun util/sync 2>&1", {
env: { ...env, NO_AUTH_CHECK: "1" },
});
actionLog("Triggered an Amber sync", { result: res.stdout });
await actionLog("Triggered an Amber sync", { result: res.stdout });
return res.stdout;
}
+106
View File
@@ -0,0 +1,106 @@
import { desc, not } from "drizzle-orm";
import { ExternalLink, PencilLine, PlusIcon } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { SimpleTooltip } from "@/components/tooltip";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import { db } from "@/lib/db";
import { tierlistTypes, tierlistVersions } from "@/lib/db/schema";
export default async function TierlistManagerPage() {
const types = await db.select().from(tierlistTypes).orderBy(tierlistTypes.id);
const versionsList = await db
.select()
.from(tierlistVersions)
.orderBy(desc(tierlistVersions.id))
.where(not(tierlistVersions.hidden));
const vers = types.map((t) => ({
...t,
versions: versionsList.filter((v) => v.type === t.id),
}));
return (
<div className="grow-0 flex flex-col pt-5 gap-2 mx-2">
<Button className="w-fit" asChild>
<Link href={`/admin/tl/ver/create`}>
<PlusIcon />
<span>Create</span>
</Link>
</Button>
{vers.map((t) => (
<div className="flex flex-col gap-1 group/type" key={t.id}>
<div className="font-bold text-4xl flex items-center gap-2">
<div className="px-2 py-1 border w-min rounded-md bg-[#2228] flex">
{t.name} <Kbd>{t.id}</Kbd>
</div>
<div className="md:opacity-0 group-hover/type:opacity-100 transition-opacity flex gap-1">
<SimpleTooltip text="Create version">
<Button asChild>
<Link href={`/admin/tl/${t.id}/create`}>
<PlusIcon />
<span className="md:hidden">Create</span>
</Link>
</Button>
</SimpleTooltip>
<SimpleTooltip text="Edit type">
<Button variant="outline" asChild>
<Link href={`/admin/tl/ver/${t.id}/edit`}>
<PencilLine />
<span className="md:hidden">Edit</span>
</Link>
</Button>
</SimpleTooltip>
</div>
</div>
<div className="flex gap-2 flex-wrap">
{t.versions.map((e) => (
<div
key={e.id}
className={
e.image
? "relative aspect-video w-[calc(100svw-16px)] md:w-60 rounded-sm border group/ver"
: "relative flex items-center justify-center aspect-video w-[calc(100svw-16px)] md:w-60 border rounded-sm bg-[#1118] backdrop-blur-xl font-bold text-4xl group/ver"
}
>
{e.image ? (
<>
<Image
src={`/cdn/${e.image}`}
alt={e.name}
fill
className="rounded-sm border object-cover bg-primary -z-1"
/>
<div className="flex absolute w-full justify-center bottom-0 py-1 bg-card rounded-b-sm gap-1">
{e.name} <Kbd>{e.id}</Kbd>
</div>
</>
) : (
e.name
)}
<div className="md:opacity-0 group-hover/ver:opacity-100 transition-opacity flex gap-1 absolute top-1 right-1">
<SimpleTooltip text="Open in admin">
<Button variant="outline" asChild className="bg-card!">
<Link href={`/tl/${t.id}/${e.id}/admin`}>
<ExternalLink />
<span className="md:hidden">Open</span>
</Link>
</Button>
</SimpleTooltip>
<SimpleTooltip text="Edit version">
<Button variant="outline" asChild className="bg-card!">
<Link href={`/admin/tl/ver/${t.id}/${e.id}/edit`}>
<PencilLine />
<span className="md:hidden">Edit</span>
</Link>
</Button>
</SimpleTooltip>
</div>
</div>
))}
</div>
</div>
))}
</div>
);
}
+2 -2
View File
@@ -54,7 +54,7 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.26",
"input-otp": "^1.4.2",
"lucide-react": "^0.544.0",
"lucide-react": "^0.562.0",
"next": "16.0.10",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
@@ -1168,7 +1168,7 @@
"lru-cache": ["[email protected]", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["[email protected]44.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw=="],
"lucide-react": ["[email protected]62.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+18 -8
View File
@@ -270,6 +270,7 @@ type FormInputProps = {
label?: string;
subLabel?: string;
children: React.ReactElement;
className?: string;
};
export function FormInput({
@@ -278,6 +279,7 @@ export function FormInput({
children,
label,
subLabel,
className,
}: FormInputProps) {
const { values, setValue, updateValues } = useFormContext();
const childProps = children.props as Record<string, unknown>;
@@ -336,15 +338,19 @@ export function FormInput({
return (
<>
{label && (
<Label htmlFor={`forminput-${name}`}>
{label}{" "}
{subLabel && (
<span className="font-normal opacity-70">{subLabel}</span>
)}
</Label>
{label ? (
<div className={cn("flex flex-col gap-2 grow", className)}>
<Label htmlFor={`forminput-${name}`}>
{label}{" "}
{subLabel && (
<span className="font-normal opacity-70">{subLabel}</span>
)}
</Label>
{React.cloneElement(children, nextProps)}
</div>
) : (
React.cloneElement(children, nextProps)
)}
{React.cloneElement(children, nextProps)}
</>
);
}
@@ -428,3 +434,7 @@ export function FormAction({
</button>
);
}
export function FormRow({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("flex gap-2", className)} {...props} />;
}
+42
View File
@@ -0,0 +1,42 @@
services:
db:
image: pgautoupgrade/pgautoupgrade:trixie
restart: always
environment:
POSTGRES_PASSWORD: dev
PGDATA: /pgdata
volumes:
- pgdata:/pgdata
healthcheck:
test: ["CMD-SHELL", "pg_isready -q -U postgres -d postgres"]
interval: 1s
timeout: 5s
retries: 10
ports:
- 5432:5432
redis:
image: redis:latest
restart: always
ports:
- 6379:6379
volumes:
- redis:/data
command: --save 60 1 --loglevel warning
redis-commander:
depends_on:
- redis
container_name: redis-commander
hostname: redis-commander
image: ghcr.io/joeferner/redis-commander:latest
restart: always
environment:
- REDIS_HOSTS=local:redis:6379
ports:
- "6380:8081"
user: redis
volumes:
pgdata:
drizzle:
redis:
+1 -1
View File
@@ -67,7 +67,7 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.26",
"input-otp": "^1.4.2",
"lucide-react": "^0.544.0",
"lucide-react": "^0.562.0",
"next": "16.0.10",
"next-themes": "^0.4.6",
"pg": "^8.16.3",