Add admin page
This commit is contained in:
58
src/app/admin/(auth)/login/login-form.tsx
Normal file
58
src/app/admin/(auth)/login/login-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/app/admin/(auth)/login/page.tsx
Normal file
18
src/app/admin/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/app/admin/(protected)/layout.tsx
Normal file
25
src/app/admin/(protected)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/app/admin/(protected)/logout-button.tsx
Normal file
23
src/app/admin/(protected)/logout-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/admin/(protected)/page.tsx
Normal file
32
src/app/admin/(protected)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/app/api/admin/lens-portraits/[name]/route.ts
Normal file
46
src/app/api/admin/lens-portraits/[name]/route.ts
Normal 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 });
|
||||
}
|
||||
24
src/app/api/admin/lens-portraits/route.ts
Normal file
24
src/app/api/admin/lens-portraits/route.ts
Normal 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 });
|
||||
}
|
||||
24
src/app/api/admin/login/route.ts
Normal file
24
src/app/api/admin/login/route.ts
Normal 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 });
|
||||
}
|
||||
8
src/app/api/admin/logout/route.ts
Normal file
8
src/app/api/admin/logout/route.ts
Normal 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 });
|
||||
}
|
||||
18
src/app/api/admin/matches/approve-all/route.ts
Normal file
18
src/app/api/admin/matches/approve-all/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
18
src/app/api/admin/matches/approve-next/route.ts
Normal file
18
src/app/api/admin/matches/approve-next/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
16
src/app/api/admin/matches/pending/route.ts
Normal file
16
src/app/api/admin/matches/pending/route.ts
Normal 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 });
|
||||
}
|
||||
29
src/app/api/admin/matches/reject/route.ts
Normal file
29
src/app/api/admin/matches/reject/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
12
src/app/api/admin/star-sync/apply/route.ts
Normal file
12
src/app/api/admin/star-sync/apply/route.ts
Normal 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);
|
||||
}
|
||||
12
src/app/api/admin/star-sync/preview/route.ts
Normal file
12
src/app/api/admin/star-sync/preview/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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">
|
||||
206
src/components/admin/lens-portraits.tsx
Normal file
206
src/components/admin/lens-portraits.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
284
src/components/admin/pending-matches.tsx
Normal file
284
src/components/admin/pending-matches.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
src/components/admin/sync-controls.tsx
Normal file
205
src/components/admin/sync-controls.tsx
Normal 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 1–5 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`;
|
||||
},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
345
src/lib/admin-matches.ts
Normal 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 };
|
||||
}
|
||||
@@ -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
96
src/lib/star-sync.ts
Normal 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 (0–1) 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user