Add admin page

This commit is contained in:
2026-05-03 00:05:13 +01:00
parent 95b7f72c3e
commit a80b2d83db
28 changed files with 1551 additions and 12 deletions

View File

@@ -0,0 +1,58 @@
"use client";
import { Button, Input, Label, TextField } from "@heroui/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function LoginForm() {
const router = useRouter();
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const res = await fetch("/api/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Login failed");
}
router.replace("/admin");
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Login failed");
setSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<TextField
name="password"
type="password"
onChange={setPassword}
autoFocus
isRequired
>
<Label>Password</Label>
<Input placeholder="••••••••" value={password} />
</TextField>
{error && <p className="text-danger text-sm">{error}</p>}
<Button type="submit" variant="primary" isDisabled={submitting}>
{submitting ? "Signing in..." : "Sign in"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,18 @@
import { redirect } from "next/navigation";
import { isAdmin } from "@/lib/admin-auth";
import { LoginForm } from "./login-form";
export default async function AdminLoginPage() {
if (await isAdmin()) {
redirect("/admin");
}
return (
<div className="flex flex-1 items-center justify-center px-4 py-16">
<div className="flex flex-col gap-6 w-full max-w-sm">
<h1 className="text-2xl font-bold">Admin login</h1>
<LoginForm />
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { isAdmin } from "@/lib/admin-auth";
import { LogoutButton } from "./logout-button";
export default async function ProtectedAdminLayout({
children,
}: {
children: React.ReactNode;
}) {
if (!(await isAdmin())) {
redirect("/admin/login");
}
return (
<div className="flex flex-col flex-1">
<div className="flex items-center justify-between px-6 py-3 border-b border-separator">
<span className="text-sm font-semibold uppercase tracking-widest text-muted">
Admin
</span>
<LogoutButton />
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import { Button } from "@heroui/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function LogoutButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
async function logout() {
setPending(true);
await fetch("/api/admin/logout", { method: "POST" });
router.replace("/admin/login");
router.refresh();
}
return (
<Button variant="secondary" size="sm" onPress={logout} isDisabled={pending}>
{pending ? "Signing out..." : "Sign out"}
</Button>
);
}

View File

@@ -0,0 +1,32 @@
import { LensPortraits } from "@/components/admin/lens-portraits";
import { PendingMatches } from "@/components/admin/pending-matches";
import { SyncControls } from "@/components/admin/sync-controls";
export default function AdminHomePage() {
return (
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-10">
<h1 className="text-3xl font-bold">Admin</h1>
<section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted uppercase tracking-widest">
Sync
</h2>
<SyncControls />
</section>
<section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted uppercase tracking-widest">
Pending matches
</h2>
<PendingMatches />
</section>
<section className="flex flex-col gap-4">
<h2 className="text-sm font-semibold text-muted uppercase tracking-widest">
Lens portraits
</h2>
<LensPortraits />
</section>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import type { NextRequest } from "next/server";
import { isAdmin } from "@/lib/admin-auth";
import { deleteLensPortrait, setLensPortrait } from "@/lib/lens-queries";
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
if (!(await isAdmin())) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { name } = await params;
const lensModel = decodeURIComponent(name);
const body = await request.json();
const assetId = typeof body?.assetId === "string" ? body.assetId.trim() : "";
if (!assetId) {
return Response.json({ error: "Missing assetId" }, { status: 400 });
}
try {
const portrait = await setLensPortrait(lensModel, assetId);
return Response.json(portrait);
} catch (e) {
return Response.json(
{ error: e instanceof Error ? e.message : "Failed to set portrait" },
{ status: 400 },
);
}
}
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ name: string }> },
) {
if (!(await isAdmin())) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { name } = await params;
const lensModel = decodeURIComponent(name);
await deleteLensPortrait(lensModel);
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,24 @@
import { isAdmin } from "@/lib/admin-auth";
import { getLensPortraits } from "@/lib/lens-queries";
import { getLensStats } from "@/lib/stats";
export async function GET() {
if (!(await isAdmin())) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const lenses = await getLensStats();
const lensModels = lenses.map((l) => l.lensModel);
const portraits = await getLensPortraits(lensModels);
const items = lenses.map((lens) => {
const portrait = portraits.get(lens.lensModel) ?? null;
return {
lensModel: lens.lensModel,
assetCount: lens.assetCount,
portrait,
};
});
return Response.json({ items });
}

View File

@@ -0,0 +1,24 @@
import { cookies } from "next/headers";
import type { NextRequest } from "next/server";
import { ADMIN_COOKIE, checkPassword } from "@/lib/admin-auth";
const SEVEN_DAYS = 60 * 60 * 24 * 7;
export async function POST(request: NextRequest) {
const { password } = await request.json();
if (typeof password !== "string" || !checkPassword(password)) {
return Response.json({ error: "Invalid password" }, { status: 401 });
}
const cookieStore = await cookies();
cookieStore.set(ADMIN_COOKIE, password, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: SEVEN_DAYS,
});
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,8 @@
import { cookies } from "next/headers";
import { ADMIN_COOKIE } from "@/lib/admin-auth";
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete(ADMIN_COOKIE);
return Response.json({ ok: true });
}

View File

@@ -0,0 +1,18 @@
import { isAdmin } from "@/lib/admin-auth";
import { approveAllPending } from "@/lib/admin-matches";
export async function POST() {
if (!(await isAdmin())) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const result = await approveAllPending();
return Response.json(result);
} catch (e) {
return Response.json(
{ error: e instanceof Error ? e.message : "Approve failed" },
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,18 @@
import { isAdmin } from "@/lib/admin-auth";
import { approveOldestPending } from "@/lib/admin-matches";
export async function POST() {
if (!(await isAdmin())) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const result = await approveOldestPending();
return Response.json(result);
} catch (e) {
return Response.json(
{ error: e instanceof Error ? e.message : "Approve failed" },
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,16 @@
import type { NextRequest } from "next/server";
import { isAdmin } from "@/lib/admin-auth";
import { getPendingCount, getPendingMatches } from "@/lib/admin-matches";
export async function GET(request: NextRequest) {
if (!(await isAdmin())) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const page = Number(request.nextUrl.searchParams.get("page") ?? "0");
const [matches, count] = await Promise.all([
getPendingMatches(page),
getPendingCount(),
]);
return Response.json({ matches, count });
}

View File

@@ -0,0 +1,29 @@
import type { NextRequest } from "next/server";
import { isAdmin } from "@/lib/admin-auth";
import { rejectMatches } from "@/lib/admin-matches";
export async function POST(request: NextRequest) {
if (!(await isAdmin())) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const ids = Array.isArray(body?.ids) ? body.ids : [];
const matchIds = ids
.map((v: unknown) => Number(v))
.filter((n: number) => Number.isFinite(n));
if (matchIds.length === 0) {
return Response.json({ error: "No valid ids provided" }, { status: 400 });
}
try {
const result = await rejectMatches(matchIds);
return Response.json(result);
} catch (e) {
return Response.json(
{ error: e instanceof Error ? e.message : "Reject failed" },
{ status: 400 },
);
}
}

View File

@@ -0,0 +1,12 @@
import { isAdmin } from "@/lib/admin-auth";
import { applyStarRatings, computeStarRatings } from "@/lib/star-sync";
export async function POST() {
if (!(await isAdmin())) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { mappings } = await computeStarRatings();
const result = await applyStarRatings(mappings);
return Response.json(result);
}

View File

@@ -0,0 +1,12 @@
import { isAdmin } from "@/lib/admin-auth";
import { computeStarRatings } from "@/lib/star-sync";
export async function POST() {
if (!(await isAdmin())) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const preview = await computeStarRatings();
// Return only summary info to keep payload small; mappings are recomputed on apply
return Response.json({ total: preview.total, buckets: preview.buckets });
}

View File

@@ -2,7 +2,11 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { LensAssetMasonry } from "@/components/lens-asset-masonry";
import { formatElo } from "@/lib/format";
import { getLensAssets, getLensDetail, getLensPortrait } from "@/lib/lens-queries";
import {
getLensAssets,
getLensDetail,
getLensPortrait,
} from "@/lib/lens-queries";
export const revalidate = 60;
@@ -25,7 +29,10 @@ export default async function LensPage({
return (
<div className="flex flex-col flex-1 max-w-7xl mx-auto w-full px-4 py-8 gap-10">
<header className="flex flex-col gap-2">
<Link href="/lens" className="text-sm text-muted hover:text-foreground">
<Link
href="/lenses"
className="text-sm text-muted hover:text-foreground"
>
All lenses
</Link>
<h1 className="text-3xl font-bold">{detail.lensModel}</h1>
@@ -44,7 +51,7 @@ export default async function LensPage({
<span className="text-xs text-muted">
Photographed with{" "}
<Link
href={`/lens/${encodeURIComponent(portrait.takenWithLens)}`}
href={`/lenses/${encodeURIComponent(portrait.takenWithLens)}`}
className="hover:underline"
>
{portrait.takenWithLens}

View File

@@ -22,7 +22,7 @@ export default async function LensIndexPage() {
return (
<Link
key={lens.lensModel}
href={`/lens/${encodeURIComponent(lens.lensModel)}`}
href={`/lenses/${encodeURIComponent(lens.lensModel)}`}
className="flex flex-col gap-3 p-4 rounded-lg bg-surface hover:bg-surface-hover transition-colors"
>
<div className="flex items-start gap-3">

View File

@@ -0,0 +1,206 @@
"use client";
import { Button, Input, Label, TextField } from "@heroui/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import useSWR from "swr";
interface LensPortraitItem {
lensModel: string;
assetCount: number;
portrait: {
assetId: string;
aspectRatio: number;
takenWithLens: string | null;
} | null;
}
interface LensPortraitsResponse {
items: LensPortraitItem[];
}
const fetcher = (url: string): Promise<LensPortraitsResponse> =>
fetch(url).then((r) => r.json());
export function LensPortraits() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <p className="text-sm text-muted">Loading</p>;
}
return <LensPortraitsInner />;
}
function LensPortraitsInner() {
const { data, isLoading, mutate } = useSWR<LensPortraitsResponse>(
"/api/admin/lens-portraits",
fetcher,
);
const items = data?.items ?? [];
if (isLoading) {
return <p className="text-sm text-muted">Loading</p>;
}
if (items.length === 0) {
return <p className="text-sm text-muted">No lenses synced yet.</p>;
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{items.map((item) => (
<LensPortraitCard
key={item.lensModel}
item={item}
onChange={() => mutate()}
/>
))}
</div>
);
}
function LensPortraitCard({
item,
onChange,
}: {
item: LensPortraitItem;
onChange: () => void;
}) {
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function set() {
if (!input.trim()) return;
setBusy(true);
setError(null);
try {
const res = await fetch(
`/api/admin/lens-portraits/${encodeURIComponent(item.lensModel)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ assetId: input.trim() }),
},
);
const json = await res.json();
if (!res.ok) throw new Error(json.error ?? "Failed to set");
setInput("");
onChange();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to set");
} finally {
setBusy(false);
}
}
async function clear() {
setBusy(true);
setError(null);
try {
const res = await fetch(
`/api/admin/lens-portraits/${encodeURIComponent(item.lensModel)}`,
{ method: "DELETE" },
);
if (!res.ok) {
const json = await res.json();
throw new Error(json.error ?? "Failed to clear");
}
onChange();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to clear");
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col gap-3 p-4 rounded-lg bg-surface border border-separator">
<div className="flex items-start gap-3">
{item.portrait ? (
<img
src={`/img/${item.portrait.assetId}`}
alt=""
className="h-20 rounded object-contain shrink-0"
style={{ aspectRatio: item.portrait.aspectRatio }}
/>
) : (
<div className="h-20 w-20 rounded bg-default shrink-0 flex items-center justify-center">
<span className="text-[10px] text-muted text-center px-2">
No portrait
</span>
</div>
)}
<div className="flex flex-col flex-1 min-w-0 gap-1">
<h3 className="text-sm font-semibold truncate" title={item.lensModel}>
{item.lensModel}
</h3>
<span className="text-xs text-muted">
{item.assetCount} {item.assetCount === 1 ? "asset" : "assets"}
</span>
{item.portrait?.takenWithLens && (
<span className="text-xs text-muted">
Taken with{" "}
<Link
href={`/lenses/${encodeURIComponent(item.portrait.takenWithLens)}`}
className="hover:underline"
>
{item.portrait.takenWithLens}
</Link>
</span>
)}
{item.portrait && (
<span className="font-mono text-[10px] text-muted truncate">
{item.portrait.assetId}
</span>
)}
</div>
</div>
<div className="flex items-end gap-2">
<TextField
name="assetId"
onChange={setInput}
className="flex-1"
isDisabled={busy}
>
<Label className="text-xs">Immich asset ID</Label>
<Input
placeholder="00000000-0000-0000-0000-000000000000"
value={input}
/>
</TextField>
<Button
variant="primary"
size="sm"
onPress={set}
isDisabled={busy || !input.trim()}
>
{item.portrait ? "Replace" : "Set"}
</Button>
{item.portrait && (
<Button
variant="secondary"
size="sm"
onPress={clear}
isDisabled={busy}
>
Clear
</Button>
)}
</div>
<p
className={`text-xs h-4 ${error ? "text-danger" : "text-muted"}`}
aria-live="polite"
>
{error ?? " "}
</p>
</div>
);
}

View File

@@ -0,0 +1,284 @@
"use client";
import { Button, Checkbox, type Selection, Table } from "@heroui/react";
import Link from "next/link";
import { useEffect, useState } from "react";
import useSWR from "swr";
import { TimeAgo } from "@/components/time-ago";
import type { PendingMatchEntry } from "@/lib/admin-matches";
import { formatElo } from "@/lib/format";
interface PendingResponse {
matches: PendingMatchEntry[];
count: number;
}
const fetcher = (url: string): Promise<PendingResponse> =>
fetch(url).then((r) => r.json());
export function PendingMatches() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <p className="text-sm text-muted">Loading</p>;
}
return <PendingMatchesInner />;
}
function PendingMatchesInner() {
const [page, setPage] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set());
const { data, isLoading, mutate } = useSWR<PendingResponse>(
`/api/admin/matches/pending?page=${page}`,
fetcher,
);
const matches = data?.matches ?? [];
const total = data?.count ?? 0;
const isFirstPage = page === 0;
// Reset selection on page change
useEffect(() => {
setSelectedKeys(new Set());
}, [page]);
function resolveSelectedIds(): number[] {
if (selectedKeys === "all") return matches.map((m) => m.id);
return [...selectedKeys].map((k) => Number(k)).filter(Number.isFinite);
}
const selectedCount =
selectedKeys === "all" ? matches.length : selectedKeys.size;
async function action(
url: string,
body: object | null,
successMessage: (json: unknown) => string,
) {
if (busy) return;
setBusy(true);
setError(null);
setMessage(null);
try {
const res = await fetch(url, {
method: "POST",
...(body && {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error ?? "Action failed");
setMessage(successMessage(json));
setSelectedKeys(new Set());
await mutate();
} catch (e) {
setError(e instanceof Error ? e.message : "Action failed");
} finally {
setBusy(false);
}
}
const buttonsDisabled = busy || isLoading;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<p className="text-sm text-muted">
{total} pending {total === 1 ? "match" : "matches"}
{selectedCount > 0 && (
<span className="ml-2">· {selectedCount} selected</span>
)}
</p>
<div className="flex gap-2 flex-wrap">
<Button
variant="primary"
onPress={() =>
action("/api/admin/matches/approve-next", null, (j) =>
(j as { approvedId: number | null }).approvedId !== null
? "Approved 1 match"
: "Nothing to approve",
)
}
isDisabled={buttonsDisabled || total === 0 || !isFirstPage}
>
Approve next
</Button>
<Button
variant="secondary"
onPress={() =>
action(
"/api/admin/matches/approve-all",
null,
(j) =>
`Approved ${(j as { approvedCount: number }).approvedCount} matches`,
)
}
isDisabled={buttonsDisabled || total === 0}
>
Approve all
</Button>
<Button
variant="secondary"
onPress={() =>
action(
"/api/admin/matches/reject",
{ ids: resolveSelectedIds() },
(j) =>
`Rejected ${(j as { rejectedCount: number }).rejectedCount} matches`,
)
}
isDisabled={buttonsDisabled || selectedCount === 0}
>
Reject selected
</Button>
</div>
</div>
{!isFirstPage && (
<p className="text-xs text-muted">
Approve buttons are only available on page 1 to keep verification
chronological.
</p>
)}
<p
className={`text-sm h-5 ${error ? "text-danger" : "text-success"}`}
aria-live="polite"
>
{error ?? message ?? " "}
</p>
<Table>
<Table.ScrollContainer>
<Table.Content
aria-label="Pending matches"
selectionMode="multiple"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<Table.Header>
<Table.Column className="pr-0">
<Checkbox aria-label="Select all" slot="selection">
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
</Checkbox>
</Table.Column>
<Table.Column isRowHeader className="text-center">
Left
</Table.Column>
<Table.Column className="text-center tabular-nums">
ELO
</Table.Column>
<Table.Column className="text-center">±</Table.Column>
<Table.Column className="text-center tabular-nums">
ELO
</Table.Column>
<Table.Column className="text-center">Right</Table.Column>
<Table.Column>IP</Table.Column>
<Table.Column>When</Table.Column>
</Table.Header>
<Table.Body>
{matches.map((m) => {
const winningSide = m.winnerId === m.leftId ? "left" : "right";
const leftEloAfter =
winningSide === "left" ? m.winnerEloAfter : m.loserEloAfter;
const rightEloAfter =
winningSide === "right" ? m.winnerEloAfter : m.loserEloAfter;
return (
<Table.Row key={m.id} id={m.id}>
<Table.Cell className="pr-0">
<Checkbox
aria-label={`Select match ${m.id}`}
slot="selection"
variant="secondary"
>
<Checkbox.Control>
<Checkbox.Indicator />
</Checkbox.Control>
</Checkbox>
</Table.Cell>
<Table.Cell className="text-center">
<Link
href={`/asset/${m.leftId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${m.leftId}`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: m.leftAspectRatio }}
/>
</Link>
</Table.Cell>
<Table.Cell
className={`text-center text-xs tabular-nums font-semibold ${winningSide === "left" ? "text-success" : "text-danger"}`}
>
{formatElo(leftEloAfter)}
</Table.Cell>
<Table.Cell className="text-center tabular-nums text-sm text-muted">
±{m.eloDelta.toFixed(1)}
</Table.Cell>
<Table.Cell
className={`text-center text-xs tabular-nums font-semibold ${winningSide === "right" ? "text-success" : "text-danger"}`}
>
{formatElo(rightEloAfter)}
</Table.Cell>
<Table.Cell className="text-center">
<Link
href={`/asset/${m.rightId}`}
className="inline-flex justify-center"
>
<img
src={`/img/${m.rightId}`}
alt=""
className="h-8 rounded object-contain"
style={{ aspectRatio: m.rightAspectRatio }}
/>
</Link>
</Table.Cell>
<Table.Cell className="font-mono text-xs text-muted">
{m.voterIp}
</Table.Cell>
<Table.Cell>
<TimeAgo date={m.createdAt} />
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table.Content>
</Table.ScrollContainer>
</Table>
<div className="flex items-center justify-between">
<Button
variant="secondary"
size="sm"
onPress={() => setPage((p) => Math.max(0, p - 1))}
isDisabled={buttonsDisabled || isFirstPage}
>
Previous
</Button>
<span className="text-xs text-muted">Page {page + 1}</span>
<Button
variant="secondary"
size="sm"
onPress={() => setPage((p) => p + 1)}
isDisabled={buttonsDisabled || matches.length < 20}
>
Next
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
"use client";
import { Button } from "@heroui/react";
import { useState } from "react";
interface ImmichSyncResult {
inserted: number;
updated: number;
deactivated: number;
total: number;
}
interface StarPreview {
total: number;
buckets: Record<1 | 2 | 3 | 4 | 5, number>;
}
interface ApplyResult {
total: number;
succeeded: number;
failed: number;
errors: Array<{ assetId: string; error: string }>;
}
export function SyncControls() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
<ImmichResyncCard />
<StarSyncCard />
</div>
);
}
function Card({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-4 p-5 rounded-lg bg-surface border border-separator">
<div className="flex flex-col gap-1">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted">{description}</p>
</div>
{children}
</div>
);
}
function ImmichResyncCard() {
const [pending, setPending] = useState(false);
const [result, setResult] = useState<ImmichSyncResult | null>(null);
const [error, setError] = useState<string | null>(null);
async function run() {
setPending(true);
setError(null);
setResult(null);
try {
const res = await fetch("/api/admin/sync", { method: "POST" });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Sync failed");
}
setResult(await res.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Sync failed");
} finally {
setPending(false);
}
}
return (
<Card
title="Re-sync from Immich"
description="Pulls the latest assets from the portfolio album. New assets are seeded; existing ones keep their ELO and match history."
>
<Button
variant="primary"
onPress={run}
isDisabled={pending}
className="self-start"
>
{pending ? "Syncing..." : "Re-sync"}
</Button>
{error && <p className="text-danger text-sm">{error}</p>}
{result && (
<div className="text-sm flex flex-col gap-1 text-muted">
<span>{result.inserted} inserted</span>
<span>{result.updated} updated</span>
<span>{result.deactivated} deactivated</span>
<span>{result.total} total in album</span>
</div>
)}
</Card>
);
}
function StarSyncCard() {
const [previewing, setPreviewing] = useState(false);
const [applying, setApplying] = useState(false);
const [preview, setPreview] = useState<StarPreview | null>(null);
const [result, setResult] = useState<ApplyResult | null>(null);
const [error, setError] = useState<string | null>(null);
async function runPreview() {
setPreviewing(true);
setError(null);
setResult(null);
try {
const res = await fetch("/api/admin/star-sync/preview", {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Preview failed");
}
setPreview(await res.json());
} catch (e) {
setError(e instanceof Error ? e.message : "Preview failed");
} finally {
setPreviewing(false);
}
}
async function runApply() {
setApplying(true);
setError(null);
try {
const res = await fetch("/api/admin/star-sync/apply", {
method: "POST",
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Apply failed");
}
setResult(await res.json());
setPreview(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Apply failed");
} finally {
setApplying(false);
}
}
return (
<Card
title="Sync ratings to Immich"
description="Maps verified ELOs to a 15 star bell distribution and PATCHes Immich. Preview first to see the bucket counts."
>
<div className="flex gap-2">
<Button
variant="secondary"
onPress={runPreview}
isDisabled={previewing || applying}
>
{previewing ? "Computing..." : "Preview"}
</Button>
<Button
variant="primary"
onPress={runApply}
isDisabled={applying || previewing || !preview}
>
{applying ? "Applying..." : "Apply"}
</Button>
</div>
{error && <p className="text-danger text-sm">{error}</p>}
{preview && (
<div className="flex flex-col gap-1 text-sm">
<span className="text-muted">
{preview.total} assets to be updated:
</span>
{([5, 4, 3, 2, 1] as const).map((stars) => (
<span key={stars} className="tabular-nums">
{"★".repeat(stars)}
{"☆".repeat(5 - stars)} {preview.buckets[stars]}
</span>
))}
</div>
)}
{result && (
<div className="flex flex-col gap-1 text-sm">
<span className="text-success">
{result.succeeded}/{result.total} updated
</span>
{result.failed > 0 && (
<span className="text-danger">{result.failed} failed</span>
)}
{result.errors.slice(0, 3).map((e) => (
<span key={e.assetId} className="text-xs text-muted">
{e.assetId.slice(0, 8)}: {e.error}
</span>
))}
</div>
)}
</Card>
);
}

View File

@@ -31,7 +31,7 @@ export function LensAssetMasonry({
(pageIndex, previousPageData) => {
if (previousPageData && previousPageData.length < LENS_ASSETS_PAGE_SIZE)
return null;
return `/api/lens/${encodeURIComponent(
return `/api/lenses/${encodeURIComponent(
lensModel,
)}/assets?page=${pageIndex}&sortBy=${sortBy}&direction=${direction}`;
},

View File

@@ -3,7 +3,7 @@ import Link from "next/link";
const links = [
{ href: "/", label: "Vote" },
{ href: "/stats", label: "Stats" },
{ href: "/lens", label: "Lenses" },
{ href: "/lenses", label: "Lenses" },
] as const;
export function Nav() {

View File

@@ -29,7 +29,7 @@ export function LensTable({ data }: { data: LensStats[] }) {
<Table.Row key={lens.lensModel}>
<Table.Cell className="font-medium text-sm">
<Link
href={`/lens/${encodeURIComponent(lens.lensModel)}`}
href={`/lenses/${encodeURIComponent(lens.lensModel)}`}
className="hover:underline"
>
{lens.lensModel}

View File

@@ -1,9 +1,13 @@
import { cookies } from "next/headers";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD!;
const COOKIE_NAME = "rms-admin";
export const ADMIN_COOKIE = "rms-admin";
export async function isAdmin(): Promise<boolean> {
const cookieStore = await cookies();
return cookieStore.get(COOKIE_NAME)?.value === ADMIN_PASSWORD;
return cookieStore.get(ADMIN_COOKIE)?.value === ADMIN_PASSWORD;
}
export function checkPassword(password: string): boolean {
return password === ADMIN_PASSWORD;
}

345
src/lib/admin-matches.ts Normal file
View File

@@ -0,0 +1,345 @@
import { and, asc, eq, sql } from "drizzle-orm";
import { db } from "@/db";
import { assets, eloHistory, matches, pairings } from "@/db/schema";
import { calculateElo } from "./elo";
const PAGE_SIZE = 20;
export interface PendingMatchEntry {
id: number;
leftId: string;
rightId: string;
leftAspectRatio: number;
rightAspectRatio: number;
winnerId: string;
winnerEloBefore: number;
loserEloBefore: number;
winnerEloAfter: number;
loserEloAfter: number;
eloDelta: number;
voterIp: string;
createdAt: string;
}
export async function getPendingMatches(
page = 0,
limit = PAGE_SIZE,
): Promise<PendingMatchEntry[]> {
const left = db
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
})
.from(assets)
.as("left");
const right = db
.select({
id: assets.id,
aspectRatio: assets.aspectRatio,
})
.from(assets)
.as("right");
const rows = await db
.select({
id: matches.id,
leftId: pairings.assetAId,
rightId: pairings.assetBId,
leftAspectRatio: left.aspectRatio,
rightAspectRatio: right.aspectRatio,
winnerId: matches.winnerId,
winnerEloBefore: matches.winnerEloBefore,
loserEloBefore: matches.loserEloBefore,
voterIp: pairings.voterIp,
createdAt: matches.createdAt,
})
.from(matches)
.innerJoin(pairings, eq(matches.pairingUuid, pairings.uuid))
.innerJoin(left, eq(pairings.assetAId, left.id))
.innerJoin(right, eq(pairings.assetBId, right.id))
.where(eq(matches.verified, false))
.orderBy(asc(matches.createdAt))
.limit(limit)
.offset(page * limit);
return rows.map((r) => {
const { newWinnerElo, newLoserElo } = calculateElo(
r.winnerEloBefore,
r.loserEloBefore,
);
return {
...r,
winnerEloAfter: newWinnerElo,
loserEloAfter: newLoserElo,
eloDelta: newWinnerElo - r.winnerEloBefore,
};
});
}
export async function getPendingCount(): Promise<number> {
const [row] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(matches)
.where(eq(matches.verified, false));
return row?.count ?? 0;
}
/**
* Approve a single pending match by ID. Caller must ensure it's the oldest pending.
* Applies the match's outcome to verified ELO and increments verified counters.
*/
async function approveMatch(matchId: number) {
return db.transaction((tx) => {
const [match] = tx
.select()
.from(matches)
.where(eq(matches.id, matchId))
.all();
if (!match) throw new Error("Match not found");
if (match.verified) throw new Error("Match already verified");
// Validate: must be the oldest pending match
const [oldest] = tx
.select({ id: matches.id })
.from(matches)
.where(eq(matches.verified, false))
.orderBy(asc(matches.createdAt))
.limit(1)
.all();
if (!oldest || oldest.id !== matchId) {
throw new Error("Can only approve the oldest pending match");
}
const [winner] = tx
.select()
.from(assets)
.where(eq(assets.id, match.winnerId))
.all();
const [loser] = tx
.select()
.from(assets)
.where(eq(assets.id, match.loserId))
.all();
if (!winner || !loser) throw new Error("Asset not found");
const { newWinnerElo, newLoserElo } = calculateElo(
winner.eloVerified,
loser.eloVerified,
);
tx.update(assets)
.set({
eloVerified: newWinnerElo,
matchesVerified: winner.matchesVerified + 1,
winsVerified: winner.winsVerified + 1,
})
.where(eq(assets.id, winner.id))
.run();
tx.update(assets)
.set({
eloVerified: newLoserElo,
matchesVerified: loser.matchesVerified + 1,
})
.where(eq(assets.id, loser.id))
.run();
tx.update(matches)
.set({ verified: true })
.where(eq(matches.id, matchId))
.run();
tx.insert(eloHistory)
.values([
{
assetId: winner.id,
elo: newWinnerElo,
eloType: "verified",
matchId: match.id,
},
{
assetId: loser.id,
elo: newLoserElo,
eloType: "verified",
matchId: match.id,
},
])
.run();
});
}
export async function approveOldestPending(): Promise<{
approvedId: number | null;
}> {
const [oldest] = await db
.select({ id: matches.id })
.from(matches)
.where(eq(matches.verified, false))
.orderBy(asc(matches.createdAt))
.limit(1);
if (!oldest) return { approvedId: null };
await approveMatch(oldest.id);
return { approvedId: oldest.id };
}
export async function approveAllPending(): Promise<{ approvedCount: number }> {
let approvedCount = 0;
// Approve one at a time so the verified ELO chain is built incrementally.
// Each approval is its own transaction; if one fails, prior approvals stay.
while (true) {
const [oldest] = await db
.select({ id: matches.id })
.from(matches)
.where(eq(matches.verified, false))
.orderBy(asc(matches.createdAt))
.limit(1);
if (!oldest) break;
await approveMatch(oldest.id);
approvedCount++;
}
return { approvedCount };
}
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
function deletePendingMatchInTx(tx: Tx, matchId: number) {
const [match] = tx
.select()
.from(matches)
.where(eq(matches.id, matchId))
.all();
if (!match) throw new Error(`Match ${matchId} not found`);
if (match.verified)
throw new Error(`Cannot reject verified match ${matchId}`);
tx.delete(eloHistory)
.where(
and(
eq(eloHistory.matchId, matchId),
eq(eloHistory.eloType, "provisional"),
),
)
.run();
tx.update(assets)
.set({
matchesProvisional: sql`${assets.matchesProvisional} - 1`,
winsProvisional: sql`${assets.winsProvisional} - 1`,
})
.where(eq(assets.id, match.winnerId))
.run();
tx.update(assets)
.set({ matchesProvisional: sql`${assets.matchesProvisional} - 1` })
.where(eq(assets.id, match.loserId))
.run();
tx.delete(matches).where(eq(matches.id, matchId)).run();
}
function recomputeProvisionalInTx(tx: Tx) {
// Reset all assets' provisional ELO to their verified ELO.
tx.update(assets)
.set({ eloProvisional: sql`${assets.eloVerified}` })
.run();
// Replay remaining pending matches in chronological order.
const pending = tx
.select({
id: matches.id,
winnerId: matches.winnerId,
loserId: matches.loserId,
})
.from(matches)
.where(eq(matches.verified, false))
.orderBy(asc(matches.createdAt))
.all();
for (const p of pending) {
const [w] = tx
.select({ eloProvisional: assets.eloProvisional })
.from(assets)
.where(eq(assets.id, p.winnerId))
.all();
const [l] = tx
.select({ eloProvisional: assets.eloProvisional })
.from(assets)
.where(eq(assets.id, p.loserId))
.all();
if (!w || !l) continue;
const { newWinnerElo, newLoserElo } = calculateElo(
w.eloProvisional,
l.eloProvisional,
);
tx.update(matches)
.set({
winnerEloBefore: w.eloProvisional,
loserEloBefore: l.eloProvisional,
})
.where(eq(matches.id, p.id))
.run();
tx.update(assets)
.set({ eloProvisional: newWinnerElo })
.where(eq(assets.id, p.winnerId))
.run();
tx.update(assets)
.set({ eloProvisional: newLoserElo })
.where(eq(assets.id, p.loserId))
.run();
tx.delete(eloHistory)
.where(
and(
eq(eloHistory.matchId, p.id),
eq(eloHistory.eloType, "provisional"),
),
)
.run();
tx.insert(eloHistory)
.values([
{
assetId: p.winnerId,
elo: newWinnerElo,
eloType: "provisional",
matchId: p.id,
},
{
assetId: p.loserId,
elo: newLoserElo,
eloType: "provisional",
matchId: p.id,
},
])
.run();
}
}
/**
* Reject (delete) one or more pending matches. All deletions happen first,
* then provisional ELO is recomputed once at the end.
*/
export async function rejectMatches(
matchIds: number[],
): Promise<{ rejectedCount: number }> {
if (matchIds.length === 0) return { rejectedCount: 0 };
await db.transaction((tx) => {
for (const id of matchIds) {
deletePendingMatchInTx(tx, id);
}
recomputeProvisionalInTx(tx);
});
return { rejectedCount: matchIds.length };
}

View File

@@ -65,14 +65,44 @@ export async function getLensPortraits(
);
const hydrated = await Promise.all(
rows.map(async (r) => [r.lensModel, await hydratePortrait(r.assetId)] as const),
rows.map(
async (r) => [r.lensModel, await hydratePortrait(r.assetId)] as const,
),
);
return new Map(
hydrated.filter((entry): entry is [string, LensPortrait] => entry[1] !== null),
hydrated.filter(
(entry): entry is [string, LensPortrait] => entry[1] !== null,
),
);
}
/** Set or replace the portrait asset for a lens. Validates the asset exists in Immich. */
export async function setLensPortrait(
lensModel: string,
assetId: string,
): Promise<LensPortrait> {
const asset = await fetchAsset(assetId);
await db
.insert(lensPortraits)
.values({ lensModel, assetId })
.onConflictDoUpdate({
target: lensPortraits.lensModel,
set: { assetId },
});
return {
assetId,
aspectRatio: asset.width / asset.height,
takenWithLens: asset.exifInfo.lensModel,
};
}
export async function deleteLensPortrait(lensModel: string): Promise<void> {
await db.delete(lensPortraits).where(eq(lensPortraits.lensModel, lensModel));
}
export interface LensDetail {
lensModel: string;
assetCount: number;
@@ -140,4 +170,3 @@ export async function getLensAssets(
.limit(limit)
.offset(page * limit);
}

96
src/lib/star-sync.ts Normal file
View File

@@ -0,0 +1,96 @@
import { desc } from "drizzle-orm";
import { db } from "@/db";
import { assets } from "@/db/schema";
import { updateAssetRating } from "./immich";
// Percentile thresholds matching a normal distribution.
// Each entry is the upper bound of the percentile (01) for its star rating.
const PERCENTILE_THRESHOLDS = [
{ stars: 1, upperBound: 0.0668 }, // bottom ~7%
{ stars: 2, upperBound: 0.3085 }, // next ~24%
{ stars: 3, upperBound: 0.6915 }, // middle ~38%
{ stars: 4, upperBound: 0.9332 }, // next ~24%
{ stars: 5, upperBound: 1.0 }, // top ~7%
] as const;
export interface StarMapping {
assetId: string;
eloVerified: number;
stars: 1 | 2 | 3 | 4 | 5;
}
export interface StarPreview {
total: number;
buckets: Record<1 | 2 | 3 | 4 | 5, number>;
mappings: StarMapping[];
}
export async function computeStarRatings(): Promise<StarPreview> {
const rows = await db
.select({
id: assets.id,
eloVerified: assets.eloVerified,
})
.from(assets)
.orderBy(desc(assets.eloVerified));
const total = rows.length;
const buckets: Record<1 | 2 | 3 | 4 | 5, number> = {
1: 0,
2: 0,
3: 0,
4: 0,
5: 0,
};
const mappings: StarMapping[] = [];
// rows are sorted descending by ELO, so rank 0 = highest, rank total-1 = lowest
// percentile-from-top = (rank + 0.5) / total → percentile-from-bottom = 1 - that
rows.forEach((row, i) => {
const percentileFromBottom = 1 - (i + 0.5) / total;
const stars =
PERCENTILE_THRESHOLDS.find((t) => percentileFromBottom <= t.upperBound)
?.stars ?? 3;
buckets[stars]++;
mappings.push({
assetId: row.id,
eloVerified: row.eloVerified,
stars,
});
});
return { total, buckets, mappings };
}
export interface ApplyResult {
total: number;
succeeded: number;
failed: number;
errors: Array<{ assetId: string; error: string }>;
}
export async function applyStarRatings(
mappings: StarMapping[],
): Promise<ApplyResult> {
let succeeded = 0;
const errors: Array<{ assetId: string; error: string }> = [];
for (const m of mappings) {
try {
await updateAssetRating(m.assetId, m.stars);
succeeded++;
} catch (e) {
errors.push({
assetId: m.assetId,
error: e instanceof Error ? e.message : String(e),
});
}
}
return {
total: mappings.length,
succeeded,
failed: errors.length,
errors,
};
}