Files
EpicNext-Cms/src/app/admin/photos/page.tsx
T
openhands dc8fb8a6ed feat: speed up admin clone import and enable Cache Components
- Clone import: defer FurnitureData.json writes and append all entries in a
  single batched write instead of one read-modify-write per item, removing
  the main serialization bottleneck for large batches.
- Clone import: raise SSE batch concurrency cap from 5 to 10 and bump the
  clone client/route default from 2 to 6.
- Add a flush hook to runSseBatch so callers can batch deferred work before
  batch_complete is emitted, and surface flush errors as an error event.
- Enable Next.js Cache Components (instant: false opt-out) and silence the
  related build warnings in next.config.ts.
- Switch isomorphic-dompurify to dompurify and refresh dependencies.
2026-08-05 11:10:16 +02:00

176 lines
5.3 KiB
TypeScript

import { count, desc, inArray } from "drizzle-orm";
import { Camera } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { deletePhoto } from "@/actions/admin-photos";
import { AdminPageShell } from "@/components/admin/admin-page-shell";
import { StatusCard } from "@/components/admin/dashboard";
import { Button } from "@/components/ui/button";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { CameraWeb, db, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
// TODO: Cache Components adoption. Refactor this route so this opt-out can be removed.
// See: https://nextjs.org/docs/app/guides/migrating-to-cache-components
export const instant = false;
export default async function AdminPhotos({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.photos");
const parsed = parseListParams(new URLSearchParams(await searchParams));
const [totalRow] = await db
.select({ total: count() })
.from(CameraWeb)
.catch(() => [{ total: 0 }]);
const total = Number(totalRow?.total ?? 0);
const pagination = calcPagination(total, parsed.page, parsed.perPage || 24);
let photos: (typeof CameraWeb.$inferSelect)[] = [];
try {
photos = await db
.select()
.from(CameraWeb)
.orderBy(desc(CameraWeb.timestamp))
.limit(pagination.perPage)
.offset(pagination.offset);
} catch {
photos = [];
}
const userIds = [...new Set(photos.map((p) => p.userId))];
const users =
userIds.length > 0
? await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, userIds))
.catch(() => [])
: [];
const usernameById = new Map(users.map((u) => [u.id, u.username]));
const uniqueRooms = new Set(photos.map((p) => p.roomId)).size;
const uniqueUsers = new Set(photos.map((p) => p.userId)).size;
const canEdit = canAccess(permissions, PERMS.PAGES_EDIT, session.user.rank);
return (
<AdminPageShell icon={Camera} title={t("title")} subtitle={t("subtitle")}>
<p className="m-0 mb-4 text-sm text-[var(--admin-text-muted)]">
{t("purgeHint")}
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard label={t("photosShown")} value={photos.length} icon="📸" />
<StatusCard
label={t("rooms")}
value={uniqueRooms}
hint={t("distinctRooms")}
icon="🚪"
/>
<StatusCard
label={t("users")}
value={uniqueUsers}
hint={t("distinctPhotographers")}
icon="👤"
/>
<StatusCard label={t("total")} value={total} icon="🗂" />
</div>
<section className="mt-2">
<h2 className="admin-section-title">{t("recentPhotos")}</h2>
{photos.length === 0 ? (
<div className="admin-empty">{t("noPhotos")}</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{photos.map((p) => {
const username = usernameById.get(p.userId) ?? `#${p.userId}`;
return (
<div
key={String(p.id)}
className="admin-card"
style={{ padding: 0, overflow: "hidden" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={p.url}
alt={t("photoAlt", { id: String(p.id) })}
style={{
width: "100%",
height: 200,
objectFit: "cover",
display: "block",
background: "#f1f5f9",
}}
/>
<div style={{ padding: "0.85rem 1.05rem 1.05rem" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: "0.5rem",
alignItems: "baseline",
}}
>
<strong>#{String(p.id)}</strong>
<Link
href={`/admin/rooms/edit/${p.roomId}`}
className="text-sm theme-text-muted dark:theme-text-muted hover:underline"
>
{t("colRoom")} {p.roomId}
</Link>
</div>
<p
className="text-xs theme-text-muted dark:theme-text-muted"
style={{ margin: "0.3rem 0 0" }}
>
<Link
href={`/admin/users/show/${p.userId}`}
className="hover:underline"
>
{t("byUser", { username })}
</Link>{" "}
·{" "}
{new Date(p.timestamp * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " ")}
</p>
{canEdit ? (
<form
action={deletePhoto}
style={{ marginTop: "0.75rem" }}
>
<input type="hidden" name="id" value={String(p.id)} />
<Button type="submit" variant="destructive">
{t("delete")}
</Button>
</form>
) : null}
</div>
</div>
);
})}
</div>
)}
{pagination.lastPage > 1 ? (
<p className="text-sm text-muted-foreground mt-4">
{t("pageOf", {
page: pagination.page,
last: pagination.lastPage,
total,
})}
</p>
) : null}
</section>
</AdminPageShell>
);
}