Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
{"sessionId":"4e3e606d-0a01-443a-a148-f45c4201f93c","pid":40700,"acquiredAt":1777427820346}
+20
View File
@@ -0,0 +1,20 @@
node_modules/
.next/
out/
dist/
*.log
.DS_Store
.env*.local
data/library.db*
data/thumbs/
data/category-covers/
data/collection-covers/
data/portraits/
data/subtitle-cache/
data/whisperjav-jobs/
library/
tsconfig.tsbuildinfo
.idea/
.vscode/
.vercel
next-env.d.ts
+1
View File
@@ -0,0 +1 @@
enable-pre-post-scripts=true
+599
View File
@@ -0,0 +1,599 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Pinkudex — Actress rating banner mockup</title>
<style>
:root {
--bg-0: #0b0d10;
--bg-1: #14171c;
--bg-2: #1c2027;
--bg-3: #252a33;
--fg: #e6e8ec;
--fg-dim: #a4abb6;
--fg-muted: #6b7380;
--cyan: #22d3ee;
--mint: #34d399;
--amber: #fbbf24;
--coral: #f87171;
--violet: #a78bfa;
--pink: #f472b6;
--glass: rgba(255,255,255,0.04);
--glass-border: rgba(255,255,255,0.10);
--glass-border-strong: rgba(255,255,255,0.18);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg-0);
color: var(--fg);
font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI";
min-height: 100vh;
}
.page { max-width: 1320px; margin: 0 auto; padding: 28px; }
h1 { font-weight: 500; font-size: 22px; margin: 0 0 6px; letter-spacing: -0.01em; }
h2 { font-weight: 600; font-size: 11px; margin: 28px 0 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--cyan); }
.sub { color: var(--fg-muted); font-size: 13px; margin-bottom: 20px; }
/* Control panel */
.controls {
display: flex; gap: 18px; align-items: center; flex-wrap: wrap;
background: var(--bg-1); border: 1px solid var(--glass-border);
border-radius: 12px; padding: 14px 18px; margin-bottom: 20px;
}
.ctrl-group { display: flex; align-items: center; gap: 10px; }
.ctrl-group label {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--fg-muted); font-weight: 600;
}
.seg {
display: flex; gap: 0; padding: 3px;
background: var(--bg-0); border: 1px solid var(--glass-border);
border-radius: 7px;
}
.seg button {
background: transparent; border: 0; color: var(--fg-dim);
font-family: ui-monospace, monospace; font-size: 11px;
padding: 5px 11px; border-radius: 5px; cursor: pointer;
transition: all 120ms; min-width: 56px; text-align: center;
}
.seg button:hover { color: var(--fg); }
.seg button.active {
background: rgba(34,211,238,0.18);
color: var(--cyan);
}
.swatch-row { display: flex; gap: 6px; }
.swatch-row button {
width: 28px; height: 28px; border-radius: 6px; border: 2px solid transparent;
cursor: pointer; padding: 0; transition: border-color 120ms, transform 120ms;
}
.swatch-row button:hover { transform: scale(1.06); }
.swatch-row button.active { border-color: white; }
/* Grid */
.grid {
display: grid;
gap: 16px;
}
.grid.portrait { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.grid.landscape { grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); }
/* Actress card */
.card {
position: relative;
background: var(--bg-1);
border: 1px solid var(--glass-border);
border-radius: 12px; overflow: hidden;
cursor: pointer;
transition: transform 180ms, border-color 180ms;
}
.card:hover {
transform: translateY(-2px);
border-color: var(--glass-border-strong);
}
.card-portrait {
position: relative; width: 100%;
background: linear-gradient(135deg, #2a2540, #15101e);
overflow: hidden;
}
.grid.portrait .card-portrait { aspect-ratio: 1 / 1.618; }
.grid.landscape .card-portrait { aspect-ratio: 1.618 / 1; }
.card-portrait .face {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: rgba(255,255,255,0.18);
font-family: ui-monospace, monospace; font-size: 14px;
}
.card-portrait .face::before {
content: "";
width: 60%; height: 60%; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, rgba(255,255,255,0.18), transparent 70%);
position: absolute;
}
.card-portrait .face::after {
content: attr(data-letter);
font-size: 64px; font-weight: 200; color: rgba(255,255,255,0.15);
z-index: 1;
}
/* Category badges (already in the codebase, matching the modified ActressCard) */
.cat-badges {
position: absolute; top: 8px; left: 8px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 4px; max-width: 60%;
}
.cat-badge {
display: inline-flex; align-items: center; gap: 4px;
font-family: ui-monospace, monospace; font-size: 9px;
font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
background: rgba(0,0,0,0.8); backdrop-filter: blur(4px);
padding: 3px 8px; border-radius: 999px;
text-shadow: 0 1px 2px rgba(0,0,0,0.9);
}
/* === THE RATING RIBBON ===
Standard pattern:
- .ribbon = a square clipping wrap pinned to a corner of the card,
overflow:hidden so anything outside the corner triangle gets cut.
- .band = a wide strip rotated 45° and offset so its visible
portion fills the diagonal exactly. Width must exceed the wrap's
diagonal (≈ wrap × √2) so the strip reaches edge-to-edge.
*/
.ribbon {
position: absolute;
width: 96px; height: 96px;
overflow: hidden;
pointer-events: none;
z-index: 11;
}
.ribbon .band {
position: absolute;
display: block;
width: 160px; /* > 96 × √2 (~136), with margin for end clip */
line-height: 22px;
padding: 3px 0;
text-align: center;
font-family: ui-monospace, monospace;
font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em;
color: white;
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
box-shadow: 0 2px 6px rgba(0,0,0,0.35);
}
/* Top-right diagonal:
pin top-right of band to (top:22px, right:-38px), rotate +45°.
Numbers chosen so the strip's cut ends sit just inside the wrap. */
.ribbon.top-right { top: 0; right: 0; }
.ribbon.top-right .band {
top: 22px;
right: -38px;
transform: rotate(45deg);
}
/* Top-left mirror */
.ribbon.top-left { top: 0; left: 0; }
.ribbon.top-left .band {
top: 22px;
left: -38px;
transform: rotate(-45deg);
}
/* Variants */
.ribbon.coral .band { background: var(--coral); }
.ribbon.amber .band { background: var(--amber); color: #2a1a00; text-shadow: none; }
.ribbon.gradient .band {
background: linear-gradient(90deg, var(--cyan), var(--violet));
}
.ribbon.cyan .band { background: var(--cyan); color: #002028; text-shadow: none; }
.ribbon.pink .band { background: var(--pink); }
/* Style: stars vs numeric */
.ribbon.numeric .band::before { content: "RATING: " attr(data-r); }
.ribbon.numeric.compact .band::before { content: "★ " attr(data-r); letter-spacing: 0.04em; }
.ribbon.stars .band::before {
content: attr(data-stars);
letter-spacing: 0.18em;
}
/* Card meta strip */
.card-meta {
background: var(--bg-2);
padding: 10px 12px;
display: flex; flex-direction: column; gap: 4px;
}
.card-name {
display: flex; align-items: center; gap: 6px;
font-weight: 500;
}
.card-name .gender {
color: var(--pink);
font-size: 14px;
}
.card-age {
font-size: 11px; color: var(--fg-muted);
}
.card-count {
margin-top: 6px;
background: var(--bg-3);
border-radius: 6px;
padding: 6px 10px;
font-family: ui-monospace, monospace; font-size: 11px;
color: var(--fg-dim);
display: flex; align-items: center; gap: 8px;
}
.card-count .play { color: var(--cyan); font-size: 12px; }
/* Editor panel */
.editor {
margin-top: 28px;
background: var(--bg-1);
border: 1px solid var(--glass-border);
border-radius: 14px; padding: 18px 20px;
}
.editor h3 {
margin: 0 0 12px;
font-size: 13px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--cyan);
}
.editor .row {
display: grid; grid-template-columns: 130px 1fr; gap: 14px;
align-items: center;
padding: 8px 0;
border-bottom: 1px dashed var(--glass-border);
}
.editor .row:last-child { border-bottom: 0; }
.editor .lbl {
font-size: 12px; color: var(--fg-muted);
text-transform: uppercase; letter-spacing: 0.06em;
font-weight: 600;
}
.stars {
display: flex; gap: 2px;
}
.stars button {
background: transparent; border: 0;
color: var(--fg-muted); cursor: pointer;
font-size: 22px; line-height: 1;
padding: 4px;
transition: color 120ms, transform 120ms;
}
.stars button:hover { transform: scale(1.15); }
.stars button.on { color: var(--amber); }
.stars button.clear {
margin-left: 8px;
font-size: 11px; font-family: ui-monospace, monospace;
color: var(--fg-muted);
text-transform: uppercase; letter-spacing: 0.06em;
border: 1px solid var(--glass-border-strong);
border-radius: 5px; padding: 4px 8px;
}
.stars button.clear:hover { color: var(--coral); border-color: var(--coral); }
/* Detail hero (showing what the bigger ribbon looks like on /actress/[slug]) */
.detail {
display: grid; grid-template-columns: 320px 1fr; gap: 20px;
background: var(--bg-1); border: 1px solid var(--glass-border);
border-radius: 14px; overflow: hidden;
margin-top: 20px;
}
.detail .hero {
position: relative;
aspect-ratio: 1 / 1.618;
background: linear-gradient(135deg, #3a2a52, #1c1430);
}
.detail .hero .face {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
}
.detail .hero .face::after {
content: attr(data-letter);
font-size: 96px; font-weight: 200; color: rgba(255,255,255,0.18);
}
.detail .hero .ribbon {
width: 140px; height: 140px;
}
.detail .hero .ribbon .band {
width: 220px;
line-height: 28px;
padding: 4px 0;
font-size: 12px;
}
.detail .hero .ribbon.top-right .band {
top: 32px;
right: -52px;
transform: rotate(45deg);
}
.detail .hero .ribbon.top-left .band {
top: 32px;
left: -52px;
transform: rotate(-45deg);
}
.detail .info {
padding: 24px;
}
.detail .info h3 {
margin: 0 0 4px; font-size: 28px; font-weight: 500;
display: flex; align-items: center; gap: 12px;
}
.detail .info h3 .female { color: var(--pink); font-size: 22px; }
.detail .info .meta-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px; margin-top: 22px;
border-top: 1px solid var(--glass-border); padding-top: 18px;
}
.detail .info .meta-grid .it {
display: flex; flex-direction: column;
}
.detail .info .meta-grid .it .k {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--fg-muted); font-family: ui-monospace, monospace;
}
.detail .info .meta-grid .it .v {
font-size: 14px; color: var(--fg);
}
/* Comparison row showing top-left vs top-right with categories */
.comparison {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;
}
.comparison .label {
font-family: ui-monospace, monospace; font-size: 10px;
text-transform: uppercase; letter-spacing: 0.08em;
color: var(--fg-muted);
margin-bottom: 8px;
}
.comparison .label.bad { color: var(--coral); }
.comparison .label.good { color: var(--mint); }
/* Demo data positioning */
.legend {
background: var(--bg-1);
border: 1px solid var(--glass-border);
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 16px;
color: var(--fg-dim);
font-size: 12px;
line-height: 1.7;
}
.legend strong { color: var(--fg); }
.legend code {
background: var(--bg-0); padding: 1px 5px; border-radius: 4px;
font-size: 11px; color: var(--cyan);
}
</style>
</head>
<body>
<div class="page">
<h1>Actress rating banner — visual mockup</h1>
<div class="sub">
Click the stars in the editor to apply a rating to all cards. Use the controls to swap color / position / style.
</div>
<div class="controls">
<div class="ctrl-group">
<label>View</label>
<div class="seg" id="seg-view">
<button class="active" data-view="portrait">Portrait</button>
<button data-view="landscape">Landscape</button>
</div>
</div>
<div class="ctrl-group">
<label>Style</label>
<div class="seg" id="seg-style">
<button class="active" data-style="numeric">RATING: N</button>
<button data-style="compact">★ N</button>
<button data-style="stars">★★★★★</button>
</div>
</div>
<div class="ctrl-group">
<label>Position</label>
<div class="seg" id="seg-pos">
<button class="active" data-pos="top-right">Top-right</button>
<button data-pos="top-left">Top-left</button>
</div>
</div>
<div class="ctrl-group">
<label>Color</label>
<div class="swatch-row" id="swatch-row">
<button class="active" data-color="coral" style="background: #f87171;" title="coral"></button>
<button data-color="amber" style="background: #fbbf24;" title="amber"></button>
<button data-color="gradient" style="background: linear-gradient(90deg, #22d3ee, #a78bfa);" title="cyan→violet"></button>
<button data-color="cyan" style="background: #22d3ee;" title="cyan"></button>
<button data-color="pink" style="background: #f472b6;" title="pink"></button>
</div>
</div>
</div>
<h2>Actress grid · current view</h2>
<div class="grid portrait" id="grid"></div>
<div class="editor">
<h3>Meta editor — set ratings</h3>
<div id="editor-rows"></div>
</div>
<h2>Detail page hero</h2>
<div class="detail">
<div class="hero">
<div class="ribbon top-right coral numeric" id="hero-ribbon">
<div class="band" data-r="5" data-stars="★★★★★"></div>
</div>
<div class="face" data-letter="V"></div>
</div>
<div class="info">
<h3><span class="female"></span> Vina Sky</h3>
<div style="color: var(--fg-muted); font-family: ui-monospace, monospace; font-size: 13px;">
Age 27 · 20 covers · 5 categories
</div>
<div class="meta-grid">
<div class="it"><span class="k">Ethnicity</span><span class="v">Asian</span></div>
<div class="it"><span class="k">Country</span><span class="v">USA</span></div>
<div class="it"><span class="k">Eye color</span><span class="v">Brown</span></div>
<div class="it"><span class="k">Hair color</span><span class="v">Black</span></div>
<div class="it"><span class="k">First seen</span><span class="v">2018</span></div>
<div class="it"><span class="k">Active</span><span class="v">Yes</span></div>
</div>
</div>
</div>
<h2>Position trade-off · with category badges</h2>
<div class="legend">
<strong>The clash.</strong> The actress card already renders category chips at <code>top-2 left-2 z-10</code> (see ActressCard.tsx:182).
Top-left ribbon overlaps them. Top-right is conflict-free.
</div>
<div class="comparison">
<div>
<div class="label bad">✗ Top-left collides with category chips</div>
<div class="card" style="max-width: 240px;">
<div class="card-portrait">
<div class="cat-badges">
<span class="cat-badge" style="color: #fbbf24; border: 1px solid #fbbf24aa;">★ Top</span>
<span class="cat-badge" style="color: #22d3ee; border: 1px solid #22d3eeaa;">◆ VIP</span>
</div>
<div class="ribbon top-left coral numeric">
<div class="band" data-r="5" data-stars="★★★★★"></div>
</div>
<div class="face" data-letter="X"></div>
</div>
<div class="card-meta">
<div class="card-name"><span class="gender"></span>Xoey Li</div>
<div class="card-count"><span class="play"></span>4</div>
</div>
</div>
</div>
<div>
<div class="label good">✓ Top-right keeps both visible</div>
<div class="card" style="max-width: 240px;">
<div class="card-portrait">
<div class="cat-badges">
<span class="cat-badge" style="color: #fbbf24; border: 1px solid #fbbf24aa;">★ Top</span>
<span class="cat-badge" style="color: #22d3ee; border: 1px solid #22d3eeaa;">◆ VIP</span>
</div>
<div class="ribbon top-right coral numeric">
<div class="band" data-r="5" data-stars="★★★★★"></div>
</div>
<div class="face" data-letter="X"></div>
</div>
<div class="card-meta">
<div class="card-name"><span class="gender"></span>Xoey Li</div>
<div class="card-count"><span class="play"></span>4</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Synthetic data
const ACTRESSES = [
{ name: "Xoey Li", age: 28, count: 4, letter: "X", rating: 4 },
{ name: "Willa", age: 34, count: 1, letter: "W", rating: 0 },
{ name: "Vina Sky", age: 27, count: 20, letter: "V", rating: 5 },
{ name: "Aria Kai", age: 25, count: 12, letter: "A", rating: 3 },
{ name: "Mira Saito", age: 31, count: 7, letter: "M", rating: 0 },
{ name: "Yui Nanase", age: 24, count: 33, letter: "Y", rating: 5 },
{ name: "Elena Voss", age: 29, count: 8, letter: "E", rating: 2 },
{ name: "Luna Park", age: 26, count: 15, letter: "L", rating: 4 },
];
let state = {
view: "portrait",
style: "numeric",
pos: "top-right",
color: "coral",
};
function ribbonHTML(r) {
if (r <= 0) return "";
const stars = "★".repeat(r) + "☆".repeat(5 - r);
return `
<div class="ribbon ${state.pos} ${state.color} ${state.style}">
<div class="band" data-r="${r}" data-stars="${stars}"></div>
</div>
`;
}
function cardHTML(a) {
return `
<div class="card" data-name="${a.name}">
<div class="card-portrait">
${ribbonHTML(a.rating)}
<div class="face" data-letter="${a.letter}"></div>
</div>
<div class="card-meta">
<div class="card-name">
<span class="gender">♀</span>
<span>${a.name}</span>
</div>
<div class="card-age">${a.age} years old</div>
<div class="card-count"><span class="play">▶</span>${a.count}</div>
</div>
</div>
`;
}
function render() {
const grid = document.getElementById("grid");
grid.className = "grid " + state.view;
grid.innerHTML = ACTRESSES.map(cardHTML).join("");
// Detail hero ribbon
const hero = document.getElementById("hero-ribbon");
const heroR = ACTRESSES.find(a => a.name === "Vina Sky").rating;
if (heroR > 0) {
const stars = "★".repeat(heroR) + "☆".repeat(5 - heroR);
hero.className = `ribbon ${state.pos} ${state.color} ${state.style}`;
hero.innerHTML = `<div class="band" data-r="${heroR}" data-stars="${stars}"></div>`;
hero.style.display = "";
} else {
hero.style.display = "none";
}
// Editor rows
document.getElementById("editor-rows").innerHTML = ACTRESSES.map((a) => `
<div class="row">
<div class="lbl">${a.name}</div>
<div class="stars" data-actress="${a.name}">
${[1,2,3,4,5].map(i => `<button class="${i <= a.rating ? "on" : ""}" data-v="${i}">${i <= a.rating ? "★" : "☆"}</button>`).join("")}
<button class="clear" data-v="0">Clear</button>
</div>
</div>
`).join("");
// wire stars
document.querySelectorAll(".stars").forEach(el => {
el.addEventListener("click", (e) => {
const t = e.target.closest("button");
if (!t) return;
const v = Number(t.dataset.v);
const name = el.dataset.actress;
const a = ACTRESSES.find(x => x.name === name);
if (a) a.rating = v;
render();
});
});
}
// Wire controls
document.querySelectorAll("#seg-view button").forEach(b => b.addEventListener("click", () => {
document.querySelectorAll("#seg-view button").forEach(x => x.classList.remove("active"));
b.classList.add("active"); state.view = b.dataset.view; render();
}));
document.querySelectorAll("#seg-style button").forEach(b => b.addEventListener("click", () => {
document.querySelectorAll("#seg-style button").forEach(x => x.classList.remove("active"));
b.classList.add("active"); state.style = b.dataset.style; render();
}));
document.querySelectorAll("#seg-pos button").forEach(b => b.addEventListener("click", () => {
document.querySelectorAll("#seg-pos button").forEach(x => x.classList.remove("active"));
b.classList.add("active"); state.pos = b.dataset.pos; render();
}));
document.querySelectorAll("#swatch-row button").forEach(b => b.addEventListener("click", () => {
document.querySelectorAll("#swatch-row button").forEach(x => x.classList.remove("active"));
b.classList.add("active"); state.color = b.dataset.color; render();
}));
render();
</script>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
"use server";
import { revalidatePath } from "next/cache";
import { rawDb, uniqueSlug } from "@/lib/db/client";
const EXCLUSIVE_GROUPS: string[][] = [["favorite", "vip"]];
function getExclusivePeers(categoryId: number): number[] {
const cat = rawDb.prepare(`SELECT slug FROM actress_categories WHERE id = ?`).get(categoryId) as { slug: string } | undefined;
if (!cat) return [];
const group = EXCLUSIVE_GROUPS.find((g) => g.includes(cat.slug));
if (!group) return [];
const peers = group.filter((s) => s !== cat.slug);
if (peers.length === 0) return [];
const placeholders = peers.map(() => "?").join(",");
const rows = rawDb.prepare(`SELECT id FROM actress_categories WHERE slug IN (${placeholders})`).all(...peers) as Array<{ id: number }>;
return rows.map((r) => r.id);
}
export async function toggleActressCategory(actressId: number, categoryId: number) {
const exists = rawDb.prepare(`
SELECT 1 FROM actress_categories_map WHERE actress_id = ? AND category_id = ?
`).get(actressId, categoryId);
if (exists) {
rawDb.prepare(`DELETE FROM actress_categories_map WHERE actress_id = ? AND category_id = ?`).run(actressId, categoryId);
} else {
// Adding: also remove any peer category in an exclusive group.
const peers = getExclusivePeers(categoryId);
const tx = rawDb.transaction(() => {
if (peers.length > 0) {
const placeholders = peers.map(() => "?").join(",");
rawDb.prepare(`
DELETE FROM actress_categories_map
WHERE actress_id = ? AND category_id IN (${placeholders})
`).run(actressId, ...peers);
}
rawDb.prepare(`INSERT INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`).run(actressId, categoryId);
});
tx();
}
const a = rawDb.prepare(`SELECT slug FROM actresses WHERE id = ?`).get(actressId) as { slug: string } | undefined;
revalidatePath("/actress");
if (a) revalidatePath(`/actress/${a.slug}`);
return { added: !exists };
}
export async function setActressCategories(actressId: number, categoryIds: number[]) {
const tx = rawDb.transaction(() => {
rawDb.prepare(`DELETE FROM actress_categories_map WHERE actress_id = ?`).run(actressId);
const ins = rawDb.prepare(`INSERT INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`);
for (const id of categoryIds) ins.run(actressId, id);
});
tx();
const a = rawDb.prepare(`SELECT slug FROM actresses WHERE id = ?`).get(actressId) as { slug: string } | undefined;
revalidatePath("/actress");
if (a) revalidatePath(`/actress/${a.slug}`);
}
export async function createActressCategory(input: { name: string; color?: string | null; icon?: string | null; priority?: number }) {
const trimmed = input.name.trim();
if (!trimmed) return null;
const existing = rawDb.prepare(`SELECT id, slug FROM actress_categories WHERE name = ?`).get(trimmed) as { id: number; slug: string } | undefined;
if (existing) return existing;
const slug = uniqueSlug(rawDb, "actress_categories", trimmed);
const row = rawDb.prepare(`
INSERT INTO actress_categories (name, slug, color, icon, priority, builtin)
VALUES (?, ?, ?, ?, ?, 0) RETURNING id
`).get(trimmed, slug, input.color ?? null, input.icon ?? null, input.priority ?? 50) as { id: number };
revalidatePath("/actress");
return { id: row.id, slug };
}
export async function bulkAddCategory(actressIds: number[], categoryId: number) {
if (actressIds.length === 0) return;
const peers = getExclusivePeers(categoryId);
const ins = rawDb.prepare(`INSERT OR IGNORE INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`);
const tx = rawDb.transaction(() => {
if (peers.length > 0) {
const peerPh = peers.map(() => "?").join(",");
const idPh = actressIds.map(() => "?").join(",");
rawDb.prepare(`
DELETE FROM actress_categories_map
WHERE actress_id IN (${idPh}) AND category_id IN (${peerPh})
`).run(...actressIds, ...peers);
}
for (const id of actressIds) ins.run(id, categoryId);
});
tx();
revalidatePath("/actress");
}
export async function bulkRemoveCategory(actressIds: number[], categoryId: number) {
if (actressIds.length === 0) return;
const placeholders = actressIds.map(() => "?").join(",");
rawDb.prepare(`
DELETE FROM actress_categories_map
WHERE category_id = ? AND actress_id IN (${placeholders})
`).run(categoryId, ...actressIds);
revalidatePath("/actress");
}
export async function deleteActressCategory(categoryId: number) {
const row = rawDb.prepare(`SELECT builtin FROM actress_categories WHERE id = ?`).get(categoryId) as { builtin: number } | undefined;
if (!row) return { ok: false, reason: "not found" };
if (row.builtin) return { ok: false, reason: "built-in category cannot be deleted" };
rawDb.prepare(`DELETE FROM actress_categories WHERE id = ?`).run(categoryId);
revalidatePath("/actress");
return { ok: true };
}
+7
View File
@@ -0,0 +1,7 @@
"use server";
import { listActressCategories } from "@/lib/db/queries";
import type { ActressCategory } from "@/lib/db/queries";
export async function listActressCategoriesAction(): Promise<ActressCategory[]> {
return listActressCategories();
}
+162
View File
@@ -0,0 +1,162 @@
"use server";
import { revalidatePath } from "next/cache";
import { rawDb, uniqueSlug } from "@/lib/db/client";
const PALETTE = ["#fbbf24", "#22d3ee", "#a78bfa", "#f472b6", "#34d399", "#fb7185", "#f97316", "#60a5fa"];
export interface ImportPreviewLine {
raw: string;
name: string;
altNames: string | null;
categories: string[];
status: "new" | "exists" | "blank" | "error";
reason?: string;
}
export interface ImportResult {
lines: ImportPreviewLine[];
added: number;
skipped: number;
errors: number;
newCategories: string[];
}
function parseLines(text: string): Array<{ raw: string; name: string; altNames: string | null; categories: string[] }> {
const out: Array<{ raw: string; name: string; altNames: string | null; categories: string[] }> = [];
for (const raw of text.split(/\r?\n/)) {
const trimmed = raw.trim();
if (!trimmed) {
out.push({ raw, name: "", altNames: null, categories: [] });
continue;
}
const parts = trimmed.split("|").map((s) => s.trim());
const [name, alt, cats] = [parts[0] ?? "", parts[1] ?? "", parts[2] ?? ""];
const categories = cats ? cats.split(/[,、,]/).map((s) => s.trim()).filter(Boolean) : [];
out.push({ raw: trimmed, name, altNames: alt || null, categories });
}
return out;
}
/**
* Dry run — parse + classify lines without writing. Drives the preview UI.
*/
export async function previewActressImport(text: string): Promise<ImportResult> {
const parsed = parseLines(text);
const lines: ImportPreviewLine[] = [];
const existingCats = new Set(
(rawDb.prepare(`SELECT name FROM actress_categories`).all() as Array<{ name: string }>)
.map((r) => r.name.toLowerCase()),
);
// Dedupe new-category collection by lowercased key to match the
// commit path, which keys its catCache by toLowerCase(). Otherwise
// "Action" and "ACTION" in the input would be reported as 2 new
// categories in the preview but commit would create only 1.
const newCategoriesByKey = new Map<string, string>();
// Track within-input new actresses too: a name appearing twice should
// be counted once in the preview, matching the commit (which inserts
// the first and rejects/sees the second as existing).
const seenNewNames = new Set<string>();
let added = 0, skipped = 0, errors = 0;
for (const p of parsed) {
if (!p.name) {
lines.push({ ...p, status: "blank" });
continue;
}
const existing = rawDb.prepare(`SELECT id FROM actresses WHERE name = ? COLLATE NOCASE`).get(p.name) as { id: number } | undefined;
const nameKey = p.name.toLowerCase();
if (existing || seenNewNames.has(nameKey)) {
lines.push({ ...p, status: "exists" });
skipped++;
continue;
}
for (const c of p.categories) {
const key = c.toLowerCase();
if (!existingCats.has(key) && !newCategoriesByKey.has(key)) {
newCategoriesByKey.set(key, c);
}
}
seenNewNames.add(nameKey);
lines.push({ ...p, status: "new" });
added++;
}
return { lines, added, skipped, errors, newCategories: Array.from(newCategoriesByKey.values()) };
}
/**
* Commit — actually write. Auto-creates unknown categories with palette colors.
*/
export async function commitActressImport(text: string, defaultCategoryIds: number[] = []): Promise<ImportResult> {
const parsed = parseLines(text);
const lines: ImportPreviewLine[] = [];
let added = 0, skipped = 0, errors = 0;
const newCategoriesSet = new Set<string>();
// Cache existing categories by lowercased name → id.
const catCache = new Map<string, number>();
for (const row of rawDb.prepare(`SELECT id, name FROM actress_categories`).all() as Array<{ id: number; name: string }>) {
catCache.set(row.name.toLowerCase(), row.id);
}
// Track NEW categories with their own counter so palette colors are
// assigned in import order — using catCache.size meant the first new
// category's color depended on the existing-category count and often
// collided with an existing category's chosen color.
let newCategoryCount = 0;
// Helper to get or create a category.
function getOrCreateCategory(name: string): number {
const key = name.toLowerCase();
const hit = catCache.get(key);
if (hit != null) return hit;
const slug = uniqueSlug(rawDb, "actress_categories", name);
const color = PALETTE[newCategoryCount++ % PALETTE.length];
const row = rawDb.prepare(`
INSERT INTO actress_categories (name, slug, color, icon, priority, builtin)
VALUES (?, ?, ?, 'tag', 50, 0) RETURNING id
`).get(name, slug, color) as { id: number };
catCache.set(key, row.id);
newCategoriesSet.add(name);
return row.id;
}
const tx = rawDb.transaction(() => {
for (const p of parsed) {
if (!p.name) {
lines.push({ ...p, status: "blank" });
continue;
}
const existing = rawDb.prepare(`SELECT id FROM actresses WHERE name = ? COLLATE NOCASE`).get(p.name) as { id: number } | undefined;
if (existing) {
lines.push({ ...p, status: "exists" });
skipped++;
continue;
}
try {
const slug = uniqueSlug(rawDb, "actresses", p.name);
const row = rawDb.prepare(`
INSERT INTO actresses (name, slug, alt_names) VALUES (?, ?, ?) RETURNING id
`).get(p.name, slug, p.altNames) as { id: number };
const assignedCatIds = new Set<number>();
for (const cat of p.categories) {
const catId = getOrCreateCategory(cat);
assignedCatIds.add(catId);
}
for (const id of defaultCategoryIds) assignedCatIds.add(id);
for (const id of assignedCatIds) {
rawDb.prepare(`INSERT OR IGNORE INTO actress_categories_map (actress_id, category_id) VALUES (?, ?)`).run(row.id, id);
}
lines.push({ ...p, status: "new" });
added++;
} catch (e) {
lines.push({ ...p, status: "error", reason: (e as Error).message });
errors++;
}
}
});
tx();
revalidatePath("/actress");
return { lines, added, skipped, errors, newCategories: Array.from(newCategoriesSet) };
}
+54
View File
@@ -0,0 +1,54 @@
"use server";
import { rawDb } from "@/lib/db/client";
import { reverseName } from "@/lib/jav/nameUtils";
export interface ActressLookupResult {
name: string;
match: { id: number; name: string; slug: string } | null;
}
/**
* For each input name, find an existing actress matching by:
* - canonical name (exact, case-insensitive)
* - any entry in alt_names (comma-separated)
* - the reversed-word-order form (e.g. "Atomi Shuri" matches "Shuri Atomi")
* Returns one row per input preserving order.
*/
export async function lookupActressesByNames(names: string[]): Promise<ActressLookupResult[]> {
const trimmed = names.map((n) => n.trim()).filter(Boolean);
if (trimmed.length === 0) return [];
const rows = rawDb.prepare(`SELECT id, name, slug, alt_names AS altNames FROM actresses`).all() as Array<{
id: number;
name: string;
slug: string;
altNames: string | null;
}>;
type Indexed = { id: number; name: string; slug: string; keys: Set<string> };
const indexed: Indexed[] = rows.map((r) => {
const keys = new Set<string>();
keys.add(r.name.toLowerCase());
const rev = reverseName(r.name);
if (rev) keys.add(rev.toLowerCase());
if (r.altNames) {
for (const part of r.altNames.split(/[,、,]/)) {
const t = part.trim().toLowerCase();
if (t) keys.add(t);
}
}
return { id: r.id, name: r.name, slug: r.slug, keys };
});
const findMatch = (q: string) => {
const lq = q.toLowerCase();
const lqRev = reverseName(q)?.toLowerCase() ?? null;
for (const r of indexed) {
if (r.keys.has(lq) || (lqRev && r.keys.has(lqRev))) {
return { id: r.id, name: r.name, slug: r.slug };
}
}
return null;
};
return trimmed.map((name) => ({ name, match: findMatch(name) }));
}
+61
View File
@@ -0,0 +1,61 @@
"use server";
import { revalidatePath } from "next/cache";
import { rawDb, uniqueSlug } from "@/lib/db/client";
import { redirect } from "next/navigation";
export async function updateActressMeta(
actressId: number,
data: {
name?: string;
altNames?: string | null;
notes?: string | null;
bornOn?: string | null;
heightCm?: number | null;
weightKg?: number | null;
cupSize?: string | null;
},
): Promise<{ slug: string } | null> {
const row = rawDb.prepare(`SELECT name, slug FROM actresses WHERE id = ?`).get(actressId) as
| { name: string; slug: string }
| undefined;
if (!row) return null;
let newSlug = row.slug;
let newName = row.name;
if (data.name != null) {
const trimmed = data.name.trim();
if (trimmed && trimmed !== row.name) {
newName = trimmed;
newSlug = uniqueSlug(rawDb, "actresses", trimmed, actressId);
}
}
const altNames = data.altNames == null ? null : data.altNames.trim() || null;
const notes = data.notes == null ? null : data.notes.trim() || null;
const bornOn = data.bornOn == null ? null : (data.bornOn.trim() || null);
const heightCm = data.heightCm == null || !Number.isFinite(data.heightCm) ? null : Math.round(data.heightCm);
const weightKg = data.weightKg == null || !Number.isFinite(data.weightKg) ? null : Math.round(data.weightKg);
const cupSize = data.cupSize == null ? null : (data.cupSize.trim() || null);
rawDb.prepare(`
UPDATE actresses
SET name = ?, slug = ?, alt_names = ?, notes = ?,
born_on = ?, height_cm = ?, weight_kg = ?, cup_size = ?
WHERE id = ?
`).run(newName, newSlug, altNames, notes, bornOn, heightCm, weightKg, cupSize, actressId);
revalidatePath("/actress");
revalidatePath(`/actress/${row.slug}`);
if (newSlug !== row.slug) revalidatePath(`/actress/${newSlug}`);
return { slug: newSlug };
}
export async function updateActressMetaAction(formData: FormData) {
const id = Number(formData.get("id"));
if (!Number.isFinite(id)) return;
const result = await updateActressMeta(id, {
name: String(formData.get("name") ?? ""),
altNames: String(formData.get("altNames") ?? ""),
notes: String(formData.get("notes") ?? ""),
});
if (result) redirect(`/actress/${result.slug}`);
}
+93
View File
@@ -0,0 +1,93 @@
"use server";
import { revalidatePath } from "next/cache";
import path from "node:path";
import fs from "node:fs/promises";
import { rawDb } from "@/lib/db/client";
import type { PortraitSlotKey } from "@/lib/db/queries";
import { safeJoin } from "@/lib/safePath";
const PORTRAIT_ROOT = path.join(process.cwd(), "data", "portraits");
const SLOT_COLS: Record<PortraitSlotKey, { path: string; zoom: string; ox: string; oy: string }> = {
"1": { path: "portrait_path", zoom: "portrait_zoom", ox: "portrait_offset_x", oy: "portrait_offset_y" },
"2": { path: "portrait2_path", zoom: "portrait2_zoom", ox: "portrait2_offset_x", oy: "portrait2_offset_y" },
"3": { path: "portrait3_path", zoom: "portrait3_zoom", ox: "portrait3_offset_x", oy: "portrait3_offset_y" },
"4": { path: "portrait4_path", zoom: "portrait4_zoom", ox: "portrait4_offset_x", oy: "portrait4_offset_y" },
"h": { path: "portraith_path", zoom: "portraith_zoom", ox: "portraith_offset_x", oy: "portraith_offset_y" },
};
export async function setActressPortraitTransform(
actressId: number,
slot: PortraitSlotKey,
transform: { zoom: number; offsetX: number; offsetY: number },
) {
const c = SLOT_COLS[slot];
if (!c) return;
const zoom = Math.max(0.5, Math.min(5, transform.zoom));
rawDb.prepare(`
UPDATE actresses SET ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ? WHERE id = ?
`).run(zoom, transform.offsetX, transform.offsetY, actressId);
const a = rawDb.prepare(`SELECT slug FROM actresses WHERE id = ?`).get(actressId) as { slug: string } | undefined;
revalidatePath("/actress");
if (a) revalidatePath(`/actress/${a.slug}`);
}
type PortraitTuple = [string | null, number, number, number];
export async function reorderActressPortraitSlots(
actressId: number,
src: PortraitSlotKey,
dest: PortraitSlotKey,
) {
if (src === dest) return;
if (src === "h" || dest === "h") return;
const order: PortraitSlotKey[] = ["1", "2", "3", "4"];
if (!order.includes(src) || !order.includes(dest)) return;
const cols = order.map((s) => SLOT_COLS[s]);
const selectFrag = cols
.map((c, i) => `${c.path} AS p${i}, ${c.zoom} AS z${i}, ${c.ox} AS x${i}, ${c.oy} AS y${i}`)
.join(", ");
const row = rawDb.prepare(`SELECT slug, ${selectFrag} FROM actresses WHERE id = ?`).get(actressId) as
| (Record<string, string | number | null> & { slug: string })
| undefined;
if (!row) return;
const current: PortraitTuple[] = order.map((_, i) => [
(row[`p${i}`] as string | null) ?? null,
Number(row[`z${i}`] ?? 1),
Number(row[`x${i}`] ?? 0),
Number(row[`y${i}`] ?? 0),
]);
const srcIdx = order.indexOf(src);
const destIdx = order.indexOf(dest);
const next = [...current];
const [moved] = next.splice(srcIdx, 1);
next.splice(destIdx, 0, moved);
const setFrag = cols.map((c) => `${c.path} = ?, ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ?`).join(", ");
const params = next.flatMap((t) => [t[0], t[1], t[2], t[3]]);
rawDb.prepare(`UPDATE actresses SET ${setFrag} WHERE id = ?`).run(...params, actressId);
revalidatePath("/actress");
revalidatePath(`/actress/${row.slug}`);
}
export async function clearActressPortrait(actressId: number, slot: PortraitSlotKey) {
const c = SLOT_COLS[slot];
if (!c) return;
const row = rawDb.prepare(`SELECT slug, ${c.path} AS p FROM actresses WHERE id = ?`).get(actressId) as
| { slug: string; p: string | null }
| undefined;
if (!row) return;
if (row.p) {
const abs = safeJoin(PORTRAIT_ROOT, row.p);
if (abs) await fs.rm(abs, { force: true }).catch(() => {});
}
rawDb.prepare(`
UPDATE actresses SET ${c.path} = NULL, ${c.zoom} = 1, ${c.ox} = 0, ${c.oy} = 0 WHERE id = ?
`).run(actressId);
revalidatePath("/actress");
revalidatePath(`/actress/${row.slug}`);
}
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { revalidatePath } from "next/cache";
import path from "node:path";
import fs from "node:fs/promises";
import { rawDb } from "@/lib/db/client";
import { safeJoin } from "@/lib/safePath";
const LIBRARY_ROOT = path.join(process.cwd(), "library");
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
export async function deleteAttachedImage(attachedId: number) {
const row = rawDb.prepare(`
SELECT parent_image_id AS parentId, rel_path AS relPath, thumb_path AS thumbPath
FROM images WHERE id = ?
`).get(attachedId) as { parentId: number | null; relPath: string; thumbPath: string } | undefined;
if (!row || row.parentId == null) return;
rawDb.prepare(`DELETE FROM images WHERE id = ?`).run(attachedId);
const fileAbs = safeJoin(LIBRARY_ROOT, row.relPath);
const thumbAbs = safeJoin(THUMB_ROOT, row.thumbPath);
if (fileAbs) await fs.rm(fileAbs, { force: true }).catch(() => {});
if (thumbAbs) await fs.rm(thumbAbs, { force: true }).catch(() => {});
const parentCode = rawDb.prepare(`SELECT code FROM images WHERE id = ?`).get(row.parentId) as { code: string | null } | undefined;
revalidatePath(`/image/${row.parentId}`);
if (parentCode?.code) revalidatePath(`/id/${parentCode.code}`);
}
+122
View File
@@ -0,0 +1,122 @@
"use server";
import { revalidatePath } from "next/cache";
import path from "node:path";
import fs from "node:fs/promises";
import { rawDb } from "@/lib/db/client";
import { getAppSetting } from "@/lib/db/appSettings";
import { safeJoin } from "@/lib/safePath";
const LIBRARY_ROOT = path.join(process.cwd(), "library");
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
interface DeleteResult {
trashed: number;
purged: number;
}
export async function bulkSetWatched(ids: number[], watched: boolean): Promise<{ updated: number }> {
if (ids.length === 0) return { updated: 0 };
const placeholders = ids.map(() => "?").join(",");
const result = rawDb
.prepare(`UPDATE images SET watched = ? WHERE id IN (${placeholders})`)
.run(watched ? 1 : 0, ...ids);
revalidatePath("/");
for (const id of ids) revalidatePath(`/image/${id}`);
return { updated: Number(result.changes) };
}
export async function bulkSetOwned(ids: number[], owned: boolean): Promise<{ updated: number }> {
if (ids.length === 0) return { updated: 0 };
const placeholders = ids.map(() => "?").join(",");
const result = rawDb
.prepare(`UPDATE images SET is_owned = ? WHERE id IN (${placeholders})`)
.run(owned ? 1 : 0, ...ids);
revalidatePath("/");
for (const id of ids) revalidatePath(`/image/${id}`);
return { updated: Number(result.changes) };
}
/**
* Bulk mark covers as VIP / Favorite / Unmarked. VIP and Favorite are mutually
* exclusive, so setting one clears the other; "unmark" clears both.
*/
export async function bulkSetMark(ids: number[], mark: "vip" | "favorite" | "unmarked"): Promise<{ updated: number }> {
if (ids.length === 0) return { updated: 0 };
const placeholders = ids.map(() => "?").join(",");
const sql =
mark === "vip" ? `UPDATE images SET is_vip = 1, is_favorite = 0 WHERE id IN (${placeholders})`
: mark === "favorite" ? `UPDATE images SET is_favorite = 1, is_vip = 0 WHERE id IN (${placeholders})`
: `UPDATE images SET is_vip = 0, is_favorite = 0 WHERE id IN (${placeholders})`;
const result = rawDb.prepare(sql).run(...ids);
revalidatePath("/");
for (const id of ids) revalidatePath(`/image/${id}`);
return { updated: Number(result.changes) };
}
export async function deleteImages(
ids: number[],
options?: { permanent?: boolean },
): Promise<DeleteResult> {
if (ids.length === 0) return { trashed: 0, purged: 0 };
const useBin = getAppSetting("useRecycleBin");
const goPermanent = options?.permanent ?? !useBin;
if (!goPermanent) {
const placeholders = ids.map(() => "?").join(",");
const now = Date.now();
const info = rawDb.prepare(
`UPDATE images SET deleted_at = ? WHERE id IN (${placeholders}) AND deleted_at IS NULL`,
).run(now, ...ids);
revalidate();
return { trashed: Number(info.changes), purged: 0 };
}
const purged = await hardDelete(ids);
revalidate();
// Report parents purged, not parents+children — `purged` already
// collapses to the top-level id set the caller asked us to delete.
return { trashed: 0, purged };
}
/** Convenience for routes that delete a single image. */
export async function deleteImage(id: number, options?: { permanent?: boolean }): Promise<DeleteResult> {
return deleteImages([id], options);
}
async function hardDelete(ids: number[]): Promise<number> {
const placeholders = ids.map(() => "?").join(",");
const rows = rawDb.prepare(
`
SELECT id, rel_path, thumb_path FROM images
WHERE id IN (${placeholders}) OR parent_image_id IN (${placeholders})
`,
).all(...ids, ...ids) as Array<{ id: number; rel_path: string; thumb_path: string }>;
// DB delete first: if the process crashes mid-purge, files on disk become
// orphans (cleanable via the maintenance scanner) rather than DB rows
// pointing at vanished files. Children cascade via parent_image_id FK.
const info = rawDb.prepare(`DELETE FROM images WHERE id IN (${placeholders})`).run(...ids);
if (getAppSetting("purgeFilesOnDelete")) {
await Promise.all(rows.flatMap((r) => {
const fileAbs = safeJoin(LIBRARY_ROOT, r.rel_path);
const thumbAbs = safeJoin(THUMB_ROOT, r.thumb_path);
const tasks: Array<Promise<unknown>> = [];
if (fileAbs) tasks.push(fs.rm(fileAbs, { force: true }));
if (thumbAbs) tasks.push(fs.rm(thumbAbs, { force: true }));
return tasks;
}));
}
return Number(info.changes);
}
function revalidate() {
revalidatePath("/");
revalidatePath("/collection");
revalidatePath("/tag");
revalidatePath("/actress");
revalidatePath("/studios");
revalidatePath("/series");
revalidatePath("/genres");
}
+49
View File
@@ -0,0 +1,49 @@
"use server";
import { revalidatePath } from "next/cache";
import path from "node:path";
import fs from "node:fs/promises";
import { rawDb } from "@/lib/db/client";
import { safeJoin } from "@/lib/safePath";
export type CategoryCoverSlot = "portrait" | "landscape";
const COVER_ROOT = path.join(process.cwd(), "data", "category-covers");
const SLOT_COLS: Record<CategoryCoverSlot, { path: string; zoom: string; ox: string; oy: string }> = {
portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" },
landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" },
};
export async function setCategoryCoverTransform(
categoryId: number,
slot: CategoryCoverSlot,
transform: { zoom: number; offsetX: number; offsetY: number },
) {
const c = SLOT_COLS[slot];
if (!c) return;
const zoom = Math.max(0.5, Math.min(5, transform.zoom));
rawDb.prepare(`
UPDATE tag_categories SET ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ? WHERE id = ?
`).run(zoom, transform.offsetX, transform.offsetY, categoryId);
const row = rawDb.prepare(`SELECT slug FROM tag_categories WHERE id = ?`).get(categoryId) as { slug: string } | undefined;
revalidatePath("/category");
if (row) revalidatePath(`/category/${row.slug}`);
}
export async function clearCategoryCover(categoryId: number, slot: CategoryCoverSlot) {
const c = SLOT_COLS[slot];
if (!c) return;
const row = rawDb.prepare(`SELECT slug, ${c.path} AS p FROM tag_categories WHERE id = ?`).get(categoryId) as
| { slug: string; p: string | null }
| undefined;
if (!row) return;
if (row.p) {
const abs = safeJoin(COVER_ROOT, row.p);
if (abs) await fs.rm(abs, { force: true }).catch(() => {});
}
rawDb.prepare(`
UPDATE tag_categories SET ${c.path} = NULL, ${c.zoom} = 1, ${c.ox} = 0, ${c.oy} = 0 WHERE id = ?
`).run(categoryId);
revalidatePath("/category");
revalidatePath(`/category/${row.slug}`);
}
+49
View File
@@ -0,0 +1,49 @@
"use server";
import { revalidatePath } from "next/cache";
import path from "node:path";
import fs from "node:fs/promises";
import { rawDb } from "@/lib/db/client";
import { safeJoin } from "@/lib/safePath";
export type CollectionCoverSlot = "portrait" | "landscape";
const COVER_ROOT = path.join(process.cwd(), "data", "collection-covers");
const SLOT_COLS: Record<CollectionCoverSlot, { path: string; zoom: string; ox: string; oy: string }> = {
portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" },
landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" },
};
export async function setCollectionCoverTransform(
collectionId: number,
slot: CollectionCoverSlot,
transform: { zoom: number; offsetX: number; offsetY: number },
) {
const c = SLOT_COLS[slot];
if (!c) return;
const zoom = Math.max(0.5, Math.min(5, transform.zoom));
rawDb.prepare(`
UPDATE collections SET ${c.zoom} = ?, ${c.ox} = ?, ${c.oy} = ? WHERE id = ?
`).run(zoom, transform.offsetX, transform.offsetY, collectionId);
const row = rawDb.prepare(`SELECT slug FROM collections WHERE id = ?`).get(collectionId) as { slug: string } | undefined;
revalidatePath("/collection");
if (row) revalidatePath(`/collection/${row.slug}`);
}
export async function clearCollectionCover(collectionId: number, slot: CollectionCoverSlot) {
const c = SLOT_COLS[slot];
if (!c) return;
const row = rawDb.prepare(`SELECT slug, ${c.path} AS p FROM collections WHERE id = ?`).get(collectionId) as
| { slug: string; p: string | null }
| undefined;
if (!row) return;
if (row.p) {
const abs = safeJoin(COVER_ROOT, row.p);
if (abs) await fs.rm(abs, { force: true }).catch(() => {});
}
rawDb.prepare(`
UPDATE collections SET ${c.path} = NULL, ${c.zoom} = 1, ${c.ox} = 0, ${c.oy} = 0 WHERE id = ?
`).run(collectionId);
revalidatePath("/collection");
revalidatePath(`/collection/${row.slug}`);
}
+153
View File
@@ -0,0 +1,153 @@
"use server";
import { revalidatePath } from "next/cache";
import { rawDb, uniqueSlug } from "@/lib/db/client";
import { redirect } from "next/navigation";
export async function createCollection(name: string, description?: string): Promise<{ id: number; slug: string } | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const slug = uniqueSlug(rawDb, "collections", trimmed);
// New collections land at the end of the manual order. Wrap the read +
// insert in a tx so concurrent creates don't both pick the same
// position.
const tx = rawDb.transaction(() => {
const max = rawDb.prepare(`SELECT COALESCE(MAX(position), -1) AS m FROM collections`).get() as { m: number };
const r = rawDb.prepare(`
INSERT INTO collections (name, slug, description, position) VALUES (?, ?, ?, ?) RETURNING id
`).get(trimmed, slug, description?.trim() || null, max.m + 1) as { id: number };
return r.id;
});
const id = tx();
revalidatePath("/collection");
return { id, slug };
}
export async function createCollectionAction(formData: FormData) {
const name = String(formData.get("name") ?? "");
const description = String(formData.get("description") ?? "");
const created = await createCollection(name, description);
if (created) redirect(`/collection/${created.slug}`);
}
export async function addImageToCollection(collectionId: number, imageId: number) {
// Wrap the read-then-insert in a transaction so concurrent calls
// can't both compute the same MAX(position) and produce duplicate
// ordering values.
const tx = rawDb.transaction(() => {
const max = rawDb.prepare(`SELECT COALESCE(MAX(position), -1) AS m FROM collection_images WHERE collection_id = ?`).get(collectionId) as { m: number };
rawDb.prepare(`
INSERT OR IGNORE INTO collection_images (collection_id, image_id, position) VALUES (?, ?, ?)
`).run(collectionId, imageId, max.m + 1);
rawDb.prepare(`UPDATE collections SET last_used_at = (unixepoch() * 1000) WHERE id = ?`).run(collectionId);
});
tx();
revalidatePath(`/collection`);
revalidatePath(`/image/${imageId}`);
}
export async function removeImageFromCollection(collectionId: number, imageId: number) {
rawDb.prepare(`DELETE FROM collection_images WHERE collection_id = ? AND image_id = ?`).run(collectionId, imageId);
revalidatePath(`/collection`);
revalidatePath(`/image/${imageId}`);
}
export async function deleteCollection(collectionId: number) {
rawDb.prepare(`DELETE FROM collections WHERE id = ?`).run(collectionId);
revalidatePath("/collection");
redirect("/collection");
}
/**
* Reorder a single image within a collection. The drag-and-drop UI passes
* the image being moved and the image it should now sit before (or null
* to drop at the end). We pull the current ordered list, splice the
* moved image into its new index, then rewrite every position so the
* sequence is dense (0..N-1) regardless of any gaps the previous
* ordering may have had.
*/
export async function reorderCollectionImage(
collectionId: number,
movedImageId: number,
beforeImageId: number | null,
): Promise<void> {
const rows = rawDb.prepare(`
SELECT image_id FROM collection_images
WHERE collection_id = ?
ORDER BY position ASC, image_id ASC
`).all(collectionId) as Array<{ image_id: number }>;
const ids = rows.map((r) => r.image_id);
const fromIdx = ids.indexOf(movedImageId);
if (fromIdx === -1) return;
ids.splice(fromIdx, 1);
let toIdx = beforeImageId == null ? ids.length : ids.indexOf(beforeImageId);
if (toIdx === -1) toIdx = ids.length;
ids.splice(toIdx, 0, movedImageId);
const tx = rawDb.transaction(() => {
const update = rawDb.prepare(`
UPDATE collection_images SET position = ? WHERE collection_id = ? AND image_id = ?
`);
for (let i = 0; i < ids.length; i++) {
update.run(i, collectionId, ids[i]);
}
});
tx();
const slugRow = rawDb.prepare(`SELECT slug FROM collections WHERE id = ?`).get(collectionId) as { slug: string } | undefined;
revalidatePath("/collection");
if (slugRow) revalidatePath(`/collection/${slugRow.slug}`);
}
/**
* Reorder a collection in the manual list on /collection. Pulls the
* current ordered ids, splices the moved one to its new index, then
* rewrites every position so the sequence is dense (0..N-1).
*/
export async function reorderCollection(
movedId: number,
beforeId: number | null,
): Promise<void> {
const rows = rawDb.prepare(`
SELECT id FROM collections ORDER BY position ASC, id ASC
`).all() as Array<{ id: number }>;
const ids = rows.map((r) => r.id);
const fromIdx = ids.indexOf(movedId);
if (fromIdx === -1) return;
ids.splice(fromIdx, 1);
let toIdx = beforeId == null ? ids.length : ids.indexOf(beforeId);
if (toIdx === -1) toIdx = ids.length;
ids.splice(toIdx, 0, movedId);
const tx = rawDb.transaction(() => {
const update = rawDb.prepare(`UPDATE collections SET position = ? WHERE id = ?`);
for (let i = 0; i < ids.length; i++) update.run(i, ids[i]);
});
tx();
revalidatePath("/collection");
}
/** Rename a collection. Returns the new slug for client-side redirect.
* Wraps the slug-uniqueness check + UPDATE in a transaction so two
* concurrent renames can't both compute the same slug and crash on
* the UNIQUE constraint (or worse, race past it). */
export async function renameCollection(id: number, name: string): Promise<{ slug: string; name: string } | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const tx = rawDb.transaction(() => {
const current = rawDb.prepare(`SELECT name, slug FROM collections WHERE id = ?`).get(id) as
| { name: string; slug: string }
| undefined;
if (!current) return null;
if (current.name === trimmed) {
return { slug: current.slug, name: trimmed };
}
const slug = uniqueSlug(rawDb, "collections", trimmed, id);
rawDb.prepare(`UPDATE collections SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id);
return { slug, name: trimmed };
});
const result = tx() as { slug: string; name: string } | null;
if (!result) return null;
revalidatePath("/collection");
revalidatePath(`/collection/${result.slug}`);
return result;
}
+265
View File
@@ -0,0 +1,265 @@
"use server";
import { revalidatePath } from "next/cache";
import path from "node:path";
import fs from "node:fs/promises";
import { rawDb, uniqueSlug } from "@/lib/db/client";
import { sanitizeFilename, uniqueFilePath, letterBucket, canonicalThumbName } from "@/lib/filename";
import { safeJoin } from "@/lib/safePath";
import { normalizeCode } from "@/lib/jav/codeParser";
const LIBRARY_ROOT = path.join(process.cwd(), "library");
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
export interface CoverMetaInput {
imageId: number;
code: string | null;
title: string | null;
releaseDate: string | null;
runtimeMin: number | null;
director: string | null;
studio: string | null;
label: string | null;
series: string | null;
rating: number | null;
watched: boolean;
notes: string | null;
actresses: string[];
genres: string[];
}
function upsertEntity(table: "studios" | "labels" | "series" | "actresses" | "genres", name: string): number {
const trimmed = name.trim();
const existing = rawDb.prepare(`SELECT id FROM ${table} WHERE name = ?`).get(trimmed) as { id: number } | undefined;
if (existing) return existing.id;
const slug = uniqueSlug(rawDb, table, trimmed);
const row = rawDb.prepare(`INSERT INTO ${table} (name, slug) VALUES (?, ?) RETURNING id`).get(trimmed, slug) as { id: number };
return row.id;
}
export async function saveCoverMeta(input: CoverMetaInput): Promise<{ ok: true }> {
const studioId = input.studio?.trim() ? upsertEntity("studios", input.studio) : null;
const labelId = input.label?.trim() ? upsertEntity("labels", input.label) : null;
const seriesId = input.series?.trim() ? upsertEntity("series", input.series) : null;
// Snapshot the previous code so we can detect a code rename and move
// the file (and its attachments) to the correct letter bucket, plus
// rename their thumbnail files to keep the prefix in sync.
const prev = rawDb.prepare(`SELECT code FROM images WHERE id = ?`).get(input.imageId) as
| { code: string | null }
| undefined;
const tx = rawDb.transaction(() => {
rawDb.prepare(`
UPDATE images SET
code = ?, title = ?, release_date = ?, runtime_min = ?, director = ?,
studio_id = ?, label_id = ?, series_id = ?,
rating = ?, watched = ?, notes = ?
WHERE id = ?
`).run(
normalizeCoverCode(input.code),
norm(input.title),
norm(input.releaseDate),
// Coerce strings → numbers so FormData callers (where everything
// arrives as a string) don't get nulled out. Number.isFinite("5")
// is false, but Number("5") is 5.
(() => {
const raw: unknown = input.runtimeMin;
const v = typeof raw === "string" ? Number(raw) : raw;
return typeof v === "number" && Number.isFinite(v) ? v : null;
})(),
norm(input.director),
studioId,
labelId,
seriesId,
typeof input.rating === "number" && input.rating >= 0 && input.rating <= 5 ? input.rating : null,
input.watched ? 1 : 0,
norm(input.notes),
input.imageId,
);
rawDb.prepare(`DELETE FROM image_actresses WHERE image_id = ?`).run(input.imageId);
for (const name of dedupeNames(input.actresses)) {
const id = upsertEntity("actresses", name);
rawDb.prepare(`INSERT OR IGNORE INTO image_actresses (image_id, actress_id) VALUES (?, ?)`).run(input.imageId, id);
}
rawDb.prepare(`DELETE FROM image_genres WHERE image_id = ?`).run(input.imageId);
for (const name of dedupeNames(input.genres)) {
const id = upsertEntity("genres", name);
rawDb.prepare(`INSERT OR IGNORE INTO image_genres (image_id, genre_id) VALUES (?, ?)`).run(input.imageId, id);
}
});
tx();
// After the DB update commits, see if the code rename crossed bucket
// boundaries. If so, move the cover file and any attached children.
// Failure here logs but doesn't roll back the DB — the file move is
// best-effort and a later Reorganize run will fix any drift.
const oldBucket = letterBucket(prev?.code ?? null).dirRel;
const newCode = normalizeCoverCode(input.code);
const newBucket = letterBucket(newCode).dirRel;
if (oldBucket !== newBucket) {
await moveImageBucket(input.imageId, newBucket).catch((e) => {
console.error(`[saveCoverMeta] bucket move failed for image ${input.imageId}:`, e);
});
}
// Code embeds in the thumbnail filename — rename whenever the code
// changes, regardless of bucket. Cascades to attachments since they
// bucket (and prefix) with the parent.
if ((prev?.code ?? null) !== newCode) {
await renameThumbsForCover(input.imageId, newCode).catch((e) => {
console.error(`[saveCoverMeta] thumb rename failed for image ${input.imageId}:`, e);
});
}
revalidatePath(`/image/${input.imageId}`);
revalidatePath("/");
return { ok: true };
}
/**
* Rename the thumbnail file(s) for a cover and its attachments after the
* cover's code changed. Embeds the new code in the filename via
* canonicalThumbName(); updates `thumb_path` in the DB.
*/
async function renameThumbsForCover(imageId: number, newCode: string | null): Promise<void> {
const rows = rawDb.prepare(`
SELECT id, sha256, thumb_path FROM images
WHERE id = ? OR parent_image_id = ?
`).all(imageId, imageId) as Array<{ id: number; sha256: string; thumb_path: string }>;
for (const row of rows) {
const target = canonicalThumbName(newCode, row.sha256);
if (target === row.thumb_path) continue;
const oldAbs = safeJoin(THUMB_ROOT, row.thumb_path);
const newAbs = path.join(THUMB_ROOT, target);
if (oldAbs) {
try {
await fs.rename(oldAbs, newAbs);
} catch {
// Source missing or rename failed; leave thumb_path untouched
// so the regenerator can pick it up.
continue;
}
}
rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, row.id);
}
}
/**
* Move an image (and any attachments parented on it) into the given
* bucket directory. Updates rel_path for each row to match the new
* on-disk location. The thumb dir stays flat — only library/ files
* are letter-bucketed.
*/
async function moveImageBucket(imageId: number, newBucketDir: string): Promise<void> {
const targets = rawDb.prepare(`
SELECT id, filename, rel_path FROM images
WHERE id = ? OR parent_image_id = ?
`).all(imageId, imageId) as Array<{ id: number; filename: string; rel_path: string }>;
await fs.mkdir(path.join(LIBRARY_ROOT, newBucketDir), { recursive: true });
for (const row of targets) {
const oldAbs = safeJoin(LIBRARY_ROOT, row.rel_path);
if (!oldAbs) continue;
const currentDir = path.posix.dirname(row.rel_path.replace(/\\/g, "/"));
if (currentDir === newBucketDir) continue; // already in place
const { base, ext } = sanitizeFilename(row.filename || path.basename(row.rel_path));
const newAbs = await uniqueFilePath(path.join(LIBRARY_ROOT, newBucketDir), base, ext);
// uniqueFilePath reserves the slot by creating a 0-byte file via wx.
// On Windows fs.rename() fails when the destination exists, so we
// unlink the placeholder right before the rename. Tiny race window
// is acceptable — single-process server actions, not concurrent uploads.
await fs.rm(newAbs, { force: true }).catch(() => {});
try {
await fs.rename(oldAbs, newAbs);
} catch {
// Source missing or rename failed; skip this row.
continue;
}
const newRel = path.posix.join(newBucketDir, path.basename(newAbs));
rawDb.prepare(`UPDATE images SET rel_path = ? WHERE id = ?`).run(newRel, row.id);
}
}
/** Revalidate every entity index that lists covers. Toggling a flag can
* change visibility under any of these (filters by VIP/Favorite/Owned/
* Watched are common across listings). Mirrors trash.ts's revalidate(). */
function revalidateAllCoverIndexes(imageId: number): void {
revalidatePath(`/image/${imageId}`);
revalidatePath("/");
revalidatePath("/collection");
revalidatePath("/tag");
revalidatePath("/actress");
revalidatePath("/studios");
revalidatePath("/series");
revalidatePath("/genres");
revalidatePath("/labels");
}
export async function setWatched(imageId: number, watched: boolean): Promise<void> {
rawDb.prepare(`UPDATE images SET watched = ? WHERE id = ?`).run(watched ? 1 : 0, imageId);
revalidateAllCoverIndexes(imageId);
}
export async function setCoverVip(imageId: number, vip: boolean): Promise<void> {
// VIP and Favorite are mutually exclusive — turning one on clears the other.
if (vip) {
rawDb.prepare(`UPDATE images SET is_vip = 1, is_favorite = 0 WHERE id = ?`).run(imageId);
} else {
rawDb.prepare(`UPDATE images SET is_vip = 0 WHERE id = ?`).run(imageId);
}
revalidateAllCoverIndexes(imageId);
}
export async function setCoverFavorite(imageId: number, favorite: boolean): Promise<void> {
if (favorite) {
rawDb.prepare(`UPDATE images SET is_favorite = 1, is_vip = 0 WHERE id = ?`).run(imageId);
} else {
rawDb.prepare(`UPDATE images SET is_favorite = 0 WHERE id = ?`).run(imageId);
}
revalidateAllCoverIndexes(imageId);
}
export async function setRating(imageId: number, rating: number | null): Promise<void> {
let v: number | null = null;
if (rating != null && Number.isFinite(rating)) {
v = Math.max(0, Math.min(5, Math.round(rating)));
}
rawDb.prepare(`UPDATE images SET rating = ? WHERE id = ?`).run(v, imageId);
revalidateAllCoverIndexes(imageId);
}
export async function setCoverOwned(imageId: number, owned: boolean): Promise<void> {
rawDb.prepare(`UPDATE images SET is_owned = ? WHERE id = ?`).run(owned ? 1 : 0, imageId);
revalidateAllCoverIndexes(imageId);
}
function norm(v: string | null): string | null {
if (v == null) return null;
const t = v.trim();
return t === "" ? null : t;
}
function normalizeCoverCode(v: string | null): string | null {
const t = norm(v);
if (!t) return null;
const normalized = normalizeCode(t);
if (normalized) return normalized;
const safe = t.toUpperCase().replace(/[^A-Z0-9-]/g, "");
return safe || null;
}
function dedupeNames(names: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of names) {
const t = raw.trim();
if (!t) continue;
const key = t.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
out.push(t);
}
return out;
}
+128
View File
@@ -0,0 +1,128 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { rawDb, uniqueSlug } from "@/lib/db/client";
type EntityTable = "actresses" | "studios" | "series";
function createEntity(table: EntityTable, name: string): { id: number; slug: string } | null {
const trimmed = name.trim();
if (!trimmed) return null;
const existing = rawDb.prepare(`SELECT id, slug FROM ${table} WHERE name = ?`).get(trimmed) as
| { id: number; slug: string }
| undefined;
if (existing) return existing;
const slug = uniqueSlug(rawDb, table, trimmed);
const row = rawDb.prepare(`INSERT INTO ${table} (name, slug) VALUES (?, ?) RETURNING id`).get(trimmed, slug) as {
id: number;
};
return { id: row.id, slug };
}
export async function createActressAction(formData: FormData) {
createEntity("actresses", String(formData.get("name") ?? ""));
revalidatePath("/actress");
}
export async function createStudioAction(formData: FormData) {
const created = createEntity("studios", String(formData.get("name") ?? ""));
revalidatePath("/studios");
if (created) redirect(`/studios/${created.slug}`);
}
export async function createSeriesAction(formData: FormData) {
const created = createEntity("series", String(formData.get("name") ?? ""));
revalidatePath("/series");
if (created) redirect(`/series/${created.slug}`);
}
/** Deletes a studio. Any covers referencing it have studio_id set to NULL. */
export async function deleteStudio(id: number) {
rawDb.prepare(`UPDATE images SET studio_id = NULL WHERE studio_id = ?`).run(id);
rawDb.prepare(`DELETE FROM studios WHERE id = ?`).run(id);
revalidatePath("/studios");
revalidatePath("/");
redirect("/studios");
}
/** Deletes a series. Any covers referencing it have series_id set to NULL. */
export async function deleteSeries(id: number) {
rawDb.prepare(`UPDATE images SET series_id = NULL WHERE series_id = ?`).run(id);
rawDb.prepare(`DELETE FROM series WHERE id = ?`).run(id);
revalidatePath("/series");
revalidatePath("/");
redirect("/series");
}
/** Rename a studio. Returns the new slug (which may be re-uniquified) or null. */
export async function renameStudio(id: number, name: string): Promise<{ slug: string } | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const row = rawDb.prepare(`SELECT name, slug FROM studios WHERE id = ?`).get(id) as { name: string; slug: string } | undefined;
if (!row) return null;
if (trimmed === row.name) return { slug: row.slug };
const slug = uniqueSlug(rawDb, "studios", trimmed, id);
rawDb.prepare(`UPDATE studios SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id);
revalidatePath("/studios");
revalidatePath(`/studios/${row.slug}`);
if (slug !== row.slug) revalidatePath(`/studios/${slug}`);
return { slug };
}
/** Rename a series. Returns the new slug or null. */
export async function renameSeries(id: number, name: string): Promise<{ slug: string } | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const row = rawDb.prepare(`SELECT name, slug FROM series WHERE id = ?`).get(id) as { name: string; slug: string } | undefined;
if (!row) return null;
if (trimmed === row.name) return { slug: row.slug };
const slug = uniqueSlug(rawDb, "series", trimmed, id);
rawDb.prepare(`UPDATE series SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id);
revalidatePath("/series");
revalidatePath(`/series/${row.slug}`);
if (slug !== row.slug) revalidatePath(`/series/${slug}`);
return { slug };
}
/** Rename a tag (keyed by name). Returns the new name or null. */
export async function renameTag(oldName: string, newName: string): Promise<{ name: string } | null> {
// Tags are stored lowercased (see tags.ts createTag/addTagToImage).
// Lookup by exact match must use the same casing or it silently misses.
const oldTrim = oldName.trim().toLowerCase();
const newTrim = newName.trim().toLowerCase();
if (!oldTrim || !newTrim) return null;
if (oldTrim === newTrim) return { name: oldTrim };
const row = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(oldTrim) as { id: number } | undefined;
if (!row) return null;
// Wrap merge in a transaction so a concurrent INSERT into image_tags
// for the old tag_id can't slip between the UPDATE re-point and the
// DELETE — both run atomically against a consistent snapshot.
const merge = rawDb.transaction(() => {
const conflict = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(newTrim) as { id: number } | undefined;
if (conflict && conflict.id !== row.id) {
rawDb.prepare(`UPDATE OR IGNORE image_tags SET tag_id = ? WHERE tag_id = ?`).run(conflict.id, row.id);
rawDb.prepare(`DELETE FROM image_tags WHERE tag_id = ?`).run(row.id);
rawDb.prepare(`DELETE FROM tags WHERE id = ?`).run(row.id);
} else {
rawDb.prepare(`UPDATE tags SET name = ? WHERE id = ?`).run(newTrim, row.id);
}
});
merge();
revalidatePath("/tag");
revalidatePath(`/tag/${encodeURIComponent(oldTrim)}`);
revalidatePath(`/tag/${encodeURIComponent(newTrim)}`);
return { name: newTrim };
}
/** Deletes a tag. Any image_tags rows referencing it are removed. */
export async function deleteTag(name: string) {
const trimmed = name.trim();
if (!trimmed) return;
const row = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(trimmed) as { id: number } | undefined;
if (!row) return;
rawDb.prepare(`DELETE FROM image_tags WHERE tag_id = ?`).run(row.id);
rawDb.prepare(`DELETE FROM tags WHERE id = ?`).run(row.id);
revalidatePath("/tag");
revalidatePath("/");
redirect("/tag");
}
+6
View File
@@ -0,0 +1,6 @@
"use server";
import { getImageContextData } from "@/lib/db/queries";
export async function fetchImageContextData(imageIds: number[]) {
return getImageContextData(imageIds);
}
+620
View File
@@ -0,0 +1,620 @@
"use server";
import path from "node:path";
import fs from "node:fs/promises";
import sharp from "sharp";
import { rawDb } from "@/lib/db/client";
import { sanitizeFilename, uniqueFilePath, letterBucket, canonicalThumbName } from "@/lib/filename";
import { extractCode } from "@/lib/jav/codeParser";
import { computeDHash, hammingDistance } from "@/lib/jav/phash";
import { clearAppSettingsCache } from "@/lib/db/appSettings";
import { safeJoin } from "@/lib/safePath";
import { revalidatePath } from "next/cache";
const LIBRARY_ROOT = path.join(process.cwd(), "library");
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
const PORTRAIT_ROOT = path.join(process.cwd(), "data", "portraits");
const CATEGORY_COVER_ROOT = path.join(process.cwd(), "data", "category-covers");
const COLLECTION_COVER_ROOT = path.join(process.cwd(), "data", "collection-covers");
const SYSTEM_FILES = new Set([".ds_store", "thumbs.db", "desktop.ini"]);
interface OrphanReport {
libraryFiles: string[];
thumbFiles: string[];
portraitFiles: string[];
categoryCoverFiles: string[];
collectionCoverFiles: string[];
bytes: number;
}
async function walk(dir: string): Promise<string[]> {
let entries: import("node:fs").Dirent[] = [];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return [];
}
const out: string[] = [];
await Promise.all(entries.map(async (e) => {
const full = path.join(dir, e.name);
if (e.isDirectory()) {
out.push(...(await walk(full)));
} else if (e.isFile() && !SYSTEM_FILES.has(e.name.toLowerCase())) {
out.push(full);
}
}));
return out;
}
async function findOrphans(): Promise<OrphanReport> {
const knownLibrary = new Set(
(rawDb.prepare(`SELECT rel_path FROM images`).all() as Array<{ rel_path: string }>)
.map((r) => path.normalize(r.rel_path)),
);
const knownThumbs = new Set(
(rawDb.prepare(`SELECT thumb_path FROM images`).all() as Array<{ thumb_path: string }>)
.map((r) => path.normalize(r.thumb_path)),
);
const knownPortraits = new Set(
(rawDb
.prepare(`
SELECT portrait_path AS p FROM actresses WHERE portrait_path IS NOT NULL
UNION ALL SELECT portrait2_path FROM actresses WHERE portrait2_path IS NOT NULL
UNION ALL SELECT portrait3_path FROM actresses WHERE portrait3_path IS NOT NULL
UNION ALL SELECT portrait4_path FROM actresses WHERE portrait4_path IS NOT NULL
UNION ALL SELECT portraith_path FROM actresses WHERE portraith_path IS NOT NULL
`)
.all() as Array<{ p: string }>)
.map((r) => path.normalize(r.p)),
);
const knownCategoryCovers = new Set(
(rawDb
.prepare(`
SELECT cover_portrait_path AS p FROM tag_categories WHERE cover_portrait_path IS NOT NULL
UNION ALL SELECT cover_landscape_path FROM tag_categories WHERE cover_landscape_path IS NOT NULL
`)
.all() as Array<{ p: string }>)
.map((r) => path.normalize(r.p)),
);
const knownCollectionCovers = new Set(
(rawDb
.prepare(`
SELECT cover_portrait_path AS p FROM collections WHERE cover_portrait_path IS NOT NULL
UNION ALL SELECT cover_landscape_path FROM collections WHERE cover_landscape_path IS NOT NULL
`)
.all() as Array<{ p: string }>)
.map((r) => path.normalize(r.p)),
);
const [libFiles, thumbFiles, portraitFiles, categoryCoverFiles, collectionCoverFiles] = await Promise.all([
walk(LIBRARY_ROOT),
walk(THUMB_ROOT),
walk(PORTRAIT_ROOT),
walk(CATEGORY_COVER_ROOT),
walk(COLLECTION_COVER_ROOT),
]);
const libraryOrphans = libFiles.filter((abs) => {
const rel = path.normalize(path.relative(LIBRARY_ROOT, abs));
return !knownLibrary.has(rel);
});
const thumbOrphans = thumbFiles.filter((abs) => {
const rel = path.normalize(path.relative(THUMB_ROOT, abs));
return !knownThumbs.has(rel);
});
const portraitOrphans = portraitFiles.filter((abs) => {
const rel = path.normalize(path.relative(PORTRAIT_ROOT, abs));
return !knownPortraits.has(rel);
});
const categoryCoverOrphans = categoryCoverFiles.filter((abs) => {
const rel = path.normalize(path.relative(CATEGORY_COVER_ROOT, abs));
return !knownCategoryCovers.has(rel);
});
const collectionCoverOrphans = collectionCoverFiles.filter((abs) => {
const rel = path.normalize(path.relative(COLLECTION_COVER_ROOT, abs));
return !knownCollectionCovers.has(rel);
});
let bytes = 0;
await Promise.all([
...libraryOrphans, ...thumbOrphans, ...portraitOrphans,
...categoryCoverOrphans, ...collectionCoverOrphans,
].map(async (f) => {
try { bytes += (await fs.stat(f)).size; } catch {}
}));
return {
libraryFiles: libraryOrphans,
thumbFiles: thumbOrphans,
portraitFiles: portraitOrphans,
categoryCoverFiles: categoryCoverOrphans,
collectionCoverFiles: collectionCoverOrphans,
bytes,
};
}
export async function previewOrphanFiles(): Promise<{ count: number; bytes: number }> {
const report = await findOrphans();
const count =
report.libraryFiles.length +
report.thumbFiles.length +
report.portraitFiles.length +
report.categoryCoverFiles.length +
report.collectionCoverFiles.length;
return { count, bytes: report.bytes };
}
export async function purgeOrphanFiles(): Promise<{ deleted: number; bytes: number }> {
const report = await findOrphans();
const all = [
...report.libraryFiles,
...report.thumbFiles,
...report.portraitFiles,
...report.categoryCoverFiles,
...report.collectionCoverFiles,
];
// Bound concurrency: Promise.all over thousands of fs.rm calls can
// exhaust file descriptors (EMFILE) on Windows / low-ulimit hosts.
const CONCURRENCY = 32;
for (let i = 0; i < all.length; i += CONCURRENCY) {
await Promise.all(all.slice(i, i + CONCURRENCY).map((f) => fs.rm(f, { force: true })));
}
// Sweep empty subdirs across every root that just shed files.
await Promise.all([
cleanEmptyDirs(LIBRARY_ROOT),
cleanEmptyDirs(THUMB_ROOT),
cleanEmptyDirs(PORTRAIT_ROOT),
cleanEmptyDirs(CATEGORY_COVER_ROOT),
cleanEmptyDirs(COLLECTION_COVER_ROOT),
]);
// Indexes that show cover/portrait/thumb counts need to refetch.
revalidatePath("/");
revalidatePath("/category");
revalidatePath("/collection");
revalidatePath("/actress");
return { deleted: all.length, bytes: report.bytes };
}
interface ReorganizePreview {
total: number;
toMove: number;
}
interface ImageRow {
id: number;
filename: string;
rel_path: string;
code: string | null;
parent_image_id: number | null;
}
/**
* Resolve the target letter-bucket directory for a row. Attached images
* (parent_image_id set) bucket with their parent's code so related files
* stay together on disk.
*/
function plannedDirRel(row: ImageRow, parentCodeById: Map<number, string | null>): string {
if (row.parent_image_id != null) {
const parentCode = parentCodeById.get(row.parent_image_id) ?? null;
return letterBucket(parentCode).dirRel;
}
return letterBucket(row.code).dirRel;
}
function loadAllImages(): { rows: ImageRow[]; parentCodeById: Map<number, string | null> } {
const rows = rawDb.prepare(`SELECT id, filename, rel_path, code, parent_image_id FROM images`).all() as ImageRow[];
const parentCodeById = new Map<number, string | null>();
for (const r of rows) parentCodeById.set(r.id, r.code);
return { rows, parentCodeById };
}
export async function previewReorganize(): Promise<ReorganizePreview> {
const { rows, parentCodeById } = loadAllImages();
let toMove = 0;
for (const r of rows) {
const target = plannedDirRel(r, parentCodeById);
const currentDir = path.posix.dirname(r.rel_path.replace(/\\/g, "/"));
if (currentDir !== target) toMove++;
}
return { total: rows.length, toMove };
}
export async function reorganizeFiles(): Promise<{ moved: number; skipped: number; errors: number }> {
const { rows, parentCodeById } = loadAllImages();
let moved = 0, skipped = 0, errors = 0;
for (const r of rows) {
const target = plannedDirRel(r, parentCodeById);
const currentDir = path.posix.dirname(r.rel_path.replace(/\\/g, "/"));
if (currentDir === target) { skipped++; continue; }
const oldAbs = path.join(LIBRARY_ROOT, r.rel_path);
try {
await fs.access(oldAbs);
} catch {
errors++;
continue;
}
const { base, ext } = sanitizeFilename(r.filename || `image${path.extname(r.rel_path)}`);
const dirAbs = path.join(LIBRARY_ROOT, target);
try {
await fs.mkdir(dirAbs, { recursive: true });
const newAbs = await uniqueFilePath(dirAbs, base, ext);
await fs.rename(oldAbs, newAbs);
const newRel = path.posix.join(target, path.basename(newAbs));
rawDb.prepare(`UPDATE images SET rel_path = ? WHERE id = ?`).run(newRel, r.id);
moved++;
} catch {
errors++;
}
}
await cleanEmptyDirs(LIBRARY_ROOT);
revalidatePath("/");
return { moved, skipped, errors };
}
export async function clearCache(): Promise<{ ok: true }> {
clearAppSettingsCache();
for (const p of ["/", "/collection", "/tag", "/category", "/actress", "/studios", "/series", "/genres", "/queue"]) {
revalidatePath(p);
}
return { ok: true };
}
export interface UndersizedCover {
id: number;
code: string | null;
filename: string;
width: number;
height: number;
bytes: number;
thumbPath: string;
}
/**
* Scan top-level covers whose pixel dimensions look smaller than a
* standard JAV cover (typically 800x538). Catches accidental imports of
* thumbnails, web previews, or other non-cover images.
*
* Defaults are deliberately permissive — the standard is 800x538 but real
* scans/rips drift by a few pixels in either direction. The 147x200
* outlier the user spotted falls well below the floor.
*/
export async function scanUndersizedCovers(opts?: {
minWidth?: number;
minHeight?: number;
}): Promise<UndersizedCover[]> {
const minW = opts?.minWidth ?? 750;
const minH = opts?.minHeight ?? 500;
return rawDb.prepare(`
SELECT id, code, filename, width, height, bytes, thumb_path AS thumbPath
FROM images
WHERE parent_image_id IS NULL
AND deleted_at IS NULL
AND (width < ? OR height < ?)
ORDER BY (width * height) ASC, id ASC
`).all(minW, minH) as UndersizedCover[];
}
interface RegenThumbsPreview {
total: number;
missing: number;
staleNames: number;
}
/**
* Resolve the planned canonical filename for a row: includes parent code
* lookup for attached images so back-covers inherit the prefix.
*/
function plannedThumbName(row: { sha256: string; code: string | null; parent_image_id: number | null }): string {
if (row.parent_image_id != null) {
const parent = rawDb.prepare(`SELECT code FROM images WHERE id = ?`).get(row.parent_image_id) as
| { code: string | null }
| undefined;
return canonicalThumbName(parent?.code ?? null, row.sha256);
}
return canonicalThumbName(row.code, row.sha256);
}
/** Count covers whose thumb file is missing on disk or whose stored name is stale. */
export async function previewRegenThumbnails(): Promise<RegenThumbsPreview> {
const rows = rawDb.prepare(`
SELECT thumb_path, sha256, code, parent_image_id FROM images WHERE deleted_at IS NULL
`).all() as Array<{ thumb_path: string; sha256: string; code: string | null; parent_image_id: number | null }>;
let missing = 0;
let staleNames = 0;
// Sequential is fine for personal-library scale; a bulk Promise.all here
// can blow up with EMFILE on very large libraries.
for (const r of rows) {
const target = plannedThumbName(r);
if (target !== r.thumb_path) staleNames++;
const targetAbs = path.join(THUMB_ROOT, target);
try { await fs.access(targetAbs); } catch { missing++; }
}
return { total: rows.length, missing, staleNames };
}
/**
* Rebuild thumbnails. Three paths per row:
* 1. Canonical file already on disk → skip (unless `force`).
* 2. Legacy file (different name from canonical) is on disk → rename it
* to canonical and update thumb_path. No re-encode needed; this is
* the migration path for libraries that predate the code-prefix
* naming.
* 3. Neither file is on disk → read original from library/ and encode
* from scratch.
*/
export async function regenerateThumbnails(opts?: { force?: boolean }): Promise<{ regenerated: number; renamed: number; skipped: number; errors: number }> {
const force = opts?.force ?? false;
const rows = rawDb.prepare(`
SELECT id, rel_path, thumb_path, sha256, code, parent_image_id FROM images WHERE deleted_at IS NULL
`).all() as Array<{ id: number; rel_path: string; thumb_path: string; sha256: string; code: string | null; parent_image_id: number | null }>;
await fs.mkdir(THUMB_ROOT, { recursive: true });
let regenerated = 0, renamed = 0, skipped = 0, errors = 0;
for (const r of rows) {
const target = plannedThumbName(r);
const targetAbs = path.join(THUMB_ROOT, target);
if (!force) {
try {
await fs.access(targetAbs);
// Canonical file exists. If the DB still has the legacy name,
// sync the column so future operations don't drift.
if (r.thumb_path !== target) {
rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, r.id);
}
skipped++;
continue;
} catch { /* missing — fall through */ }
}
// Try the legacy/current path: if a thumb exists at the stored
// thumb_path that's different from canonical, rename it instead of
// re-encoding. Faster, lossless, preserves whatever the file already
// was.
if (r.thumb_path !== target) {
const oldAbs = safeJoin(THUMB_ROOT, r.thumb_path);
if (oldAbs) {
try {
await fs.access(oldAbs);
if (force) {
// Force mode: drop the old file and re-encode at canonical.
await fs.rm(oldAbs, { force: true }).catch(() => {});
} else {
await fs.rename(oldAbs, targetAbs);
rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, r.id);
renamed++;
continue;
}
} catch { /* legacy file missing — fall through to encode */ }
}
}
const libAbs = safeJoin(LIBRARY_ROOT, r.rel_path);
if (!libAbs) {
errors++;
continue;
}
try {
// Pass the file path to sharp instead of reading into a buffer.
// The library can contain multi-GB videos that were misclassified
// as images; reading those into memory would OOM the server.
// sharp streams from disk and reports its own decode errors.
// Mirrors lib/ingest/ingest.ts's resize pipeline.
await sharp(libAbs, { failOn: "none" })
.rotate()
.resize({ width: 768, height: 768, fit: "inside", withoutEnlargement: true })
.webp({ quality: 82 })
.toFile(targetAbs);
if (r.thumb_path !== target) {
rawDb.prepare(`UPDATE images SET thumb_path = ? WHERE id = ?`).run(target, r.id);
}
regenerated++;
} catch {
errors++;
}
}
revalidatePath("/");
return { regenerated, renamed, skipped, errors };
}
async function cleanEmptyDirs(root: string): Promise<void> {
let entries: import("node:fs").Dirent[] = [];
try { entries = await fs.readdir(root, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
if (!e.isDirectory()) continue;
const dir = path.join(root, e.name);
await cleanEmptyDirs(dir);
try {
const remaining = await fs.readdir(dir);
if (remaining.length === 0) await fs.rmdir(dir);
} catch {}
}
}
export interface ReparseCodesPreview {
total: number;
/** Rows with no code where extractCode now finds one — safe to fill. */
missing: number;
/** Rows where extractCode disagrees with the stored code — overwrite
* is destructive of any manual edit, so it's gated behind force=true. */
changed: number;
/** Sample of up to 20 changed rows for the preview UI. */
sampleChanges: Array<{ id: number; filename: string; oldCode: string; newCode: string }>;
}
/**
* Walk every top-level cover (parent_image_id IS NULL, not soft-deleted)
* and re-run extractCode against the stored filename. Reports how many
* rows would change so the user can preview before committing.
*/
export async function previewReparseCodes(): Promise<ReparseCodesPreview> {
const rows = rawDb.prepare(`
SELECT id, filename, code FROM images
WHERE deleted_at IS NULL AND parent_image_id IS NULL
`).all() as Array<{ id: number; filename: string; code: string | null }>;
let missing = 0, changed = 0;
const sampleChanges: ReparseCodesPreview["sampleChanges"] = [];
for (const r of rows) {
const extracted = extractCode(r.filename);
if (!extracted) continue;
if (r.code == null) {
missing++;
} else if (r.code !== extracted) {
changed++;
if (sampleChanges.length < 20) {
sampleChanges.push({ id: r.id, filename: r.filename, oldCode: r.code, newCode: extracted });
}
}
}
return { total: rows.length, missing, changed, sampleChanges };
}
/**
* Apply the re-parse. By default only fills rows with NULL code (safe);
* pass force=true to overwrite codes that disagree with extractCode.
*
* Note: this only updates the DB. Files won't move into their new
* letter buckets until you also run Reorganize. Same for thumbnail
* filenames — the code prefix in `<CODE>-<sha>.webp` won't update until
* Regenerate Thumbnails runs.
*/
export async function reparseCodes(opts?: { force?: boolean }): Promise<{ filled: number; updated: number; skipped: number }> {
const force = opts?.force ?? false;
const rows = rawDb.prepare(`
SELECT id, filename, code FROM images
WHERE deleted_at IS NULL AND parent_image_id IS NULL
`).all() as Array<{ id: number; filename: string; code: string | null }>;
let filled = 0, updated = 0, skipped = 0;
const tx = rawDb.transaction(() => {
const update = rawDb.prepare(`UPDATE images SET code = ? WHERE id = ?`);
for (const r of rows) {
const extracted = extractCode(r.filename);
if (!extracted) { skipped++; continue; }
if (r.code == null) {
update.run(extracted, r.id);
filled++;
} else if (r.code !== extracted) {
if (force) {
update.run(extracted, r.id);
updated++;
} else {
skipped++;
}
} else {
skipped++;
}
}
});
tx();
revalidatePath("/");
return { filled, updated, skipped };
}
export interface NearDupePair {
a: { id: number; code: string | null; filename: string; thumbPath: string; width: number; height: number; bytes: number };
b: { id: number; code: string | null; filename: string; thumbPath: string; width: number; height: number; bytes: number };
distance: number;
}
export interface NearDupesPreview {
total: number;
hashed: number;
unhashed: number;
}
/** Quick stats: how many rows already have a phash vs need backfilling. */
export async function previewNearDupes(): Promise<NearDupesPreview> {
const row = rawDb.prepare(`
SELECT
COUNT(*) AS total,
SUM(CASE WHEN phash IS NOT NULL THEN 1 ELSE 0 END) AS hashed
FROM images WHERE deleted_at IS NULL
`).get() as { total: number; hashed: number };
return {
total: row.total,
hashed: row.hashed,
unhashed: row.total - row.hashed,
};
}
/**
* Backfill `phash` for every row that doesn't have one yet. Reads the
* library file, computes dHash, writes to DB. Skips rows whose file is
* missing on disk.
*/
export async function backfillPhashes(): Promise<{ hashed: number; skipped: number; errors: number }> {
const rows = rawDb.prepare(`
SELECT id, rel_path FROM images
WHERE deleted_at IS NULL AND phash IS NULL
`).all() as Array<{ id: number; rel_path: string }>;
let hashed = 0, skipped = 0, errors = 0;
const update = rawDb.prepare(`UPDATE images SET phash = ? WHERE id = ?`);
for (const r of rows) {
const abs = safeJoin(LIBRARY_ROOT, r.rel_path);
if (!abs) { errors++; continue; }
try {
const buf = await fs.readFile(abs);
const hash = await computeDHash(buf);
update.run(hash, r.id);
hashed++;
} catch {
errors++;
}
}
return { hashed, skipped, errors };
}
/**
* Find pairs of covers whose dHashes are within `threshold` Hamming
* distance. Brute force O(n²); fine for personal-library scale (5k
* covers ≈ 12.5M comparisons, runs in well under a second).
*
* Excludes pairs that are already SHA-identical (those are caught by
* upload dedup) and excludes attached-image pairs (those are
* intentionally similar to their parent).
*
* Default threshold = 10 (out of 64 bits) is a strong "same image,
* different encode" signal.
*/
export async function findNearDuplicates(opts?: { threshold?: number; limit?: number }): Promise<NearDupePair[]> {
const threshold = opts?.threshold ?? 10;
const limit = opts?.limit ?? 200;
const rows = rawDb.prepare(`
SELECT id, code, filename, rel_path, thumb_path AS thumbPath, sha256, phash, width, height, bytes
FROM images
WHERE deleted_at IS NULL AND parent_image_id IS NULL AND phash IS NOT NULL
ORDER BY id ASC
`).all() as Array<{
id: number; code: string | null; filename: string; rel_path: string; thumbPath: string;
sha256: string; phash: string; width: number; height: number; bytes: number;
}>;
const pairs: NearDupePair[] = [];
for (let i = 0; i < rows.length && pairs.length < limit; i++) {
for (let j = i + 1; j < rows.length && pairs.length < limit; j++) {
const a = rows[i];
const b = rows[j];
if (a.sha256 === b.sha256) continue; // SHA-identical pairs handled elsewhere
const d = hammingDistance(a.phash, b.phash);
if (d <= threshold) {
pairs.push({
a: { id: a.id, code: a.code, filename: a.filename, thumbPath: a.thumbPath, width: a.width, height: a.height, bytes: a.bytes },
b: { id: b.id, code: b.code, filename: b.filename, thumbPath: b.thumbPath, width: b.width, height: b.height, bytes: b.bytes },
distance: d,
});
}
}
}
// Sort tightest matches first, then by lowest id pair for stability.
pairs.sort((x, y) => x.distance - y.distance || x.a.id - y.a.id || x.b.id - y.b.id);
return pairs;
}
+8
View File
@@ -0,0 +1,8 @@
"use server";
import { listImagesByIds } from "@/lib/db/queries";
import type { CardImage } from "@/components/grid/ImageCard";
export async function fetchQueueCovers(ids: number[]): Promise<CardImage[]> {
if (!Array.isArray(ids) || ids.length === 0) return [];
return listImagesByIds(ids);
}
+142
View File
@@ -0,0 +1,142 @@
"use server";
import { revalidatePath } from "next/cache";
import { setAppSetting, type AppSettings, type WhisperJavSettings, APP_SETTINGS_DEFAULTS } from "@/lib/db/appSettings";
export async function setBoolSetting(
key: "fadeTransitions" | "purgeFilesOnDelete" | "useRecycleBin",
value: boolean,
) {
setAppSetting(key, value);
revalidatePath("/");
}
export async function setTranscodeMode(value: "off" | "always" | "auto-predicate" | "auto-runtime") {
if (value !== "off" && value !== "always" && value !== "auto-predicate" && value !== "auto-runtime") return;
setAppSetting("transcodeMode", value);
revalidatePath("/");
}
export async function setNumberSetting(
key: "fadeDurationMs" | "trashRetentionDays" | "gridColumns" | "gridColumnsPortrait" | "supersededRetentionDays" | "coverPageSize",
value: number,
) {
if (!Number.isFinite(value)) return;
if (key === "gridColumns" && (value < 2 || value > 4)) return;
if (key === "gridColumnsPortrait" && (value < 4 || value > 10)) return;
if (key === "trashRetentionDays" && value < 0) return;
if (key === "supersededRetentionDays" && value < 0) return;
if (key === "coverPageSize" && (value < 25 || value > 500)) return;
setAppSetting(key, value);
revalidatePath("/");
}
const HEX_RE = /^#[0-9a-fA-F]{6}$/;
export async function setColorSetting(
key: "accentPrimary" | "accentSecondary",
value: string,
) {
const normalized = value === "" ? "" : value.toLowerCase();
if (normalized !== "" && !HEX_RE.test(normalized)) return;
setAppSetting(key, normalized);
revalidatePath("/");
}
export async function setPaginationMode(value: "url" | "scroll") {
if (value !== "url" && value !== "scroll") return;
setAppSetting("paginationMode", value);
revalidatePath("/");
}
export async function setSettingsLayout(value: "sidebar" | "three-column") {
if (value !== "sidebar" && value !== "three-column") return;
setAppSetting("settingsLayout", value);
revalidatePath("/");
}
export async function setVideoLibraryPath(value: string) {
setAppSetting("videoLibraryPath", value.trim());
revalidatePath("/");
}
export async function setPartSuffixPatterns(values: string[]) {
// Trim, drop blanks, preserve order. Validation of token grammar
// (e.g. `{N}`, `{L}`) happens client-side; storage accepts whatever
// the user typed so a malformed pattern doesn't silently disappear.
const cleaned = (values ?? []).map((v) => (v ?? "").trim()).filter(Boolean);
setAppSetting("partSuffixPatterns", cleaned);
// Reclassify on the next video scan; trigger a rescan so the change
// takes effect without a manual refresh.
try {
const { rescanVideoIndex } = await import("@/lib/video");
await rescanVideoIndex();
} catch (e) {
console.error("[settings] failed to rescan video index after pattern change:", e);
}
revalidatePath("/");
}
export async function setWhisperJavSettings(values: Partial<WhisperJavSettings>) {
const sanitized: WhisperJavSettings = {
...APP_SETTINGS_DEFAULTS.whisperjav,
...values,
cliPath: typeof values.cliPath === "string" ? values.cliPath.trim() : APP_SETTINGS_DEFAULTS.whisperjav.cliPath,
};
// Validate enum members so a bad client payload can't poison the row.
const QUALITIES: WhisperJavSettings["quality"][] = ["fast", "balanced", "qwen"];
const SOURCE_LANGS: WhisperJavSettings["sourceLanguage"][] = ["japanese", "korean", "chinese", "english"];
const OUTPUT_MODES: WhisperJavSettings["outputMode"][] = ["native", "direct-to-english"];
const SENSITIVITIES: WhisperJavSettings["sensitivity"][] = ["conservative", "balanced", "aggressive"];
const LOCATIONS: WhisperJavSettings["outputLocation"][] = ["beside-video", "data-folder"];
if (!QUALITIES.includes(sanitized.quality)) sanitized.quality = "balanced";
if (!SOURCE_LANGS.includes(sanitized.sourceLanguage)) sanitized.sourceLanguage = "japanese";
if (!OUTPUT_MODES.includes(sanitized.outputMode)) sanitized.outputMode = "native";
if (!SENSITIVITIES.includes(sanitized.sensitivity)) sanitized.sensitivity = "balanced";
if (!LOCATIONS.includes(sanitized.outputLocation)) sanitized.outputLocation = "beside-video";
sanitized.noSignature = sanitized.noSignature !== false;
const retention = Number(sanitized.retentionDays);
sanitized.retentionDays = Number.isFinite(retention) && retention >= 0 ? Math.floor(retention) : 30;
setAppSetting("whisperjav", sanitized);
revalidatePath("/");
}
export async function setSubtitleCacheLimitMb(value: number) {
if (!Number.isFinite(value) || value < 0) return;
setAppSetting("subtitleCacheLimitMb", Math.floor(value));
revalidatePath("/");
}
export async function setSubtitleExtraPaths(values: string[]) {
const seen = new Set<string>();
const cleaned: string[] = [];
for (const v of values) {
const t = (v ?? "").trim();
if (!t) continue;
const key = t.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
cleaned.push(t);
}
setAppSetting("subtitleExtraPaths", cleaned);
revalidatePath("/");
}
export async function setVideoExtraPaths(values: string[]) {
// Trim, drop blanks, dedupe (case-insensitive on Windows-friendly compare).
const seen = new Set<string>();
const cleaned: string[] = [];
for (const v of values) {
const t = (v ?? "").trim();
if (!t) continue;
const key = t.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
cleaned.push(t);
}
setAppSetting("videoExtraPaths", cleaned);
revalidatePath("/");
}
export type WritableBoolKey = Parameters<typeof setBoolSetting>[0];
export type WritableNumberKey = Parameters<typeof setNumberSetting>[0];
export type WritableColorKey = Parameters<typeof setColorSetting>[0];
export type WritableSettings = Pick<AppSettings, WritableBoolKey | WritableNumberKey | WritableColorKey>;
+22
View File
@@ -0,0 +1,22 @@
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { setAppSetting } from "@/lib/db/appSettings";
import { isValidSort, SORT_COOKIE } from "@/lib/sort";
const ONE_YEAR = 60 * 60 * 24 * 365;
export async function setDefaultSort(sort: string) {
if (!isValidSort(sort)) return;
setAppSetting("defaultSort", sort);
(await cookies()).set(SORT_COOKIE, sort, {
path: "/",
maxAge: ONE_YEAR,
sameSite: "lax",
});
revalidatePath("/");
revalidatePath("/collection");
revalidatePath("/actress");
revalidatePath("/studios");
revalidatePath("/series");
}
+91
View File
@@ -0,0 +1,91 @@
"use server";
import { revalidatePath } from "next/cache";
import { rawDb, uniqueSlug } from "@/lib/db/client";
export interface TagCategoryRow {
id: number;
name: string;
slug: string;
color: string | null;
description: string | null;
}
const PALETTE = ["#fbbf24", "#22d3ee", "#a78bfa", "#f472b6", "#34d399", "#fb7185", "#f97316", "#60a5fa"];
function nextPaletteColor(): string {
const taken = (rawDb.prepare(`SELECT color FROM tag_categories WHERE color IS NOT NULL`).all() as Array<{ color: string }>)
.map((r) => r.color);
for (const c of PALETTE) if (!taken.includes(c)) return c;
return PALETTE[Math.floor(Math.random() * PALETTE.length)];
}
export async function createTagCategory(name: string, color?: string, description?: string): Promise<TagCategoryRow | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const slug = uniqueSlug(rawDb, "tag_categories", trimmed);
const finalColor = color?.trim() || nextPaletteColor();
const finalDesc = description?.trim() || null;
const row = rawDb.prepare(`
INSERT INTO tag_categories (name, slug, color, description) VALUES (?, ?, ?, ?) RETURNING *
`).get(trimmed, slug, finalColor, finalDesc) as TagCategoryRow;
revalidatePath("/category");
revalidatePath("/tag");
return row;
}
export async function createTagCategoryAction(formData: FormData) {
const name = String(formData.get("name") ?? "");
const color = String(formData.get("color") ?? "");
const description = String(formData.get("description") ?? "");
await createTagCategory(name, color || undefined, description || undefined);
}
export async function renameTagCategory(id: number, name: string, color?: string, description?: string): Promise<{ slug: string; name: string } | null> {
const trimmed = name.trim();
if (!trimmed) return null;
const current = rawDb.prepare(`SELECT name FROM tag_categories WHERE id = ?`).get(id) as { name: string } | undefined;
if (!current) return null;
const slug = uniqueSlug(rawDb, "tag_categories", trimmed, id);
// COALESCE both color AND description: passing `undefined` (caller
// omitted the field) preserves the existing value; passing an empty
// string clears it. Without COALESCE on description, the prior code
// wiped any existing description on every rename.
const colorArg = color === undefined ? null : (color.trim() || null);
const descArg = description === undefined ? null : (description.trim() || null);
if (color === undefined && description === undefined) {
rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ? WHERE id = ?`).run(trimmed, slug, id);
} else if (description === undefined) {
rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ?, color = COALESCE(?, color) WHERE id = ?`)
.run(trimmed, slug, colorArg, id);
} else if (color === undefined) {
rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ?, description = ? WHERE id = ?`)
.run(trimmed, slug, descArg, id);
} else {
rawDb.prepare(`UPDATE tag_categories SET name = ?, slug = ?, color = COALESCE(?, color), description = ? WHERE id = ?`)
.run(trimmed, slug, colorArg, descArg, id);
}
revalidatePath("/category");
revalidatePath("/tag");
return { slug, name: trimmed };
}
export async function deleteTagCategory(id: number) {
// ON DELETE SET NULL on tags.category_id keeps every tag intact; they
// simply become uncategorised again.
rawDb.prepare(`DELETE FROM tag_categories WHERE id = ?`).run(id);
revalidatePath("/category");
revalidatePath("/tag");
}
export async function setTagCategory(tagId: number, categoryId: number | null) {
rawDb.prepare(`UPDATE tags SET category_id = ? WHERE id = ?`).run(categoryId, tagId);
revalidatePath("/category");
revalidatePath("/tag");
}
export async function setTagCategoryByName(tagName: string, categoryId: number | null) {
const tag = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(tagName.trim()) as { id: number } | undefined;
if (!tag) return;
await setTagCategory(tag.id, categoryId);
revalidatePath(`/tag/${encodeURIComponent(tagName.trim())}`);
}
+149
View File
@@ -0,0 +1,149 @@
"use server";
import { revalidatePath } from "next/cache";
import { rawDb, uniqueSlug } from "@/lib/db/client";
import { redirect } from "next/navigation";
export async function createTag(name: string) {
const trimmed = name.trim().toLowerCase();
if (!trimmed) return null;
const row = rawDb.prepare(`
INSERT INTO tags (name) VALUES (?)
ON CONFLICT(name) DO UPDATE SET name=excluded.name
RETURNING id
`).get(trimmed) as { id: number };
revalidatePath("/tag");
return row.id;
}
export async function createTagAction(formData: FormData) {
const name = String(formData.get("name") ?? "");
await createTag(name);
redirect("/tag");
}
export async function addTagToImage(imageId: number, name: string) {
const trimmed = name.trim().toLowerCase();
if (!trimmed) return;
const tag = rawDb.prepare(`
INSERT INTO tags (name) VALUES (?) ON CONFLICT(name) DO UPDATE SET name=excluded.name RETURNING id
`).get(trimmed) as { id: number };
rawDb.prepare(`INSERT OR IGNORE INTO image_tags (image_id, tag_id) VALUES (?, ?)`).run(imageId, tag.id);
// Bump recency so this tag floats up in the context-menu Recent strip.
rawDb.prepare(`UPDATE tags SET last_used_at = (unixepoch() * 1000) WHERE id = ?`).run(tag.id);
revalidatePath(`/image/${imageId}`);
revalidatePath("/tag");
}
export type BulkImportRow = {
name: string;
category?: string | null;
color?: string | null;
};
export type BulkImportResult = {
ok: boolean;
added: number;
updated: number;
skipped: number;
categoriesCreated: number;
errors: Array<{ row: number; message: string }>;
};
const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
export async function bulkImportTags(
rows: BulkImportRow[],
opts: { createMissingCategories: boolean; updateExisting: boolean },
): Promise<BulkImportResult> {
const errors: Array<{ row: number; message: string }> = [];
let added = 0;
let updated = 0;
let skipped = 0;
let categoriesCreated = 0;
// Cache category id lookups so we don't requery for every row that shares
// the same category.
const catCache = new Map<string, number>();
function resolveCategoryId(catName: string | null | undefined): number | null {
if (!catName) return null;
const key = catName.trim().toLowerCase();
if (!key) return null;
const cached = catCache.get(key);
if (cached !== undefined) return cached;
const existing = rawDb.prepare(`SELECT id FROM tag_categories WHERE LOWER(name) = ?`).get(key) as { id: number } | undefined;
if (existing) {
catCache.set(key, existing.id);
return existing.id;
}
if (!opts.createMissingCategories) return null;
const trimmedName = catName.trim();
const slug = uniqueSlug(rawDb, "tag_categories", trimmedName);
const ins = rawDb.prepare(`INSERT INTO tag_categories (name, slug) VALUES (?, ?) RETURNING id`).get(trimmedName, slug) as { id: number };
categoriesCreated++;
catCache.set(key, ins.id);
return ins.id;
}
const tx = rawDb.transaction(() => {
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
const name = (r.name ?? "").trim().toLowerCase();
if (!name) {
errors.push({ row: i + 1, message: "blank name" });
continue;
}
if (name.length > 48) {
errors.push({ row: i + 1, message: `name too long (${name.length} > 48)` });
continue;
}
const color = r.color?.trim() || null;
if (color && !COLOR_RE.test(color)) {
errors.push({ row: i + 1, message: `invalid color "${color}" — expected #rrggbb` });
continue;
}
const categoryId = resolveCategoryId(r.category);
const existing = rawDb.prepare(`SELECT id FROM tags WHERE name = ?`).get(name) as { id: number } | undefined;
if (existing) {
if (opts.updateExisting && (color || categoryId !== null)) {
rawDb.prepare(`UPDATE tags SET color = COALESCE(?, color), category_id = COALESCE(?, category_id) WHERE id = ?`)
.run(color, categoryId, existing.id);
updated++;
} else {
skipped++;
}
continue;
}
rawDb.prepare(`INSERT INTO tags (name, color, category_id) VALUES (?, ?, ?)`).run(name, color, categoryId);
added++;
}
});
try {
tx();
} catch (e) {
return { ok: false, added: 0, updated: 0, skipped: 0, categoriesCreated: 0, errors: [{ row: 0, message: (e as Error).message }] };
}
revalidatePath("/tag");
revalidatePath("/category");
return { ok: true, added, updated, skipped, categoriesCreated, errors };
}
export async function bulkDeleteTags(ids: number[]): Promise<{ deleted: number }> {
if (!ids || ids.length === 0) return { deleted: 0 };
const placeholders = ids.map(() => "?").join(",");
// image_tags has ON DELETE CASCADE on tag_id, so removing tag rows
// also drops every image association.
const info = rawDb.prepare(`DELETE FROM tags WHERE id IN (${placeholders})`).run(...ids);
revalidatePath("/tag");
revalidatePath("/category");
return { deleted: info.changes };
}
export async function removeTagFromImage(imageId: number, tagId: number) {
rawDb.prepare(`DELETE FROM image_tags WHERE image_id = ? AND tag_id = ?`).run(imageId, tagId);
revalidatePath(`/image/${imageId}`);
revalidatePath("/tag");
}
+64
View File
@@ -0,0 +1,64 @@
"use server";
import { revalidatePath } from "next/cache";
import path from "node:path";
import fs from "node:fs/promises";
import { rawDb } from "@/lib/db/client";
import { getAppSetting } from "@/lib/db/appSettings";
import { safeJoin } from "@/lib/safePath";
const LIBRARY_ROOT = path.join(process.cwd(), "library");
const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs");
export async function restoreImages(ids: number[]): Promise<{ restored: number }> {
if (ids.length === 0) return { restored: 0 };
const placeholders = ids.map(() => "?").join(",");
const r = rawDb.prepare(
`UPDATE images SET deleted_at = NULL WHERE id IN (${placeholders}) AND deleted_at IS NOT NULL`,
).run(...ids);
revalidate();
return { restored: r.changes };
}
export async function purgeFromTrash(ids: number[]): Promise<{ purged: number }> {
if (ids.length === 0) return { purged: 0 };
const placeholders = ids.map(() => "?").join(",");
const rows = rawDb.prepare(
`
WITH targets AS (
SELECT id FROM images WHERE deleted_at IS NOT NULL AND id IN (${placeholders})
)
SELECT id, rel_path, thumb_path FROM images
WHERE id IN (SELECT id FROM targets)
OR parent_image_id IN (SELECT id FROM targets)
`,
).all(...ids) as Array<{ id: number; rel_path: string; thumb_path: string }>;
if (rows.length === 0) return { purged: 0 };
if (getAppSetting("purgeFilesOnDelete")) {
await Promise.all(rows.flatMap((r) => {
const fileAbs = safeJoin(LIBRARY_ROOT, r.rel_path);
const thumbAbs = safeJoin(THUMB_ROOT, r.thumb_path);
return [
fileAbs ? fs.rm(fileAbs, { force: true }) : null,
thumbAbs ? fs.rm(thumbAbs, { force: true }) : null,
].filter((p): p is Promise<void> => !!p);
}));
}
rawDb.prepare(`DELETE FROM images WHERE id IN (${rows.map(() => "?").join(",")})`).run(...rows.map((r) => r.id));
revalidate();
return { purged: rows.length };
}
export async function emptyTrash(): Promise<{ purged: number }> {
const ids = (rawDb.prepare(`SELECT id FROM images WHERE deleted_at IS NOT NULL`).all() as Array<{ id: number }>).map((r) => r.id);
return purgeFromTrash(ids);
}
function revalidate() {
revalidatePath("/");
revalidatePath("/collection");
revalidatePath("/tag");
revalidatePath("/actress");
revalidatePath("/studios");
revalidatePath("/series");
revalidatePath("/genres");
}
+87
View File
@@ -0,0 +1,87 @@
import { notFound } from "next/navigation";
import { getActressBySlug, listImages, listActressCategories, libraryLetterCounts, listCoStars } from "@/lib/db/queries";
import { MasonryGrid } from "@/components/grid/MasonryGrid";
import { RegisterVisible } from "@/components/select/RegisterVisible";
import { FilterBar } from "@/components/grid/FilterBar";
import { LetterBar } from "@/components/grid/LetterBar";
import { resolveSort } from "@/lib/sortServer";
import { ActressHero } from "@/components/actress/ActressHero";
import { CoStarsRow } from "@/components/actress/CoStarsRow";
import { parseFilterCriteria, statusToFlags } from "@/lib/filters";
export const dynamic = "force-dynamic";
export default async function ActressPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
const sp = await searchParams;
const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined);
const rawLetter = (typeof sp.letter === "string" ? sp.letter : "").toUpperCase();
const letter = rawLetter === "#" ? "#" : (/^[A-Z]$/.test(rawLetter) ? rawLetter : null);
const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined;
const criteria = parseFilterCriteria(sp);
const a = getActressBySlug(decodeURIComponent(slug));
if (!a) notFound();
const items = listImages({
actressId: a.id,
sort,
letter: letter ?? undefined,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
});
const allCategories = listActressCategories();
const costars = listCoStars(a.id, 24);
const letterCounts = libraryLetterCounts({
actressId: a.id,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
});
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<ActressHero actress={a} coverCount={items.length} allCategories={allCategories} />
<CoStarsRow actressName={a.name} costars={costars} />
<FilterBar current={{ kind: "actress", name: a.name }} criteria={criteria} sort={sort} />
<div className="my-6">
<LetterBar active={letter} counts={letterCounts} />
</div>
<RegisterVisible ids={items.map((i) => i.id)} />
<div key={letter ?? "all"} className="fade-in">
<MasonryGrid images={items} />
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { listAllActresses, listActressCategories } from "@/lib/db/queries";
import { ActressDirectory } from "@/components/actress/ActressDirectory";
import { ActressCreateBar } from "@/components/actress/ActressCreateBar";
import { Users } from "lucide-react";
export const dynamic = "force-dynamic";
export default function ActressesPage() {
const items = listAllActresses();
const categories = listActressCategories();
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Cast</h1>
<p className="text-[var(--color-fg-dim)] mt-1">{items.length} total</p>
</div>
<ActressCreateBar />
</div>
{items.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<Users className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">No actresses yet. Create one above or add from any cover.</p>
</div>
) : (
<ActressDirectory items={items} categories={categories} />
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
import { revalidatePath } from "next/cache";
import { rawDb } from "@/lib/db/client";
import { safeJoin } from "@/lib/safePath";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const PORTRAIT_ROOT = path.join(process.cwd(), "data", "portraits");
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const SLOT_COLS: Record<string, { path: string; zoom: string; ox: string; oy: string }> = {
"1": { path: "portrait_path", zoom: "portrait_zoom", ox: "portrait_offset_x", oy: "portrait_offset_y" },
"2": { path: "portrait2_path", zoom: "portrait2_zoom", ox: "portrait2_offset_x", oy: "portrait2_offset_y" },
"3": { path: "portrait3_path", zoom: "portrait3_zoom", ox: "portrait3_offset_x", oy: "portrait3_offset_y" },
"4": { path: "portrait4_path", zoom: "portrait4_zoom", ox: "portrait4_offset_x", oy: "portrait4_offset_y" },
"h": { path: "portraith_path", zoom: "portraith_zoom", ox: "portraith_offset_x", oy: "portraith_offset_y" },
};
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { id } = await ctx.params;
const numId = Number(id);
if (!Number.isFinite(numId)) return NextResponse.json({ error: "bad id" }, { status: 400 });
const url = new URL(req.url);
const slot = (url.searchParams.get("slot") ?? "1") as keyof typeof SLOT_COLS;
const cols = SLOT_COLS[slot];
if (!cols) return NextResponse.json({ error: "bad slot" }, { status: 400 });
const actress = rawDb.prepare(`SELECT id, slug, ${cols.path} AS prevPath FROM actresses WHERE id = ?`).get(numId) as
| { id: number; slug: string; prevPath: string | null }
| undefined;
if (!actress) return NextResponse.json({ error: "actress not found" }, { status: 404 });
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) return NextResponse.json({ error: "missing file" }, { status: 400 });
const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) return NextResponse.json({ error: "unsupported format" }, { status: 415 });
const buf = Buffer.from(await file.arrayBuffer());
const sha = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16);
const filename = `${actress.id}-${slot}-${sha}${ext}`;
await fs.mkdir(PORTRAIT_ROOT, { recursive: true });
await fs.writeFile(path.join(PORTRAIT_ROOT, filename), buf);
if (actress.prevPath && actress.prevPath !== filename) {
const prevAbs = safeJoin(PORTRAIT_ROOT, actress.prevPath);
if (prevAbs) await fs.rm(prevAbs, { force: true }).catch(() => {});
}
rawDb.prepare(`
UPDATE actresses
SET ${cols.path} = ?, ${cols.zoom} = 1, ${cols.ox} = 0, ${cols.oy} = 0
WHERE id = ?
`).run(filename, actress.id);
revalidatePath("/actress");
revalidatePath(`/actress/${actress.slug}`);
return NextResponse.json({ portraitPath: filename });
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { rawDb } from "@/lib/db/client";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const TABLES = [
"images",
"studios",
"labels",
"series",
"actresses",
"genres",
"tag_categories",
"tags",
"collections",
"image_actresses",
"image_genres",
"image_tags",
"collection_images",
"actress_categories",
"actress_categories_map",
"app_settings",
];
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const data: Record<string, unknown[]> = {};
for (const t of TABLES) {
try {
data[t] = rawDb.prepare(`SELECT * FROM ${t}`).all();
} catch {
data[t] = [];
}
}
const payload = {
app: "Pinkudex",
version: 1,
exportedAt: new Date().toISOString(),
tables: data,
};
const json = JSON.stringify(payload, null, 2);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
return new NextResponse(json, {
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Disposition": `attachment; filename="pinkudex-backup-${stamp}.json"`,
"Cache-Control": "no-store",
},
});
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { importDatabaseTables } from "@/lib/backup/importDb";
// Strip absolute-path noise — only the basename is useful to the client
// and absolute paths leak filesystem layout to anything that pings the
// API on the local network.
const baseOnly = (p: string | null | undefined): string | null =>
p ? path.basename(p) : null;
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
let payload: { tables?: Record<string, unknown[]>; version?: number; app?: string };
try {
payload = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const tables = payload.tables;
if (!tables || typeof tables !== "object") {
return NextResponse.json({ error: "Missing 'tables' object" }, { status: 400 });
}
if (!Array.isArray(tables.actresses) && !Array.isArray(tables.images)) {
return NextResponse.json({ error: "Backup payload is missing core tables." }, { status: 400 });
}
const result = await importDatabaseTables(tables);
const snapshotName = baseOnly(result.snapshotPath);
if (!result.ok) {
return NextResponse.json(
{
error: result.error,
snapshotName,
hint: snapshotName
? `Live DB rolled back. A pre-import snapshot was saved as ${snapshotName}.`
: undefined,
},
{ status: 500 },
);
}
return NextResponse.json({
ok: true,
counts: result.counts,
errors: result.errors,
snapshotName,
});
}
+133
View File
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import yazl from "yazl";
import { rawDb } from "@/lib/db/client";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 3600;
const ROOT = process.cwd();
const SOURCES: Array<{ absDir: string; zipPrefix: string; skipRel?: (rel: string) => boolean }> = [
{
absDir: path.join(ROOT, "library"),
zipPrefix: "library",
skipRel: (rel) => rel === ".superseded" || rel.startsWith(".superseded/"),
},
{ absDir: path.join(ROOT, "data", "thumbs"), zipPrefix: "data/thumbs" },
{ absDir: path.join(ROOT, "data", "portraits"), zipPrefix: "data/portraits" },
{ absDir: path.join(ROOT, "data", "category-covers"), zipPrefix: "data/category-covers" },
{ absDir: path.join(ROOT, "data", "collection-covers"), zipPrefix: "data/collection-covers" },
];
const DB_TABLES = [
"images", "studios", "labels", "series", "actresses", "genres",
"tag_categories", "tags", "collections", "image_actresses", "image_genres",
"image_tags", "collection_images", "actress_categories",
"actress_categories_map", "app_settings",
];
type Entry = { abs: string; rel: string; size: number; mtime: Date };
async function walk(absDir: string, zipPrefix: string, skipRel?: (rel: string) => boolean): Promise<Entry[]> {
const out: Entry[] = [];
const stack: Array<{ abs: string; rel: string }> = [{ abs: absDir, rel: "" }];
while (stack.length) {
const { abs, rel } = stack.pop()!;
let dirents: fs.Dirent[];
try {
dirents = await fsp.readdir(abs, { withFileTypes: true });
} catch (e) {
if ((e as NodeJS.ErrnoException).code === "ENOENT") continue;
throw e;
}
for (const d of dirents) {
const childRel = rel ? `${rel}/${d.name}` : d.name;
if (skipRel?.(childRel)) continue;
const childAbs = path.join(abs, d.name);
if (d.isDirectory()) {
stack.push({ abs: childAbs, rel: childRel });
} else if (d.isFile()) {
const st = await fsp.stat(childAbs);
out.push({ abs: childAbs, rel: `${zipPrefix}/${childRel}`, size: st.size, mtime: st.mtime });
}
}
}
return out;
}
function buildDatabaseJson(): Buffer {
const data: Record<string, unknown[]> = {};
for (const t of DB_TABLES) {
try { data[t] = rawDb.prepare(`SELECT * FROM ${t}`).all(); }
catch { data[t] = []; }
}
const payload = {
app: "Pinkudex",
version: 1,
exportedAt: new Date().toISOString(),
tables: data,
};
return Buffer.from(JSON.stringify(payload, null, 2), "utf8");
}
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
let entries: Entry[] = [];
for (const src of SOURCES) {
entries = entries.concat(await walk(src.absDir, src.zipPrefix, src.skipRel));
}
// Materialise the zip to a temp file first. Streaming yazl's outputStream
// straight through NextResponse hung mid-download (turbopack streaming /
// adapter chunking weirdness). A temp file guarantees: exact Content-Length
// from fs.stat, full backpressure handling by Node's createReadStream, and
// resumability if the browser retries. Costs one extra pass over disk —
// acceptable for an explicit user-triggered backup.
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pinkudex-export-"));
const tmpZip = path.join(tmpDir, "library.zip");
const zip = new yazl.ZipFile();
zip.addBuffer(buildDatabaseJson(), "database.json", { compress: false });
for (const e of entries) {
zip.addReadStream(fs.createReadStream(e.abs), e.rel, {
compress: false,
size: e.size,
mtime: e.mtime,
});
}
zip.end();
await pipeline(
zip.outputStream as unknown as Readable,
fs.createWriteStream(tmpZip),
);
const { size } = await fsp.stat(tmpZip);
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const fileStream = fs.createReadStream(tmpZip);
// Best-effort cleanup once the stream is fully consumed (success or abort).
fileStream.on("close", () => {
fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
const webStream = Readable.toWeb(fileStream) as unknown as ReadableStream<Uint8Array>;
return new NextResponse(webStream, {
headers: {
"Content-Type": "application/zip",
"Content-Length": String(size),
"Content-Disposition": `attachment; filename="pinkudex-library-${stamp}.zip"`,
"Cache-Control": "no-store",
},
});
}
+257
View File
@@ -0,0 +1,257 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import yauzl from "yauzl";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { importDatabaseTables, restoreDatabaseSnapshot } from "@/lib/backup/importDb";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 3600;
const ROOT = process.cwd();
// Folders the export route ships and that we expect to restore. zipPrefix is
// the path inside the archive; absDir is where it lives on disk.
const TARGETS: Array<{ zipPrefix: string; absDir: string }> = [
{ zipPrefix: "library", absDir: path.join(ROOT, "library") },
{ zipPrefix: "data/thumbs", absDir: path.join(ROOT, "data", "thumbs") },
{ zipPrefix: "data/portraits", absDir: path.join(ROOT, "data", "portraits") },
{ zipPrefix: "data/category-covers", absDir: path.join(ROOT, "data", "category-covers") },
{ zipPrefix: "data/collection-covers", absDir: path.join(ROOT, "data", "collection-covers") },
];
function openZip(zipPath: string): Promise<yauzl.ZipFile> {
return new Promise((resolve, reject) => {
yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (err, zf) => {
if (err || !zf) return reject(err ?? new Error("Failed to open zip"));
resolve(zf);
});
});
}
function readEntryStream(zf: yauzl.ZipFile, entry: yauzl.Entry): Promise<Readable> {
return new Promise((resolve, reject) => {
zf.openReadStream(entry, (err, stream) => {
if (err || !stream) return reject(err ?? new Error("Failed to open entry stream"));
resolve(stream);
});
});
}
// Reject zip-slip / path-traversal entries: the resolved destination must
// stay strictly inside the staging root. Without this, a crafted entry
// named "../../etc/passwd" would write outside the staging folder.
function safeJoin(root: string, rel: string): string | null {
const resolved = path.resolve(root, rel);
const rel2 = path.relative(root, resolved);
if (rel2.startsWith("..") || path.isAbsolute(rel2)) return null;
return resolved;
}
async function extractAll(zipPath: string, stagingRoot: string): Promise<void> {
const zf = await openZip(zipPath);
try {
await new Promise<void>((resolve, reject) => {
zf.on("error", reject);
zf.on("end", resolve);
zf.on("entry", async (entry: yauzl.Entry) => {
try {
const isDir = /\/$/.test(entry.fileName);
const dest = safeJoin(stagingRoot, entry.fileName);
if (!dest) {
// Skip suspicious entries silently and continue.
zf.readEntry();
return;
}
if (isDir) {
await fsp.mkdir(dest, { recursive: true });
zf.readEntry();
return;
}
await fsp.mkdir(path.dirname(dest), { recursive: true });
const rs = await readEntryStream(zf, entry);
await pipeline(rs, fs.createWriteStream(dest));
zf.readEntry();
} catch (e) {
reject(e);
}
});
zf.readEntry();
});
} finally {
zf.close();
}
}
async function rollbackMediaSwap(
renamedBackups: Array<{ from: string; to: string }>,
movedTargets: string[],
ts: string,
): Promise<string[]> {
const errors: string[] = [];
for (const tgt of [...TARGETS].reverse()) {
const backup = renamedBackups.find((r) => r.from === tgt.absDir);
const wasMoved = movedTargets.includes(tgt.zipPrefix);
if (!backup && !wasMoved) continue;
if (wasMoved) {
try {
await fsp.access(tgt.absDir);
await fsp.rename(tgt.absDir, `${tgt.absDir}.failed-restore-${ts}`);
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
errors.push(`Could not move failed ${tgt.zipPrefix}: ${(e as Error).message}`);
}
}
}
if (backup) {
try {
await fsp.rename(backup.to, backup.from);
} catch (e) {
errors.push(`Could not restore backup ${backup.to}: ${(e as Error).message}`);
}
}
}
return errors;
}
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
if (!req.body) {
return NextResponse.json({ error: "Missing request body" }, { status: 400 });
}
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pinkudex-import-"));
const tmpZip = path.join(tmpDir, "upload.zip");
const staging = path.join(tmpDir, "staging");
try {
// 1) Stream upload to disk. Buffering a multi-GB upload in memory is a
// non-starter; piping the request body straight to a file is constant-RAM.
await pipeline(
Readable.fromWeb(req.body as unknown as import("node:stream/web").ReadableStream),
fs.createWriteStream(tmpZip),
);
// 2) Extract everything to staging. If anything throws here we abort
// before touching live state.
await fsp.mkdir(staging, { recursive: true });
await extractAll(tmpZip, staging);
// 3) Read database.json and validate.
const dbJsonPath = path.join(staging, "database.json");
let dbJsonRaw: string;
try {
dbJsonRaw = await fsp.readFile(dbJsonPath, "utf8");
} catch {
return NextResponse.json(
{ error: "Archive is missing database.json — not a Pinkudex library export." },
{ status: 400 },
);
}
let parsed: { tables?: Record<string, unknown[]> };
try {
parsed = JSON.parse(dbJsonRaw);
} catch {
return NextResponse.json({ error: "database.json is not valid JSON." }, { status: 400 });
}
const tables = parsed.tables;
if (!tables || typeof tables !== "object") {
return NextResponse.json({ error: "database.json missing 'tables' object." }, { status: 400 });
}
if (!Array.isArray(tables.actresses) && !Array.isArray(tables.images)) {
return NextResponse.json(
{ error: "database.json is missing core tables." },
{ status: 400 },
);
}
// 4) Import DB. On failure the helper restores a pre-import .bak snapshot
// and we abort BEFORE swapping any folders so live media is untouched.
const dbResult = await importDatabaseTables(tables);
if (!dbResult.ok) {
return NextResponse.json(
{
error: `Database import failed: ${dbResult.error}`,
snapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null,
hint: "Media folders were not modified. The DB was rolled back from the pre-import snapshot.",
},
{ status: 500 },
);
}
// 5) Swap media folders. Existing folders renamed to *.pre-restore-<ts>
// for manual rollback. New folders moved out of staging into place.
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const renamedBackups: Array<{ from: string; to: string }> = [];
const movedTargets: string[] = [];
try {
for (const tgt of TARGETS) {
const stagedSrc = path.join(staging, tgt.zipPrefix);
let stagedExists = false;
try {
const st = await fsp.stat(stagedSrc);
stagedExists = st.isDirectory();
} catch {}
if (!stagedExists) continue;
// Rename existing folder if present.
try {
await fsp.access(tgt.absDir);
const backupPath = `${tgt.absDir}.pre-restore-${ts}`;
await fsp.rename(tgt.absDir, backupPath);
renamedBackups.push({ from: tgt.absDir, to: backupPath });
} catch {
// Didn't exist — fine.
}
await fsp.mkdir(path.dirname(tgt.absDir), { recursive: true });
await fsp.rename(stagedSrc, tgt.absDir);
movedTargets.push(tgt.zipPrefix);
}
} catch (e) {
const rollbackErrors = await rollbackMediaSwap(renamedBackups, movedTargets, ts);
if (dbResult.snapshotPath) {
try {
await restoreDatabaseSnapshot(dbResult.snapshotPath);
} catch (restoreErr) {
rollbackErrors.push(`Could not restore DB snapshot: ${(restoreErr as Error).message}`);
}
}
return NextResponse.json(
{
error: `Library restore failed during media swap: ${(e as Error).message}`,
dbSnapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null,
mediaBackupNames: renamedBackups.map((r) => path.basename(r.to)),
rollbackErrors,
},
{ status: 500 },
);
}
return NextResponse.json({
ok: true,
counts: dbResult.counts,
errors: dbResult.errors,
dbSnapshotName: dbResult.snapshotPath ? path.basename(dbResult.snapshotPath) : null,
mediaRestored: movedTargets,
mediaBackupNames: renamedBackups.map((r) => path.basename(r.to)),
});
} catch (e) {
return NextResponse.json(
{ error: `Library restore failed: ${(e as Error).message}` },
{ status: 500 },
);
} finally {
fsp.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
@@ -0,0 +1,50 @@
import { NextRequest } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const COVER_ROOT = path.join(process.cwd(), "data", "category-covers");
const IMAGE_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
};
export async function GET(_req: NextRequest, ctx: { params: Promise<{ file: string }> }) {
const { file } = await ctx.params;
const name = decodeURIComponent(file);
// Stored filenames are always `${id}-${slot}-${sha16}.${ext}`. Reject
// anything containing path separators or traversal segments before
// touching the disk.
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
return new Response("not found", { status: 404 });
}
const abs = path.join(COVER_ROOT, name);
try {
const buf = await fs.readFile(abs);
const ext = path.extname(abs).toLowerCase();
const etag = `"${crypto.createHash("sha256").update(buf).digest("hex")}"`;
return new Response(new Uint8Array(buf), {
headers: {
"Content-Type": IMAGE_MIME[ext] ?? "application/octet-stream",
"Content-Disposition": rfc5987Disposition(name),
"Cache-Control": "public, max-age=0, must-revalidate",
ETag: etag,
},
});
} catch {
return new Response("not found", { status: 404 });
}
}
function rfc5987Disposition(filename: string): string {
// ASCII-safe fallback: strip non-ASCII + escape quotes/backslashes for
// the legacy `filename=` token. UTF-8 path uses RFC 5987 percent-encoding.
const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
const utf8 = encodeURIComponent(filename);
return `inline; filename="${ascii}"; filename*=UTF-8''${utf8}`;
}
+67
View File
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
import { revalidatePath } from "next/cache";
import { rawDb } from "@/lib/db/client";
import { safeJoin } from "@/lib/safePath";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const COVER_ROOT = path.join(process.cwd(), "data", "category-covers");
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const SLOT_COLS: Record<string, { path: string; zoom: string; ox: string; oy: string }> = {
portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" },
landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" },
};
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { id } = await ctx.params;
const numId = Number(id);
if (!Number.isFinite(numId)) return NextResponse.json({ error: "bad id" }, { status: 400 });
const url = new URL(req.url);
const slot = (url.searchParams.get("slot") ?? "portrait") as keyof typeof SLOT_COLS;
const cols = SLOT_COLS[slot];
if (!cols) return NextResponse.json({ error: "bad slot" }, { status: 400 });
const cat = rawDb.prepare(`SELECT id, slug, ${cols.path} AS prevPath FROM tag_categories WHERE id = ?`).get(numId) as
| { id: number; slug: string; prevPath: string | null }
| undefined;
if (!cat) return NextResponse.json({ error: "category not found" }, { status: 404 });
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) return NextResponse.json({ error: "missing file" }, { status: 400 });
const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) return NextResponse.json({ error: "unsupported format" }, { status: 415 });
const buf = Buffer.from(await file.arrayBuffer());
const sha = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16);
const filename = `${cat.id}-${slot}-${sha}${ext}`;
await fs.mkdir(COVER_ROOT, { recursive: true });
await fs.writeFile(path.join(COVER_ROOT, filename), buf);
// Replace any previous file in this slot, unless the bytes happened to
// hash to the same name (in which case we just kept it).
if (cat.prevPath && cat.prevPath !== filename) {
const prevAbs = safeJoin(COVER_ROOT, cat.prevPath);
if (prevAbs) await fs.rm(prevAbs, { force: true }).catch(() => {});
}
rawDb.prepare(`
UPDATE tag_categories
SET ${cols.path} = ?, ${cols.zoom} = 1, ${cols.ox} = 0, ${cols.oy} = 0
WHERE id = ?
`).run(filename, cat.id);
revalidatePath("/category");
revalidatePath(`/category/${cat.slug}`);
return NextResponse.json({ coverPath: filename });
}
@@ -0,0 +1,45 @@
import { NextRequest } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const COVER_ROOT = path.join(process.cwd(), "data", "collection-covers");
const IMAGE_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
};
export async function GET(_req: NextRequest, ctx: { params: Promise<{ file: string }> }) {
const { file } = await ctx.params;
const name = decodeURIComponent(file);
if (name.includes("/") || name.includes("\\") || name.includes("..")) {
return new Response("not found", { status: 404 });
}
const abs = path.join(COVER_ROOT, name);
try {
const buf = await fs.readFile(abs);
const ext = path.extname(abs).toLowerCase();
const etag = `"${crypto.createHash("sha256").update(buf).digest("hex")}"`;
return new Response(new Uint8Array(buf), {
headers: {
"Content-Type": IMAGE_MIME[ext] ?? "application/octet-stream",
"Content-Disposition": rfc5987Disposition(name),
"Cache-Control": "public, max-age=0, must-revalidate",
ETag: etag,
},
});
} catch {
return new Response("not found", { status: 404 });
}
}
function rfc5987Disposition(filename: string): string {
const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
const utf8 = encodeURIComponent(filename);
return `inline; filename="${ascii}"; filename*=UTF-8''${utf8}`;
}
+65
View File
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import crypto from "node:crypto";
import { revalidatePath } from "next/cache";
import { rawDb } from "@/lib/db/client";
import { safeJoin } from "@/lib/safePath";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const COVER_ROOT = path.join(process.cwd(), "data", "collection-covers");
const ALLOWED_EXT = new Set([".jpg", ".jpeg", ".png", ".webp"]);
const SLOT_COLS: Record<string, { path: string; zoom: string; ox: string; oy: string }> = {
portrait: { path: "cover_portrait_path", zoom: "cover_portrait_zoom", ox: "cover_portrait_offset_x", oy: "cover_portrait_offset_y" },
landscape: { path: "cover_landscape_path", zoom: "cover_landscape_zoom", ox: "cover_landscape_offset_x", oy: "cover_landscape_offset_y" },
};
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { id } = await ctx.params;
const numId = Number(id);
if (!Number.isFinite(numId)) return NextResponse.json({ error: "bad id" }, { status: 400 });
const url = new URL(req.url);
const slot = (url.searchParams.get("slot") ?? "portrait") as keyof typeof SLOT_COLS;
const cols = SLOT_COLS[slot];
if (!cols) return NextResponse.json({ error: "bad slot" }, { status: 400 });
const coll = rawDb.prepare(`SELECT id, slug, ${cols.path} AS prevPath FROM collections WHERE id = ?`).get(numId) as
| { id: number; slug: string; prevPath: string | null }
| undefined;
if (!coll) return NextResponse.json({ error: "collection not found" }, { status: 404 });
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) return NextResponse.json({ error: "missing file" }, { status: 400 });
const ext = path.extname(file.name).toLowerCase();
if (!ALLOWED_EXT.has(ext)) return NextResponse.json({ error: "unsupported format" }, { status: 415 });
const buf = Buffer.from(await file.arrayBuffer());
const sha = crypto.createHash("sha256").update(buf).digest("hex").slice(0, 16);
const filename = `${coll.id}-${slot}-${sha}${ext}`;
await fs.mkdir(COVER_ROOT, { recursive: true });
await fs.writeFile(path.join(COVER_ROOT, filename), buf);
if (coll.prevPath && coll.prevPath !== filename) {
const prevAbs = safeJoin(COVER_ROOT, coll.prevPath);
if (prevAbs) await fs.rm(prevAbs, { force: true }).catch(() => {});
}
rawDb.prepare(`
UPDATE collections
SET ${cols.path} = ?, ${cols.zoom} = 1, ${cols.ox} = 0, ${cols.oy} = 0
WHERE id = ?
`).run(filename, coll.id);
revalidatePath("/collection");
revalidatePath(`/collection/${coll.slug}`);
return NextResponse.json({ coverPath: filename });
}
+75
View File
@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { listImages, countImages } from "@/lib/db/queries";
import { resolveSort } from "@/lib/sortServer";
import { parseFilterCriteria, statusToFlags } from "@/lib/filters";
import { getAppSetting } from "@/lib/db/appSettings";
import type { LibraryView } from "@/components/grid/ViewToggle";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Paginated covers feed for client-side infinite-scroll appends.
* Mirrors the SSR filter shape in app/page.tsx — every filter the
* grid supports (letter, search, sort, marks, multi-select tabs)
* resolves through the same listImages/countImages path.
*/
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const sp = req.nextUrl.searchParams;
// Ape Object.fromEntries for plain access matching page params.
const params: Record<string, string | string[] | undefined> = {};
for (const [k, v] of sp.entries()) {
const cur = params[k];
if (cur == null) params[k] = v;
else if (Array.isArray(cur)) cur.push(v);
else params[k] = [cur, v];
}
const criteria = parseFilterCriteria(params);
const sort = await resolveSort(typeof params.sort === "string" ? params.sort : undefined);
const rawLetter = (typeof params.letter === "string" ? params.letter : "").toUpperCase();
const letter = rawLetter === "#" ? "#" : (/^[A-Z]$/.test(rawLetter) ? rawLetter : null);
const search = (typeof params.q === "string" ? params.q.trim() : "") || undefined;
// view is purely a presentational hint; included for symmetry but
// doesn't affect query.
void (params.view === "portrait" ? "portrait" : "landscape" as LibraryView);
const rawPage = typeof params.page === "string" ? Number(params.page) : NaN;
const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1;
const filterOpts = {
sort,
letter: letter ?? undefined,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
};
const PAGE_SIZE = Math.max(25, Math.min(500, getAppSetting("coverPageSize") ?? 100));
const totalCount = countImages(filterOpts);
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const effectivePage = Math.min(page, totalPages);
const offset = (effectivePage - 1) * PAGE_SIZE;
const items = listImages({ ...filterOpts, limit: PAGE_SIZE, offset });
return NextResponse.json(
{ items, page: effectivePage, totalPages, totalCount, hasMore: effectivePage < totalPages },
{ headers: { "Cache-Control": "no-store" } },
);
}
+14
View File
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { serveImage } from "@/lib/api/serveAssets";
export const runtime = "nodejs";
export async function GET(req: NextRequest, ctx: { params: Promise<{ file: string }> }) {
const { file } = await ctx.params;
const url = new URL(req.url);
const id = url.searchParams.get("id");
const codeFromPath = decodeURIComponent(file).replace(/\.[^.]+$/, "");
// Don't try to look up by code when the path is the "image-<id>" fallback.
const code = codeFromPath.startsWith("image-") ? null : codeFromPath;
return serveImage(req, { id, code });
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "node:fs/promises";
import path from "node:path";
import { revalidatePath } from "next/cache";
import { assertLocalRequest } from "@/lib/api/localOnly";
import {
attachManualSubtitle,
detachManualSubtitle,
listManualSubtitlesForVariant,
} from "@/lib/video/manualSubtitles";
import { SUBTITLE_EXTS } from "@/lib/video/subtitles";
import { isAllowedSubtitlePath } from "@/lib/video/subtitleAccess";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface AttachBody {
partIdx?: number;
abs?: string;
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const body = (await req.json().catch(() => ({}))) as AttachBody;
const partIdx = typeof body.partIdx === "number" && Number.isFinite(body.partIdx) ? Math.max(0, body.partIdx) : 0;
const abs = typeof body.abs === "string" ? body.abs.trim() : "";
if (!abs) return NextResponse.json({ error: "Missing abs" }, { status: 400 });
const ext = path.extname(abs).toLowerCase();
if (!(SUBTITLE_EXTS as readonly string[]).includes(ext)) {
return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 });
}
// Containment: only attach paths that already pass the same allowlist
// the track endpoint enforces (configured roots / generated-subtitles /
// session-trusted via /api/pick-file). Without this check, any local
// POST could persist an arbitrary on-disk path into manual_subtitles
// and gain permanent read access through the track endpoint.
const absResolved = path.resolve(abs);
if (!isAllowedSubtitlePath(absResolved)) {
return NextResponse.json({ error: "Subtitle path not allowed" }, { status: 403 });
}
// Sanity-check the file is readable. Rejecting now beats silent
// failure later when the picker tries to fetch the track.
try {
await fs.access(absResolved);
} catch {
return NextResponse.json({ error: "File not accessible" }, { status: 404 });
}
attachManualSubtitle(decoded, partIdx, absResolved);
revalidatePath("/id/[code]", "page");
return NextResponse.json({ ok: true });
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const partRaw = req.nextUrl.searchParams.get("part");
const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const abs = req.nextUrl.searchParams.get("abs") ?? "";
if (!abs) return NextResponse.json({ error: "Missing abs" }, { status: 400 });
detachManualSubtitle(decoded, partIdx, abs);
return NextResponse.json({ ok: true });
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const partRaw = req.nextUrl.searchParams.get("part");
const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
return NextResponse.json({ entries: listManualSubtitlesForVariant(decoded, partIdx) });
}
+123
View File
@@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from "next/server";
import { spawn } from "node:child_process";
import path from "node:path";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { trustSubtitlePath } from "@/lib/video/subtitleAccess";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Open a native OS file-picker dialog and return the absolute path of
* the selected file. Mirrors /api/pick-folder. Currently scoped to
* subtitle files — when a subtitle is picked, the path is added to the
* session-trusted set so the subtitle track endpoint will serve it
* even if it lives outside any indexed video root.
*/
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const body = await req.json().catch(() => ({}));
const startPath = typeof body.start === "string" ? body.start : "";
const purpose = typeof body.purpose === "string" ? body.purpose : "subtitle";
try {
const picked = await runPicker(startPath, purpose);
if (!picked) return NextResponse.json({ path: null, cancelled: true });
const abs = path.resolve(picked);
if (purpose === "subtitle") {
trustSubtitlePath(abs);
}
return NextResponse.json({ path: abs });
} catch (e) {
return NextResponse.json({ error: (e as Error).message, path: null }, { status: 500 });
}
}
function runPicker(startPath: string, purpose: string): Promise<string | null> {
if (process.platform === "win32") return pickerWindows(startPath, purpose);
if (process.platform === "darwin") return pickerMacOS(startPath, purpose);
return pickerLinux(startPath, purpose);
}
function pickerWindows(startPath: string, purpose: string): Promise<string | null> {
// User-controlled values (startPath, filter) are passed via env vars so
// PowerShell never parses them as code. The script body itself contains
// no interpolation — only literal references to $env:PINKUDEX_PICK_*.
const filter = purpose === "subtitle"
? "Subtitle files (*.srt;*.vtt;*.ass;*.ssa)|*.srt;*.vtt;*.ass;*.ssa|All files (*.*)|*.*"
: "All files (*.*)|*.*";
const script = `
Add-Type -AssemblyName System.Windows.Forms | Out-Null
$dlg = New-Object System.Windows.Forms.OpenFileDialog
$dlg.Title = 'Pinkudex — pick a file'
$dlg.Filter = $env:PINKUDEX_PICK_FILTER
$dlg.Multiselect = $false
if ($env:PINKUDEX_PICK_START) { try { $dlg.InitialDirectory = $env:PINKUDEX_PICK_START } catch {} }
$owner = New-Object System.Windows.Forms.Form
$owner.TopMost = $true
$owner.Opacity = 0
$owner.ShowInTaskbar = $false
$result = $dlg.ShowDialog($owner)
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
Write-Output $dlg.FileName
}
`.trim();
return runProcess("powershell.exe", ["-NoProfile", "-Sta", "-Command", script], {
PINKUDEX_PICK_START: startPath,
PINKUDEX_PICK_FILTER: filter,
});
}
function pickerMacOS(startPath: string, purpose: string): Promise<string | null> {
const startClause = startPath
? ` default location (POSIX file "${startPath.replace(/"/g, '\\"')}")`
: "";
const typeClause = purpose === "subtitle"
? ` of type {"srt", "vtt", "ass", "ssa"}`
: "";
const script = `try
set f to choose file with prompt "Pinkudex — pick a file"${typeClause}${startClause}
return POSIX path of f
on error number -128
return ""
end try`;
return runProcess("osascript", ["-e", script]);
}
function pickerLinux(startPath: string, purpose: string): Promise<string | null> {
const args = ["--file-selection", "--title=Pinkudex — pick a file"];
if (purpose === "subtitle") {
args.push("--file-filter=Subtitles | *.srt *.vtt *.ass *.ssa");
args.push("--file-filter=All files | *");
}
if (startPath) args.push(`--filename=${startPath}`);
return runProcess("zenity", args).catch((e) => {
throw new Error(`Linux file pickers require zenity to be installed (${(e as Error).message})`);
});
}
function runProcess(
cmd: string,
args: string[],
extraEnv?: Record<string, string>,
): Promise<string | null> {
return new Promise((resolve, reject) => {
const env = extraEnv ? { ...process.env, ...extraEnv } : process.env;
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env });
let out = "";
let err = "";
child.stdout.on("data", (b) => { out += b.toString(); });
child.stderr.on("data", (b) => { err += b.toString(); });
child.on("error", (e) => reject(e));
child.on("close", (code) => {
if (code !== 0 && code !== 1 && code !== null) {
reject(new Error(err.trim() || `picker exited with code ${code}`));
return;
}
const trimmed = out.trim().replace(/\r/g, "");
resolve(trimmed || null);
});
});
}
+107
View File
@@ -0,0 +1,107 @@
import { NextRequest, NextResponse } from "next/server";
import { spawn } from "node:child_process";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Open a native OS folder-picker dialog and return the absolute path the
* user selected. Works because Pinkudex is local-only — the Next.js
* server has the same desktop session as the browser. Returns
* `{ path: null }` if the user cancels.
*
* Windows: PowerShell + WinForms FolderBrowserDialog.
* macOS: osascript "choose folder".
* Linux: zenity (must be installed).
*/
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const body = await req.json().catch(() => ({}));
const startPath = typeof body.start === "string" ? body.start : "";
try {
const path = await runPicker(startPath);
return NextResponse.json({ path });
} catch (e) {
return NextResponse.json({ error: (e as Error).message, path: null }, { status: 500 });
}
}
function runPicker(startPath: string): Promise<string | null> {
if (process.platform === "win32") return pickerWindows(startPath);
if (process.platform === "darwin") return pickerMacOS(startPath);
return pickerLinux(startPath);
}
function pickerWindows(startPath: string): Promise<string | null> {
// STA threading is required for WinForms dialogs in PowerShell.
// -Sta keeps it; -NoProfile avoids whatever the user's profile prints.
// startPath is passed via env var so PowerShell never parses it as code.
const script = `
Add-Type -AssemblyName System.Windows.Forms | Out-Null
$dlg = New-Object System.Windows.Forms.FolderBrowserDialog
$dlg.Description = 'Pinkudex — pick a folder'
$dlg.ShowNewFolderButton = $false
if ($env:PINKUDEX_PICK_START) { try { $dlg.SelectedPath = $env:PINKUDEX_PICK_START } catch {} }
$owner = New-Object System.Windows.Forms.Form
$owner.TopMost = $true
$owner.Opacity = 0
$owner.ShowInTaskbar = $false
$result = $dlg.ShowDialog($owner)
if ($result -eq [System.Windows.Forms.DialogResult]::OK) {
Write-Output $dlg.SelectedPath
}
`.trim();
return runProcess("powershell.exe", ["-NoProfile", "-Sta", "-Command", script], {
PINKUDEX_PICK_START: startPath,
});
}
function pickerMacOS(startPath: string): Promise<string | null> {
const startClause = startPath
? ` default location (POSIX file "${startPath.replace(/"/g, '\\"')}")`
: "";
const script = `try
set f to choose folder with prompt "Pinkudex — pick a folder"${startClause}
return POSIX path of f
on error number -128
return ""
end try`;
return runProcess("osascript", ["-e", script]);
}
function pickerLinux(startPath: string): Promise<string | null> {
const args = ["--file-selection", "--directory", "--title=Pinkudex — pick a folder"];
if (startPath) args.push(`--filename=${startPath.endsWith("/") ? startPath : startPath + "/"}`);
return runProcess("zenity", args).catch((e) => {
throw new Error(`Linux folder pickers require zenity to be installed (${(e as Error).message})`);
});
}
function runProcess(
cmd: string,
args: string[],
extraEnv?: Record<string, string>,
): Promise<string | null> {
return new Promise((resolve, reject) => {
const env = extraEnv ? { ...process.env, ...extraEnv } : process.env;
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], env });
let out = "";
let err = "";
child.stdout.on("data", (b) => { out += b.toString(); });
child.stderr.on("data", (b) => { err += b.toString(); });
child.on("error", (e) => reject(e));
child.on("close", (code) => {
// Cancel paths return non-zero (zenity) or empty stdout — treat as null.
if (code !== 0 && code !== 1 && code !== null) {
reject(new Error(err.trim() || `picker exited with code ${code}`));
return;
}
const trimmed = out.trim().replace(/\r/g, "");
resolve(trimmed || null);
});
});
}
+10
View File
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { servePortrait } from "@/lib/api/serveAssets";
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const p = new URL(req.url).searchParams.get("p");
if (!p) return new Response("not found", { status: 404 });
return servePortrait(p);
}
+10
View File
@@ -0,0 +1,10 @@
import { NextRequest } from "next/server";
import { serveThumb } from "@/lib/api/serveAssets";
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const p = new URL(req.url).searchParams.get("p");
if (!p) return new Response("not found", { status: 404 });
return serveThumb(p);
}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from "next/server";
import { ingestFile } from "@/lib/ingest/ingest";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { rawDb } from "@/lib/db/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Hard cap on a single uploaded file. Pinkudex stores images and short
// covers; anything beyond this is almost certainly a mistake (or an
// attack). Without the cap, `await file.arrayBuffer()` happily buffers
// multi-GB POSTs and OOMs the Node process.
const MAX_UPLOAD_BYTES = 512 * 1024 * 1024;
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const contentLength = Number(req.headers.get("content-length") ?? "");
if (Number.isFinite(contentLength) && contentLength > MAX_UPLOAD_BYTES) {
return NextResponse.json({ error: "Upload too large" }, { status: 413 });
}
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File)) {
return NextResponse.json({ error: "missing file" }, { status: 400 });
}
if (file.size > MAX_UPLOAD_BYTES) {
return NextResponse.json({ error: "Upload too large" }, { status: 413 });
}
const buf = Buffer.from(await file.arrayBuffer());
const nfoFile = form.get("nfo");
const nfoXml = nfoFile instanceof File ? await nfoFile.text() : undefined;
const autoTag = form.get("autoTag");
const autoCollection = form.get("autoCollection");
let autoCollectionId: number | undefined;
if (typeof autoCollection === "string" && autoCollection.trim()) {
const parsed = Number(autoCollection);
if (!Number.isInteger(parsed) || parsed <= 0) {
return NextResponse.json({ error: "invalid collection" }, { status: 400 });
}
const exists = rawDb.prepare(`SELECT id FROM collections WHERE id = ?`).get(parsed) as { id: number } | undefined;
if (!exists) {
return NextResponse.json({ error: "collection not found" }, { status: 400 });
}
autoCollectionId = parsed;
}
const autoAssign = (typeof autoTag === "string" && autoTag.trim()) || autoCollectionId != null
? {
tagName: typeof autoTag === "string" ? autoTag : undefined,
collectionId: autoCollectionId,
}
: undefined;
const parentImageIdRaw = form.get("parentImageId");
const parentImageId = typeof parentImageIdRaw === "string" && parentImageIdRaw ? Number(parentImageIdRaw) : undefined;
const targetFilenameRaw = form.get("targetFilename");
const targetFilename = typeof targetFilenameRaw === "string" && targetFilenameRaw.trim() ? targetFilenameRaw.trim() : undefined;
const actressNamesRaw = form.get("actressNames");
let actressNames: string[] | undefined;
if (typeof actressNamesRaw === "string" && actressNamesRaw.trim()) {
try {
const parsed = JSON.parse(actressNamesRaw);
if (Array.isArray(parsed)) actressNames = parsed.filter((s): s is string => typeof s === "string");
} catch {
// ignore
}
}
const onCollisionRaw = form.get("onCollision");
const onCollision = onCollisionRaw === "replace" || onCollisionRaw === "skip" ? onCollisionRaw : "detect";
try {
const result = await ingestFile(buf, file.name, {
nfoXml,
autoAssign,
parentImageId,
targetFilename,
actressNames,
onCollision,
});
return NextResponse.json(result);
} catch (err) {
console.error("ingest failed", err);
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
}
}
+143
View File
@@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import { findVideosForCode } from "@/lib/video";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { getStoredVideoMetadata, serializeVideoMetadata } from "@/lib/video/metadata";
import { variantLabel } from "@/lib/video/partClassify";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface VariantOut {
/** Absolute 0-based index into the original findVideosForCode result.
* Used as the `?part=` query value for stream/HLS endpoints. */
partIdx: number;
abs: string;
rel: string;
filename: string;
size: number;
label: string;
metadata: ReturnType<typeof serializeVideoMetadata>;
}
interface PartOut {
/** 1-based display index for the parts strip. */
partIndex: number;
/** Index into `variants[]` to use when no user pick has been made. */
defaultIdx: number;
variants: VariantOut[];
}
function stemOf(filename: string): string {
const ext = path.extname(filename);
return ext ? filename.slice(0, -ext.length) : filename;
}
/**
* Group raw video files into parts (sequential CDs/discs) with
* variants (alt encodes of the same part). Uses classification from
* the metadata table; falls back to "every file is its own part" when
* classification hasn't run yet.
*/
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const files = findVideosForCode(decoded);
// Build per-part groups.
const partMap = new Map<string, VariantOut[]>();
const orderedKeys: string[] = [];
files.forEach((f, i) => {
const meta = getStoredVideoMetadata(f.abs);
const stem = stemOf(f.filename);
const kind = meta?.partKind;
const idx = meta?.partIndex ?? null;
const group = meta?.variantGroup ?? null;
// Group key strategy:
// - "part" → group by the part's variantGroup (variants attach via dot-prefix)
// - "variant" → group by their attached variantGroup
// - "single" / unclassified → a singleton group keyed by abs path
let key: string;
if ((kind === "part" || kind === "variant") && group != null) {
key = `g:${group}`;
} else {
key = `s:${f.abs}`;
}
const variant: VariantOut = {
partIdx: i,
abs: f.abs,
rel: f.rel,
filename: f.filename,
size: f.size,
label: group ? variantLabel(stem, group) : "original",
metadata: serializeVideoMetadata(meta),
};
// Stash the underlying part index for sorting; non-parts get +Infinity.
(variant as VariantOut & { __sort: number }).__sort = idx ?? (kind === "variant" ? -1 : Number.MAX_SAFE_INTEGER);
let arr = partMap.get(key);
if (!arr) {
arr = [];
partMap.set(key, arr);
orderedKeys.push(key);
}
arr.push(variant);
});
// Build the ordered parts list. Sort parts by their lowest known
// partIndex (singles fall to the end), preserving insertion order
// as a tiebreak.
const partEntries = orderedKeys.map((k) => {
const variants = partMap.get(k)!;
const minSort = Math.min(...variants.map((v) => (v as VariantOut & { __sort: number }).__sort));
return { key: k, variants, sort: minSort };
});
partEntries.sort((a, b) => {
if (a.sort !== b.sort) return a.sort - b.sort;
return a.variants[0]!.partIdx - b.variants[0]!.partIdx;
});
const parts: PartOut[] = partEntries.map((entry, i) => {
const variants = entry.variants;
// Strip the sort helper field.
for (const v of variants) delete (v as Partial<VariantOut & { __sort: number }>).__sort;
// Default = the variant whose stem == group (the "base" file). If
// none, alphabetically first by filename.
const groupKey = entry.key.startsWith("g:") ? entry.key.slice(2) : null;
let defaultIdx = 0;
if (groupKey != null) {
const exact = variants.findIndex((v) => stemOf(v.filename) === groupKey);
if (exact >= 0) defaultIdx = exact;
else {
const sortedAlpha = [...variants].sort((a, b) => a.filename.localeCompare(b.filename));
defaultIdx = variants.indexOf(sortedAlpha[0]!);
}
}
return {
partIndex: i + 1,
defaultIdx,
variants,
};
});
// Backwards-compatible flat list — the default variant of each part
// in display order. Existing consumers that only need one entry per
// part keep working without changes.
const flat = parts.map((p) => p.variants[p.defaultIdx]!);
return NextResponse.json({
parts,
files: flat.map((v) => ({
abs: v.abs,
rel: v.rel,
filename: v.filename,
size: v.size,
metadata: v.metadata,
})),
});
}
@@ -0,0 +1,83 @@
import { NextRequest } from "next/server";
import fsp from "node:fs/promises";
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
import { getAppSetting } from "@/lib/db/appSettings";
import { probeDuration } from "@/lib/video/duration";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* HLS playlist generator. Returns an m3u8 with N segment URLs covering
* the full video duration. Segments are produced on demand by the
* sibling /segment endpoint (each one is a fresh NVENC transcode of a
* fixed time window). Player (hls.js) requests segments as needed for
* playback and seeking.
*/
const SEGMENT_SECONDS = 6;
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
let files = findVideosForCode(decoded);
if (files.length === 0) {
const main = (getAppSetting("videoLibraryPath") || "").trim();
const extras = getAppSetting("videoExtraPaths") ?? [];
const expected = [main, ...extras].filter(Boolean);
const idx = getVideoIndex();
const haveAll = expected.length === idx.rootsScanned.length
&& expected.every((r, i) => r === idx.rootsScanned[i]);
if (expected.length > 0 && !haveAll) {
await rescanVideoIndex();
files = findVideosForCode(decoded);
}
}
if (files.length === 0) return new Response("not found", { status: 404 });
const file = files[Math.min(part, files.length - 1)];
try {
await fsp.stat(file.abs);
} catch {
return new Response("not found", { status: 404 });
}
const duration = await probeDuration(file.abs, req.signal);
if (duration == null) {
return new Response("ffprobe failed", { status: 500 });
}
const segCount = Math.ceil(duration / SEGMENT_SECONDS);
const lines: string[] = [
"#EXTM3U",
"#EXT-X-VERSION:3",
`#EXT-X-TARGETDURATION:${SEGMENT_SECONDS}`,
"#EXT-X-MEDIA-SEQUENCE:0",
"#EXT-X-PLAYLIST-TYPE:VOD",
];
for (let i = 0; i < segCount; i++) {
const remaining = duration - i * SEGMENT_SECONDS;
const segDur = Math.min(SEGMENT_SECONDS, remaining);
lines.push(`#EXTINF:${segDur.toFixed(3)},`);
// Relative URL — resolves against the playlist URL's directory.
// Playlist is at /api/video-hls/[code]/playlist, so its directory is
// /api/video-hls/[code]/ and `segment?...` resolves to the sibling.
lines.push(`segment?part=${part}&i=${i}`);
}
lines.push("#EXT-X-ENDLIST");
return new Response(lines.join("\n"), {
status: 200,
headers: {
"Content-Type": "application/vnd.apple.mpegurl",
"Cache-Control": "no-store",
},
});
}
+160
View File
@@ -0,0 +1,160 @@
import { NextRequest } from "next/server";
import { spawn, type ChildProcess } from "node:child_process";
import fsp from "node:fs/promises";
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
import { getAppSetting } from "@/lib/db/appSettings";
import { probeDuration } from "@/lib/video/duration";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* HLS segment endpoint. Each request transcodes a single 6-second
* window of the source via NVENC into MPEG-TS and pipes to the
* response. -bf 0 keeps Chromium's H.264 sink happy. -force_key_frames
* 0 (and NVENC's -forced-idr) ensure the segment opens with an IDR so
* it's independently decodable — required by HLS.
*/
const SEGMENT_SECONDS = 6;
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const iRaw = url.searchParams.get("i");
const segmentIndex = iRaw == null ? 0 : Math.max(0, parseInt(iRaw, 10) || 0);
let files = findVideosForCode(decoded);
if (files.length === 0) {
const main = (getAppSetting("videoLibraryPath") || "").trim();
const extras = getAppSetting("videoExtraPaths") ?? [];
const expected = [main, ...extras].filter(Boolean);
const idx = getVideoIndex();
const haveAll = expected.length === idx.rootsScanned.length
&& expected.every((r, i) => r === idx.rootsScanned[i]);
if (expected.length > 0 && !haveAll) {
await rescanVideoIndex();
files = findVideosForCode(decoded);
}
}
if (files.length === 0) return new Response("not found", { status: 404 });
const file = files[Math.min(part, files.length - 1)];
try {
await fsp.stat(file.abs);
} catch {
return new Response("not found", { status: 404 });
}
const duration = await probeDuration(file.abs, req.signal);
if (duration == null) {
return new Response("ffprobe failed", { status: 500 });
}
const startTime = segmentIndex * SEGMENT_SECONDS;
if (startTime >= duration) {
return new Response("segment out of range", { status: 416 });
}
const segDur = Math.min(SEGMENT_SECONDS, duration - startTime);
const ffmpegArgs: string[] = [
"-hide_banner", "-loglevel", "error",
// -ss before -i = fast container-level seek (lands on the prior key
// frame, NVENC's first emitted frame is an IDR by spec).
"-ss", startTime.toFixed(3),
"-t", segDur.toFixed(3),
"-i", file.abs,
"-map", "0:v:0",
"-map", "0:a:0?",
"-c:v", "h264_nvenc",
"-preset", "p4",
"-tune", "ll",
"-profile:v", "high",
"-bf", "0",
"-forced-idr", "1",
"-rc", "cbr",
"-b:v", "8M",
"-maxrate", "8M",
"-bufsize", "16M",
"-pix_fmt", "yuv420p",
"-c:a", "aac",
"-b:a", "192k",
"-ac", "2",
"-f", "mpegts",
"-mpegts_flags", "+resend_headers",
// Shift output timestamps so segment N's PTS starts at N*SEGMENT_SECONDS.
// Without this, every segment would emit at PTS≈0 and hls.js / MSE
// can't lay them out on a continuous timeline (would need
// #EXT-X-DISCONTINUITY markers for that). Continuous PTS = clean
// append, smooth playback across segment boundaries.
"-output_ts_offset", startTime.toFixed(3),
"pipe:1",
];
let ffmpeg: ChildProcess;
try {
ffmpeg = spawn("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
} catch (e) {
return new Response(`ffmpeg spawn failed: ${(e as Error).message}`, { status: 500 });
}
ffmpeg.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString();
if (text.trim()) console.error(`[hls ${decoded} seg=${segmentIndex}] ${text.trim()}`);
});
return new Response(streamFromFfmpeg(ffmpeg, req.signal), {
status: 200,
headers: {
"Content-Type": "video/mp2t",
// Allow short-term caching — within a single playback session hls.js
// may re-request a segment if its buffer was evicted, and a cache
// hit avoids re-spawning ffmpeg.
"Cache-Control": "private, max-age=300",
},
});
}
function streamFromFfmpeg(proc: ChildProcess, signal: AbortSignal): ReadableStream<Uint8Array> {
return new ReadableStream<Uint8Array>({
start(controller) {
let closed = false;
const finish = () => {
if (closed) return;
closed = true;
try { controller.close(); } catch { /* already closed */ }
};
const fail = (err: Error) => {
if (closed) return;
closed = true;
try { controller.error(err); } catch { /* already closed */ }
};
proc.stdout?.on("data", (chunk: Buffer) => {
if (closed) return;
try {
controller.enqueue(new Uint8Array(chunk));
} catch {
closed = true;
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
}
});
proc.stdout?.on("end", finish);
proc.on("error", (e) => fail(e));
proc.on("exit", finish);
const onAbort = () => {
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
};
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
},
cancel() {
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
},
});
}
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
import { getAppSetting } from "@/lib/db/appSettings";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { probeVideoMetadata, serializeVideoMetadata, setVideoPlaybackMode } from "@/lib/video/metadata";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface ProbeResponse {
codec: string | null;
bFrames: number | null;
cachedMode: string | null;
metadata: ReturnType<typeof serializeVideoMetadata>;
}
async function resolveFile(decoded: string, partIdx: number) {
let files = findVideosForCode(decoded);
if (files.length === 0) {
const main = (getAppSetting("videoLibraryPath") || "").trim();
const extras = getAppSetting("videoExtraPaths") ?? [];
const expected = [main, ...extras].filter(Boolean);
const idx = getVideoIndex();
const haveAll = expected.length === idx.rootsScanned.length
&& expected.every((r, i) => r === idx.rootsScanned[i]);
if (expected.length > 0 && !haveAll) {
await rescanVideoIndex();
files = findVideosForCode(decoded);
}
}
if (files.length === 0) return null;
return files[Math.min(Math.max(0, partIdx), files.length - 1)];
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const file = await resolveFile(decoded, partIdx);
if (!file) {
return NextResponse.json<ProbeResponse>({ codec: null, bFrames: null, cachedMode: null, metadata: null });
}
try {
const meta = await probeVideoMetadata(file, req.signal);
return NextResponse.json<ProbeResponse>({
codec: meta.videoCodec,
bFrames: meta.videoBFrames,
cachedMode: meta.playbackMode,
metadata: serializeVideoMetadata(meta),
});
} catch (e) {
console.error("[video-probe] failed:", e);
return NextResponse.json<ProbeResponse>(
{ codec: null, bFrames: null, cachedMode: null, metadata: null },
{ status: 200 },
);
}
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const partIdx = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const body = await req.json().catch(() => ({})) as { mode?: string | null };
const mode = body.mode;
if (mode !== "direct" && mode !== "transcode" && mode !== null && mode !== undefined) {
return NextResponse.json({ error: "invalid mode" }, { status: 400 });
}
const file = await resolveFile(decoded, partIdx);
if (!file) return NextResponse.json({ updated: 0 });
setVideoPlaybackMode(file, mode ?? null);
return NextResponse.json({ updated: 1 });
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
import { rescanVideoIndex } from "@/lib/video";
import { rawDb } from "@/lib/db/client";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const t0 = Date.now();
const force = req.nextUrl.searchParams.get("force") === "1";
const idx = await rescanVideoIndex({ force });
// Bust the RSC cache for detail pages so file-size / duration
// refresh without a navigation. Skip the layout invalidation —
// it triggers a full-app re-render and isn't needed for the
// metadata badges we actually changed.
revalidatePath("/id/[code]", "page");
// codes count comes from the DB now, not an in-memory Map. Cheap.
const distinctCodesRow = rawDb
.prepare(`SELECT COUNT(DISTINCT upper(code)) AS n FROM video_metadata`)
.get() as { n: number };
return NextResponse.json({
ok: true,
count: idx.count,
codes: distinctCodesRow.n,
rootsScanned: idx.rootsScanned,
elapsedMs: Date.now() - t0,
});
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { spawn } from "node:child_process";
import { findVideosForCode } from "@/lib/video";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Open the OS file manager pre-selected on the cover's video file.
* Local-only — explicitly gated by assertLocalRequest.
*/
export async function POST(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const files = findVideosForCode(decoded);
if (files.length === 0) return NextResponse.json({ error: "not found" }, { status: 404 });
const file = files[Math.min(part, files.length - 1)];
try {
if (process.platform === "win32") {
// explorer doesn't return zero-exit even on success; detach and don't await.
spawn("explorer", ["/select,", file.abs], { detached: true, stdio: "ignore" }).unref();
} else if (process.platform === "darwin") {
spawn("open", ["-R", file.abs], { detached: true, stdio: "ignore" }).unref();
} else {
// Linux: open the parent dir; most file managers don't have a select API.
const parent = file.abs.replace(/[/\\][^/\\]*$/, "");
spawn("xdg-open", [parent], { detached: true, stdio: "ignore" }).unref();
}
return NextResponse.json({ ok: true, path: file.abs });
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { getVideoIndex, rescanVideoIndex, getCodesWithVideos, getCodesWithSubtitles } from "@/lib/video";
import { getAppSetting } from "@/lib/db/appSettings";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Lightweight enumeration of every JAV code that has at least one
* playable file in the index. The client uses this to show "has video"
* badges on cover cards. Returned as a plain array for JSON portability.
*
* Auto-builds the index on first hit if a video folder is configured but
* the index is empty — avoids requiring a manual rescan on a fresh
* server boot.
*/
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
let idx = getVideoIndex();
const main = (getAppSetting("videoLibraryPath") || "").trim();
const extras = getAppSetting("videoExtraPaths") ?? [];
const expected = [main, ...extras].filter(Boolean);
const haveAll = expected.length === idx.rootsScanned.length
&& expected.every((r, i) => r === idx.rootsScanned[i]);
if (expected.length > 0 && !haveAll) {
idx = await rescanVideoIndex();
}
return NextResponse.json({
codes: Array.from(getCodesWithVideos()),
subtitleCodes: Array.from(getCodesWithSubtitles()),
count: idx.count,
lastScannedAt: idx.lastScannedAt,
rootsScanned: idx.rootsScanned,
});
}
+164
View File
@@ -0,0 +1,164 @@
import { NextRequest } from "next/server";
import path from "node:path";
import fs from "node:fs";
import fsp from "node:fs/promises";
import { findVideosForCode } from "@/lib/video";
import { assertLocalRequest } from "@/lib/api/localOnly";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const MIME_BY_EXT: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
".wmv": "video/x-ms-wmv",
".ts": "video/mp2t",
".mpg": "video/mpeg",
".mpeg": "video/mpeg",
".flv": "video/x-flv",
};
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const url = new URL(req.url);
const partRaw = url.searchParams.get("part");
const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
const files = findVideosForCode(decoded);
if (files.length === 0) return new Response("not found", { status: 404 });
const file = files[Math.min(part, files.length - 1)];
let stat: import("node:fs").Stats;
try {
stat = await fsp.stat(file.abs);
} catch {
return new Response("not found", { status: 404 });
}
const total = stat.size;
const ext = path.extname(file.abs).toLowerCase();
const mime = MIME_BY_EXT[ext] ?? "application/octet-stream";
// Stable identity for the byte stream — lets the browser's HTTP cache
// hold onto previously fetched ranges (the moov tail in particular)
// instead of re-hitting our endpoint on every seek / buffer-ahead.
const etag = `"${stat.size.toString(36)}-${Math.floor(stat.mtimeMs).toString(36)}"`;
const lastModified = new Date(stat.mtimeMs).toUTCString();
const range = req.headers.get("range");
const baseHeaders: Record<string, string> = {
"Content-Type": mime,
"Accept-Ranges": "bytes",
"Cache-Control": "private, max-age=3600",
"ETag": etag,
"Last-Modified": lastModified,
"Content-Disposition": `inline; filename="${encodeURIComponent(file.filename)}"`,
};
if (!range) {
return new Response(streamFile(file.abs, undefined, undefined, req.signal), {
status: 200,
headers: { ...baseHeaders, "Content-Length": String(total) },
});
}
// Parse "bytes=START-END"; END may be empty for "until end", and
// START may be empty for HTTP suffix ranges ("last N bytes").
const m = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!m) return new Response("bad range", { status: 416 });
let start: number;
let end: number;
if (m[1] === "") {
const suffixLen = Number(m[2]);
if (!Number.isFinite(suffixLen) || suffixLen <= 0) {
return new Response("bad range", { status: 416, headers: { "Content-Range": `bytes */${total}` } });
}
start = Math.max(total - suffixLen, 0);
end = total - 1;
} else {
start = Number(m[1]);
end = m[2] === "" ? total - 1 : Number(m[2]);
}
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || end >= total) {
return new Response("bad range", { status: 416, headers: { "Content-Range": `bytes */${total}` } });
}
const len = end - start + 1;
return new Response(streamFile(file.abs, start, end, req.signal), {
status: 206,
headers: {
...baseHeaders,
"Content-Range": `bytes ${start}-${end}/${total}`,
"Content-Length": String(len),
},
});
}
/**
* Pipe a file slice into a Web ReadableStream that the runtime can hand
* to fetch's Response. Tying the read stream to the request's AbortSignal
* is the bit that fixes "Invalid state: Controller is already closed":
* when the browser cancels (modal close, seek, network blip) the Node
* stream is destroyed before it can push more bytes into a stream the
* runtime has already closed.
*/
function streamFile(
abs: string,
start: number | undefined,
end: number | undefined,
signal: AbortSignal,
): ReadableStream<Uint8Array> {
let node: fs.ReadStream | null = null;
let closed = false;
return new ReadableStream<Uint8Array>({
start(controller) {
node = fs.createReadStream(abs, { start, end });
const finish = () => {
if (closed) return;
closed = true;
try { controller.close(); } catch { /* already closed */ }
};
const fail = (err: Error) => {
if (closed) return;
closed = true;
try { controller.error(err); } catch { /* already closed */ }
};
node.on("data", (chunk: unknown) => {
if (closed) return;
try {
const u8 = chunk instanceof Uint8Array
? chunk
: new Uint8Array(chunk as ArrayBufferLike);
controller.enqueue(u8);
} catch {
closed = true;
node?.destroy();
}
});
node.on("end", finish);
node.on("error", (err) => fail(err as Error));
const onAbort = () => {
closed = true;
node?.destroy();
};
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
},
cancel() {
// ReadableStream.cancel() fires when the consumer is done before
// req.signal aborts (e.g. browser closes the response body cleanly
// after a Range fulfill). Without destroying the node stream here,
// the open file handle leaks until GC.
closed = true;
node?.destroy();
},
});
}
+221
View File
@@ -0,0 +1,221 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
import { assertLocalRequest } from "@/lib/api/localOnly";
import {
walkSubtitles,
detectLanguageFromName,
normalizeLanguageTag,
languageDisplay,
stemOf,
type LangIso,
} from "@/lib/video/subtitles";
import { runFfprobeSubtitles } from "@/lib/video/metadata";
import { getAppSetting } from "@/lib/db/appSettings";
import { listManualSubtitlesForVariant } from "@/lib/video/manualSubtitles";
import fs from "node:fs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Sidecar (external file) subtitle source. */
interface SidecarOut {
/** Stable client-side id; encodes the abs path so the track endpoint
* can resolve it. */
id: string;
abs: string;
filename: string;
ext: string; // ".srt" | ".vtt" | ".ass" | ".ssa"
language: LangIso | null;
label: string;
origin: "same-folder" | "library" | "manual";
}
/** Embedded-stream subtitle source (filled in once ffprobe is wired up
* in phase 2). */
interface EmbeddedOut {
id: string;
streamIndex: number;
codec: string;
language: LangIso | null;
label: string;
renderable: boolean;
}
function formatCodecLabel(codec: string): string | null {
switch (codec) {
case "subrip": return "SRT";
case "ass": return "ASS";
case "ssa": return "SSA";
case "mov_text": return "mov_text";
case "webvtt": return "VTT";
case "hdmv_pgs_subtitle": return "PGS";
case "dvd_subtitle": return "DVDSub";
case "dvb_subtitle": return "DVBSub";
default: return codec ? codec.toUpperCase() : null;
}
}
function encodeSideId(abs: string): string {
return `side:${Buffer.from(abs, "utf8").toString("base64url")}`;
}
/** Filter walkSubtitles results to entries that look like they belong
* to this specific video — stem prefix is the strong signal; code
* substring is the fallback. Both case-insensitive. */
function matchesVideo(filename: string, stem: string, code: string): boolean {
const lowerName = filename.toLowerCase();
const lowerStem = stem.toLowerCase();
const lowerCode = code.toLowerCase();
if (lowerName.startsWith(lowerStem + ".")) return true;
if (lowerName === lowerStem) return true;
return lowerName.includes(lowerCode);
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const partParam = req.nextUrl.searchParams.get("part");
const partIdx = partParam == null ? 0 : Number.parseInt(partParam, 10);
if (!Number.isFinite(partIdx) || partIdx < 0) {
return NextResponse.json({ error: "Invalid part index" }, { status: 400 });
}
let files = findVideosForCode(decoded);
if (files.length === 0) {
// Cold-boot path: VideoIndexProvider may not have triggered the
// initial scan yet. Build it once so the picker doesn't appear
// empty on first modal open after server start.
const main = (getAppSetting("videoLibraryPath") || "").trim();
const extras = getAppSetting("videoExtraPaths") ?? [];
const expected = [main, ...extras].filter(Boolean);
const idx = getVideoIndex();
const haveAll = expected.length === idx.rootsScanned.length
&& expected.every((r, i) => r === idx.rootsScanned[i]);
if (expected.length > 0 && !haveAll) {
await rescanVideoIndex();
files = findVideosForCode(decoded);
}
}
const variant = files[partIdx];
if (!variant) {
return NextResponse.json({ embedded: [], sidecar: [] });
}
const variantStem = stemOf(variant.filename);
const dir = path.dirname(variant.abs);
// Phase 1: same-folder sidecars only. Embedded streams + library scan
// are added in later phases via additive concat into these arrays.
const sidecar: SidecarOut[] = [];
const seen = new Set<string>();
const pushEntry = (
entry: { abs: string; filename: string },
origin: "same-folder" | "library",
) => {
if (seen.has(entry.abs)) return;
if (!matchesVideo(entry.filename, variantStem, decoded)) return;
seen.add(entry.abs);
const detected = detectLanguageFromName(entry.filename);
const ext = path.extname(entry.filename).toLowerCase();
sidecar.push({
id: encodeSideId(entry.abs),
abs: entry.abs,
filename: entry.filename,
ext,
language: detected.lang,
label: detected.label,
origin,
});
};
try {
for (const entry of await walkSubtitles(dir, 1)) pushEntry(entry, "same-folder");
} catch { /* ignore */ }
// Library scan: persistent extra paths from settings. Slightly deeper
// walk because users typically point these at organized hierarchies.
const extraPaths = (getAppSetting("subtitleExtraPaths") ?? []).filter(Boolean);
for (const root of extraPaths) {
try {
for (const entry of await walkSubtitles(root, 3)) pushEntry(entry, "library");
} catch { /* missing or unreadable root */ }
}
// Implicit always-on root: data/generated-subtitles/<code>/ catches
// WhisperJAV-produced .srt when the video folder isn't writable.
const generatedDir = path.join(process.cwd(), "data", "generated-subtitles", decoded);
try {
for (const entry of await walkSubtitles(generatedDir, 1)) pushEntry(entry, "library");
} catch { /* nothing generated yet */ }
// Manually attached files via Browse... in the player. Persisted
// across sessions; only included when the file still exists on disk.
for (const m of listManualSubtitlesForVariant(decoded, partIdx)) {
if (seen.has(m.absPath)) continue;
if (!fs.existsSync(m.absPath)) continue;
const filename = path.basename(m.absPath);
if (!filename) continue;
const detected = detectLanguageFromName(filename);
const ext = path.extname(filename).toLowerCase();
seen.add(m.absPath);
sidecar.push({
id: encodeSideId(m.absPath),
abs: m.absPath,
filename,
ext,
language: detected.lang,
label: detected.label,
origin: "manual",
});
}
// Stable order: same-folder before library, then by language priority
// (EN, CN, JP, Unknown), then by filename.
const langRank: Record<string, number> = { eng: 0, zho: 1, jpn: 2 };
sidecar.sort((a, b) => {
if (a.origin !== b.origin) return a.origin === "same-folder" ? -1 : 1;
const ra = a.language ? (langRank[a.language] ?? 9) : 9;
const rb = b.language ? (langRank[b.language] ?? 9) : 9;
if (ra !== rb) return ra - rb;
return a.filename.localeCompare(b.filename);
});
const embedded: EmbeddedOut[] = [];
let streams: Awaited<ReturnType<typeof runFfprobeSubtitles>> = [];
try {
streams = await runFfprobeSubtitles(variant.abs);
} catch {
streams = [];
}
for (const s of streams) {
const iso = normalizeLanguageTag(s.language);
const codecLabel = formatCodecLabel(s.codec);
const trailing: string[] = [];
if (s.title) trailing.push(s.title);
if (codecLabel) trailing.push(codecLabel);
const base = iso ? languageDisplay(iso) : (s.title ?? "Unknown");
const label = trailing.length > 0 && !iso
? `${base}${codecLabel ? ` (${codecLabel})` : ""}`
: codecLabel
? `${base} (${codecLabel})`
: base;
embedded.push({
id: `emb:${s.index}`,
streamIndex: s.index,
codec: s.codec,
language: iso,
label,
renderable: s.isTextBased,
});
}
return NextResponse.json(
{ embedded, sidecar },
{ headers: { "Cache-Control": "no-store" } },
);
}
@@ -0,0 +1,242 @@
import { NextRequest, NextResponse } from "next/server";
import path from "node:path";
import fs from "node:fs/promises";
import { spawn } from "node:child_process";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { srtToVtt, SUBTITLE_EXTS, decodeSubtitleBuffer } from "@/lib/video/subtitles";
import { isAllowedSubtitlePath } from "@/lib/video/subtitleAccess";
import { cachePath, readCache, writeCache } from "@/lib/video/subtitleCache";
import { findVideosForCode } from "@/lib/video";
import { runFfprobeSubtitles } from "@/lib/video/metadata";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VTT_HEADERS = {
"Content-Type": "text/vtt; charset=utf-8",
"Cache-Control": "no-store",
} as const;
function decodeSide(src: string): string | null {
if (!src.startsWith("side:")) return null;
const b64 = src.slice("side:".length);
try {
const decoded = Buffer.from(b64, "base64url").toString("utf8");
if (!decoded) return null;
return path.resolve(decoded);
} catch {
return null;
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const src = req.nextUrl.searchParams.get("src") ?? "";
if (!src) {
return NextResponse.json({ error: "Missing src" }, { status: 400 });
}
if (src.startsWith("emb:")) {
return handleEmbedded(req, ctx, src);
}
const abs = decodeSide(src);
if (!abs) {
return NextResponse.json({ error: "Invalid src" }, { status: 400 });
}
if (!isAllowedSubtitlePath(abs)) {
return NextResponse.json({ error: "Subtitle path not allowed" }, { status: 403 });
}
const ext = path.extname(abs).toLowerCase();
if (!(SUBTITLE_EXTS as readonly string[]).includes(ext)) {
return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 });
}
let stat;
try {
stat = await fs.stat(abs);
} catch {
return NextResponse.json({ error: "Subtitle file not found" }, { status: 404 });
}
if (ext === ".vtt") {
// VTT spec mandates UTF-8 but real-world files occasionally ship
// as UTF-16 BOM or a legacy Asian encoding. Run through the same
// decoder as .srt so the output is consistent UTF-8.
let buf: Buffer;
try {
buf = await fs.readFile(abs);
} catch {
return NextResponse.json({ error: "Read failed" }, { status: 500 });
}
const text = decodeSubtitleBuffer(buf);
return new NextResponse(text, { headers: VTT_HEADERS });
}
if (ext === ".srt") {
const file = cachePath({
abs,
size: stat.size,
mtimeMs: stat.mtimeMs,
kind: "srt",
streamOrExt: "srt",
});
const cached = await readCache(file);
if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS });
let buf: Buffer;
try {
buf = await fs.readFile(abs);
} catch {
return NextResponse.json({ error: "Read failed" }, { status: 500 });
}
// decodeSubtitleBuffer auto-detects UTF-8 / UTF-16 / shift_jis /
// gb18030 / big5 — a bare `toString("utf8")` mojibakes legacy CN
// and JP fansub SRTs.
const raw = decodeSubtitleBuffer(buf);
const vtt = srtToVtt(raw);
try {
await writeCache(file, vtt);
} catch {
// Cache miss + failed write isn't fatal; still serve the conversion.
}
return new NextResponse(vtt, { headers: VTT_HEADERS });
}
if (ext === ".ass" || ext === ".ssa") {
const file = cachePath({
abs,
size: stat.size,
mtimeMs: stat.mtimeMs,
kind: ext === ".ass" ? "ass" : "ssa",
streamOrExt: ext.slice(1),
});
const cached = await readCache(file);
if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS });
let buf;
try {
buf = await ffmpegToVtt(["-i", abs, "-map", "0:s:0", "-c:s", "webvtt", "-f", "webvtt", "pipe:1"], req.signal);
} catch {
return NextResponse.json({ error: "Subtitle conversion failed" }, { status: 500 });
}
if (buf.length === 0) return new NextResponse(null, { status: 204 });
try { await writeCache(file, buf); } catch { /* ignore */ }
return new NextResponse(new Uint8Array(buf), { headers: VTT_HEADERS });
}
return NextResponse.json({ error: "Unsupported subtitle extension" }, { status: 415 });
}
async function handleEmbedded(
req: NextRequest,
ctx: { params: Promise<{ code: string }> },
src: string,
): Promise<NextResponse> {
const streamIdx = Number.parseInt(src.slice("emb:".length), 10);
if (!Number.isFinite(streamIdx) || streamIdx < 0) {
return NextResponse.json({ error: "Invalid stream index" }, { status: 400 });
}
const partParam = req.nextUrl.searchParams.get("part");
const partIdx = partParam == null ? 0 : Number.parseInt(partParam, 10);
if (!Number.isFinite(partIdx) || partIdx < 0) {
return NextResponse.json({ error: "Invalid part index" }, { status: 400 });
}
const { code } = await ctx.params;
const decoded = decodeURIComponent(code);
const variant = findVideosForCode(decoded)[partIdx];
if (!variant) {
return NextResponse.json({ error: "Video not found" }, { status: 404 });
}
// Re-probe to validate the requested stream is real and text-based.
// Cheap (sub-100ms) and avoids serving image-based subtitles that
// would render as garbled text or hang ffmpeg.
const streams = await runFfprobeSubtitles(variant.abs);
const target = streams.find((s) => s.index === streamIdx);
if (!target) {
return NextResponse.json({ error: "Stream not found" }, { status: 404 });
}
if (target.isImageBased) {
return NextResponse.json({ error: "Image-based subtitles not supported" }, { status: 415 });
}
if (!target.isTextBased) {
return NextResponse.json({ error: "Subtitle codec not supported" }, { status: 415 });
}
let stat;
try {
stat = await fs.stat(variant.abs);
} catch {
return NextResponse.json({ error: "Video not readable" }, { status: 404 });
}
const file = cachePath({
abs: variant.abs,
size: stat.size,
mtimeMs: stat.mtimeMs,
kind: "embedded",
streamOrExt: streamIdx,
});
const cached = await readCache(file);
if (cached) return new NextResponse(new Uint8Array(cached), { headers: VTT_HEADERS });
let buf: Buffer;
try {
buf = await ffmpegToVtt([
"-i", variant.abs,
"-map", `0:s:${streamIdx}`,
"-c:s", "webvtt",
"-f", "webvtt",
"pipe:1",
], req.signal);
} catch {
return NextResponse.json({ error: "Subtitle extraction failed" }, { status: 500 });
}
if (buf.length === 0) return new NextResponse(null, { status: 204 });
try { await writeCache(file, buf); } catch { /* ignore */ }
return new NextResponse(new Uint8Array(buf), { headers: VTT_HEADERS });
}
const FFMPEG_TIMEOUT_MS = 15_000;
function ffmpegToVtt(args: string[], signal?: AbortSignal): Promise<Buffer> {
return new Promise((resolve, reject) => {
const proc = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", ...args]);
const chunks: Buffer[] = [];
let err = "";
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(t);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
fn();
};
const t = setTimeout(() => {
try { proc.kill("SIGKILL"); } catch {}
settle(() => reject(new Error("ffmpeg timed out")));
}, FFMPEG_TIMEOUT_MS);
// Tear down the subprocess on client disconnect so a 15-second
// ghost ffmpeg doesn't keep CPU after the user closes the modal.
const onAbort = signal
? () => {
try { proc.kill("SIGKILL"); } catch {}
settle(() => reject(new Error("client aborted")));
}
: null;
if (signal && onAbort) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
proc.stdout?.on("data", (d: Buffer) => { chunks.push(d); });
proc.stderr?.on("data", (d) => { err += d.toString(); });
proc.on("error", (e) => settle(() => reject(e)));
proc.on("close", (code) => {
settle(() => {
if (code !== 0) { reject(new Error(err.trim() || `ffmpeg exited ${code}`)); return; }
resolve(Buffer.concat(chunks));
});
});
});
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { rawDb } from "@/lib/db/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
interface CandidateRow {
id: number;
code: string;
title: string | null;
thumb_path: string;
}
/**
* Codes with a playable video but no discoverable subtitle. The user
* picks from this list when running batch WhisperJAV generation.
*
* has_subtitle is the cheap signal — populated by the video index
* scan (sidecar files / generated subs / library roots).
*/
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const limit = Math.min(500, Math.max(1, Number(req.nextUrl.searchParams.get("limit") ?? "200")));
const offset = Math.max(0, Number(req.nextUrl.searchParams.get("offset") ?? "0"));
const includeAlreadyHasSubs = req.nextUrl.searchParams.get("all") === "1";
const where = includeAlreadyHasSubs
? `i.has_video = 1 AND i.code IS NOT NULL AND i.deleted_at IS NULL AND i.parent_image_id IS NULL`
: `i.has_video = 1 AND i.has_subtitle = 0 AND i.code IS NOT NULL AND i.deleted_at IS NULL AND i.parent_image_id IS NULL`;
const rows = rawDb.prepare(`
SELECT i.id, i.code, i.title, i.thumb_path
FROM images i
WHERE ${where}
ORDER BY UPPER(i.code) ASC
LIMIT ? OFFSET ?
`).all(limit, offset) as CandidateRow[];
const totalRow = rawDb.prepare(`
SELECT COUNT(*) AS n FROM images i WHERE ${where}
`).get() as { n: number };
return NextResponse.json({
candidates: rows.map((r) => ({
id: r.id,
code: r.code,
title: r.title,
thumbPath: r.thumb_path,
})),
total: totalRow.n,
});
}
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { cancelJob } from "@/lib/whisperjav/queue";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { id } = await ctx.params;
const ok = cancelJob(id);
if (!ok) return NextResponse.json({ error: "Not found or not cancellable" }, { status: 404 });
return NextResponse.json({ ok: true });
}
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "node:fs/promises";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { getJob, estimateRealtimeMultiplier } from "@/lib/whisperjav/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const LOG_TAIL_LINES = 50;
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const { id } = await ctx.params;
const job = getJob(id);
if (!job) return NextResponse.json({ error: "Not found" }, { status: 404 });
let logTail: string[] = [];
try {
const raw = await fs.readFile(job.logPath, "utf8");
const lines = raw.split(/\r?\n/);
logTail = lines.slice(-LOG_TAIL_LINES - 1).filter(Boolean);
} catch { /* log may not exist yet */ }
// ETA: per-mode multiplier from history × video duration elapsed.
// Returns null when we can't compute (no duration / not running yet).
let etaSec: number | null = null;
if (
(job.status === "queued" || job.status === "running") &&
job.videoDurationSec && job.videoDurationSec > 0 &&
job.mode
) {
const multiplier = estimateRealtimeMultiplier(job.mode);
const totalProjected = job.videoDurationSec * multiplier;
const start = job.startedAt ?? job.enqueuedAt;
const elapsedSec = (Date.now() - start) / 1000;
etaSec = Math.max(0, totalProjected - elapsedSec);
}
return NextResponse.json({ ...job, logTail, etaSec });
}
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { enqueueJob, cancelAllQueued } from "@/lib/whisperjav/queue";
import { rawDb } from "@/lib/db/client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Enqueue WhisperJAV for many codes at once. Each code becomes a
* separate row in whisperjav_jobs; the single-worker loop processes
* them sequentially. Codes that already have a generated subtitle
* are skipped (alreadyExists), not failed. */
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const body = await req.json().catch(() => ({}));
const rawCodes = Array.isArray(body.codes) ? body.codes : [];
const codes = rawCodes
.filter((c: unknown): c is string => typeof c === "string" && c.trim().length > 0)
.map((c: string) => c.trim());
if (codes.length === 0) {
return NextResponse.json({ enqueued: 0, skipped: 0, errors: [] });
}
let enqueued = 0;
let skipped = 0;
const errors: Array<{ code: string; error: string }> = [];
for (const code of codes) {
try {
// Always part 0 for batch — multi-part videos are uncommon and
// the user can hit individual codes via the player picker for
// those edge cases.
const result = await enqueueJob({ code, partIdx: 0, overwrite: false });
if ("alreadyExists" in result) skipped++;
else enqueued++;
} catch (e) {
errors.push({ code, error: (e as Error).message });
}
}
return NextResponse.json({ enqueued, skipped, errors });
}
/** Cancel every queued (not-yet-running) job. Useful when the user
* wants to stop a batch mid-flight. The currently-running job is
* left alone — kill it via the per-job cancel endpoint. */
export async function DELETE(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const cancelled = cancelAllQueued();
return NextResponse.json({ cancelled });
}
/** Lightweight queue-state probe used by the batch UI: how many jobs
* are queued/running right now, plus the active row's id. */
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const queued = (rawDb
.prepare(`SELECT COUNT(*) AS n FROM whisperjav_jobs WHERE status = 'queued'`)
.get() as { n: number }).n;
const running = rawDb
.prepare(`SELECT id, code, started_at, stage, stage_index, stage_total FROM whisperjav_jobs WHERE status = 'running' ORDER BY started_at DESC LIMIT 1`)
.get() as { id: string; code: string; started_at: number | null; stage: string | null; stage_index: number | null; stage_total: number | null } | undefined;
return NextResponse.json({ queued, running: running ?? null });
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { enqueueJob, clearAllJobHistory, runRetentionSweep } from "@/lib/whisperjav/queue";
import { listJobsForCode } from "@/lib/whisperjav/db";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const body = await req.json().catch(() => ({}));
const code = typeof body.code === "string" ? body.code.trim() : "";
const rawPartIdx = typeof body.partIdx === "number" && Number.isFinite(body.partIdx) ? body.partIdx : 0;
const partIdx = Math.max(0, Math.floor(rawPartIdx));
const overwrite = body.overwrite === true;
if (!code) return NextResponse.json({ error: "Missing code" }, { status: 400 });
try {
const result = await enqueueJob({ code, partIdx, overwrite });
if ("alreadyExists" in result) {
return NextResponse.json(result, { status: 409 });
}
return NextResponse.json(result, { status: 202 });
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const code = req.nextUrl.searchParams.get("code") ?? "";
if (!code) return NextResponse.json({ jobs: [] });
const jobs = listJobsForCode(code, 5);
return NextResponse.json({ jobs });
}
/** Clear-all-history. Wipes every non-running row + every temp dir. */
export async function DELETE(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const result = await clearAllJobHistory();
return NextResponse.json(result);
}
/** Manual retention sweep trigger. */
export async function PATCH(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const result = await runRetentionSweep();
return NextResponse.json(result);
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { assertLocalRequest } from "@/lib/api/localOnly";
import { verifyCli, autoDetectCli } from "@/lib/whisperjav/spawn";
import { getAppSetting } from "@/lib/db/appSettings";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const blocked = assertLocalRequest(req);
if (blocked) return blocked;
const body = await req.json().catch(() => ({}));
const explicit = typeof body.path === "string" ? body.path.trim() : "";
const autodetect = body.autodetect === true;
let cliPath = explicit;
if (!cliPath && !autodetect) {
cliPath = (getAppSetting("whisperjav").cliPath ?? "").trim();
}
if (!cliPath) {
const detected = await autoDetectCli();
if (!detected) {
return NextResponse.json({ ok: false, error: "whisperjav not found on PATH" });
}
cliPath = detected;
}
const result = await verifyCli(cliPath);
return NextResponse.json(result);
}
+119
View File
@@ -0,0 +1,119 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Trash2 } from "lucide-react";
import { getTagCategoryBySlug, listTagsInCategory, listAllTags } from "@/lib/db/queries";
import { deleteTagCategory, renameTagCategory } from "@/app/actions/tagCategories";
import { EntityRenameInline } from "@/components/entities/EntityRenameInline";
import { CategoryTagAssigner } from "@/components/categories/CategoryTagAssigner";
import { CategoryCoverPanel } from "@/components/categories/CategoryCoverPanel";
export const dynamic = "force-dynamic";
export default async function CategoryDetailPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const cat = getTagCategoryBySlug(decodeURIComponent(slug));
if (!cat) notFound();
const tags = listTagsInCategory(cat.id);
// All tags, with their current category, so the assigner can let the
// user reassign uncategorised or differently-categorised tags into
// this one.
const allTags = listAllTags("az");
const remove = async () => {
"use server";
await deleteTagCategory(cat.id);
};
const rename = async (name: string) => {
"use server";
return await renameTagCategory(cat.id, name);
};
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<Link href="/category" className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] mb-4">
<ArrowLeft className="w-4 h-4" /> All categories
</Link>
<div className="flex items-start justify-between gap-6 mb-6">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span
className="w-4 h-4 rounded-full shrink-0"
style={{ background: cat.color ?? "var(--color-fg-muted)" }}
/>
<h1 className="text-3xl font-semibold tracking-tight truncate">{cat.name}</h1>
<EntityRenameInline
initialName={cat.name}
onRename={rename}
redirectBase="/category/"
redirectKey="slug"
/>
<form action={remove}>
<button className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-lg glass text-[var(--color-fg-muted)] hover:text-[var(--color-coral)] hover:border-[var(--color-coral)]/30">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</form>
</div>
{cat.description && <p className="text-[var(--color-fg-dim)] mt-2">{cat.description}</p>}
<p className="text-sm text-[var(--color-fg-muted)] mt-1">{tags.length} tag{tags.length === 1 ? "" : "s"} in this category</p>
</div>
</div>
<section className="mb-6">
<h2 className="text-sm uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">Cover art</h2>
<CategoryCoverPanel
categoryId={cat.id}
categoryName={cat.name}
categoryColor={cat.color}
portrait={{
path: cat.coverPortraitPath,
zoom: cat.coverPortraitZoom,
offsetX: cat.coverPortraitOffsetX,
offsetY: cat.coverPortraitOffsetY,
}}
landscape={{
path: cat.coverLandscapePath,
zoom: cat.coverLandscapeZoom,
offsetX: cat.coverLandscapeOffsetX,
offsetY: cat.coverLandscapeOffsetY,
}}
/>
</section>
<section className="mb-6">
<h2 className="text-sm uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">Member tags</h2>
{tags.length === 0 ? (
<div className="glass rounded-2xl p-8 text-center text-sm text-[var(--color-fg-dim)]">
No tags assigned yet. Use the picker below to add some.
</div>
) : (
<div className="flex flex-wrap gap-2">
{tags.map((t) => (
<Link
key={t.id}
href={`/tag/${encodeURIComponent(t.name)}`}
className="flex items-center gap-2 px-3 py-1.5 rounded-full glass glass-hover text-sm"
>
<span className="text-[var(--color-violet)]">{t.name}</span>
<span className="text-xs font-mono text-[var(--color-fg-muted)]">{t.count}</span>
</Link>
))}
</div>
)}
</section>
<section>
<h2 className="text-sm uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-2">Assign tags</h2>
<CategoryTagAssigner
categoryId={cat.id}
tags={allTags.map((t) => ({
id: t.id,
name: t.name,
count: t.count,
currentCategoryId: t.categoryId,
currentCategoryName: t.categoryName,
}))}
/>
</section>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
import Link from "next/link";
import { Layers, Plus, ArrowDownAZ, Hash, RectangleVertical, RectangleHorizontal } from "lucide-react";
import { listAllTagCategories, type CategorySort } from "@/lib/db/queries";
import { createTagCategoryAction } from "@/app/actions/tagCategories";
import { CategoryGridCard } from "@/components/categories/CategoryGridCard";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
type View = "portrait" | "landscape";
export default async function CategoriesPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const sort: CategorySort = sp.sort === "count" ? "count" : "az";
const view: View = sp.view === "landscape" ? "landscape" : "portrait";
const qs = (overrides: Partial<{ sort: CategorySort; view: View }>) => {
const params = new URLSearchParams();
const finalSort = overrides.sort ?? sort;
const finalView = overrides.view ?? view;
if (finalSort === "count") params.set("sort", "count");
if (finalView === "landscape") params.set("view", "landscape");
const s = params.toString();
return s ? `/category?${s}` : "/category";
};
const cats = listAllTagCategories(sort);
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<div className="flex items-start justify-between gap-6 mb-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Categories</h1>
<p className="text-[var(--color-fg-dim)] mt-1 max-w-prose">
Umbrellas that group related tags. A category like <span className="font-mono text-[var(--color-fg)]">BDSM</span> can collect{" "}
<span className="font-mono text-[var(--color-fg)]">bondage</span>,{" "}
<span className="font-mono text-[var(--color-fg)]">shibari</span>,{" "}
<span className="font-mono text-[var(--color-fg)]">cuffs</span>, etc.
Each tag belongs to at most one category.
</p>
<p className="text-sm text-[var(--color-fg-muted)] mt-1">{cats.length} categor{cats.length === 1 ? "y" : "ies"}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
<Link
href={qs({ sort: "az" })}
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
sort === "az"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Sort A → Z"
>
<ArrowDownAZ className="w-3.5 h-3.5" /> A-Z
</Link>
<Link
href={qs({ sort: "count" })}
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
sort === "count"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Sort by cover count"
>
<Hash className="w-3.5 h-3.5" /> Count
</Link>
</div>
<form action={createTagCategoryAction} className="flex items-center gap-2">
<input
name="name"
placeholder="New category"
required
maxLength={64}
className="glass rounded-lg px-3 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] w-56"
/>
<button className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium">
<Plus className="w-4 h-4" /> Create
</button>
</form>
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
<Link
href={qs({ view: "portrait" })}
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
view === "portrait"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Portrait layout"
>
<RectangleVertical className="w-3.5 h-3.5" /> P
</Link>
<Link
href={qs({ view: "landscape" })}
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
view === "landscape"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Landscape layout"
>
<RectangleHorizontal className="w-3.5 h-3.5" /> L
</Link>
</div>
</div>
</div>
{cats.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<Layers className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">No categories yet. Create one above to start grouping tags.</p>
</div>
) : view === "portrait" ? (
// Target 7 per row at full desktop width; scale down responsively.
<div key="portrait" className="fade-in grid gap-4 grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-7">
{cats.map((c) => (
<CategoryGridCard
key={c.id}
id={c.id}
slug={c.slug}
name={c.name}
color={c.color}
description={c.description}
tagCount={c.tagCount}
imageCount={c.imageCount}
view="portrait"
portrait={{ path: c.coverPortraitPath, zoom: c.coverPortraitZoom, offsetX: c.coverPortraitOffsetX, offsetY: c.coverPortraitOffsetY }}
landscape={{ path: c.coverLandscapePath, zoom: c.coverLandscapeZoom, offsetX: c.coverLandscapeOffsetX, offsetY: c.coverLandscapeOffsetY }}
/>
))}
</div>
) : (
// Landscape: 3 per row at desktop; 1-2 on smaller screens.
<div key="landscape" className="fade-in grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{cats.map((c) => (
<CategoryGridCard
key={c.id}
id={c.id}
slug={c.slug}
name={c.name}
color={c.color}
description={c.description}
tagCount={c.tagCount}
imageCount={c.imageCount}
view="landscape"
portrait={{ path: c.coverPortraitPath, zoom: c.coverPortraitZoom, offsetX: c.coverPortraitOffsetX, offsetY: c.coverPortraitOffsetY }}
landscape={{ path: c.coverLandscapePath, zoom: c.coverLandscapeZoom, offsetX: c.coverLandscapeOffsetX, offsetY: c.coverLandscapeOffsetY }}
/>
))}
</div>
)}
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Trash2 } from "lucide-react";
import { getCollectionBySlug, listImages } from "@/lib/db/queries";
import { MasonryGrid } from "@/components/grid/MasonryGrid";
import { RegisterVisible } from "@/components/select/RegisterVisible";
import { FilterBar } from "@/components/grid/FilterBar";
import { UploadCard } from "@/components/ingest/UploadCard";
import { deleteCollection, renameCollection } from "@/app/actions/collections";
import { EntityRenameInline } from "@/components/entities/EntityRenameInline";
import { ReorderableCollectionGrid } from "@/components/collections/ReorderableCollectionGrid";
import { resolveSort } from "@/lib/sortServer";
import { parseFilterCriteria, statusToFlags } from "@/lib/filters";
export const dynamic = "force-dynamic";
export default async function CollectionPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
const sp = await searchParams;
const c = getCollectionBySlug(decodeURIComponent(slug));
if (!c) notFound();
const urlSort = typeof sp.sort === "string" ? sp.sort : undefined;
// Resolve only when user explicitly chose; otherwise keep manual position default.
const sort = urlSort ? await resolveSort(urlSort) : undefined;
const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined;
const criteria = parseFilterCriteria(sp);
const items = listImages({
collectionId: c.id,
sort,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
});
const remove = async () => {
"use server";
await deleteCollection(c.id);
};
const rename = async (name: string) => {
"use server";
return await renameCollection(c.id, name);
};
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<Link href="/collection" className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] mb-4">
<ArrowLeft className="w-4 h-4" /> Collection
</Link>
<div className="flex items-start justify-between gap-6 mb-6">
<div className="min-w-0">
<div className="flex items-center gap-2">
<h1 className="text-3xl font-semibold tracking-tight truncate">{c.name}</h1>
<EntityRenameInline
initialName={c.name}
onRename={rename}
redirectBase="/collection/"
redirectKey="slug"
/>
<form action={remove}>
<button className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-lg glass text-[var(--color-fg-muted)] hover:text-[var(--color-coral)] hover:border-[var(--color-coral)]/30">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</form>
</div>
{c.description && <p className="text-[var(--color-fg-dim)] mt-1">{c.description}</p>}
<p className="text-sm text-[var(--color-fg-muted)] mt-1">{items.length} Item{items.length === 1 ? "" : "s"}</p>
</div>
<div className="w-[300px] flex-shrink-0">
<UploadCard autoAssign={{ collectionId: c.id }} />
</div>
</div>
<FilterBar current={{ kind: "collection", id: c.id, name: c.name }} criteria={criteria} sort={sort} />
{items.length === 0 ? (
<div className="glass rounded-2xl p-card text-center text-[var(--color-fg-dim)]">
Empty. Add images from any image detail page.
</div>
) : (
<>
<RegisterVisible ids={items.map((i) => i.id)} />
{urlSort ? (
// User overrode the default order via the sort menu — disable
// drag-reorder since drag-position vs sorted-position would
// contradict each other.
<MasonryGrid images={items} />
) : (
<ReorderableCollectionGrid images={items} collectionId={c.id} />
)}
</>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import Link from "next/link";
import { listAllCollections } from "@/lib/db/queries";
import { createCollectionAction } from "@/app/actions/collections";
import { FolderHeart, Plus, RectangleVertical, RectangleHorizontal } from "lucide-react";
import { ReorderableCollectionsIndex } from "@/components/collections/ReorderableCollectionsIndex";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
type View = "portrait" | "landscape";
export default async function CollectionsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const view: View = sp.view === "portrait" ? "portrait" : "landscape";
const items = listAllCollections();
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Collection</h1>
<p className="text-[var(--color-fg-dim)] mt-1">{items.length} total</p>
</div>
<div className="flex items-center gap-2">
<form action={createCollectionAction} className="flex items-center gap-2">
<input
name="name"
placeholder="New Collection Name"
required
className="glass rounded-lg px-3 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] w-56"
/>
<button className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium">
<Plus className="w-4 h-4" /> Create
</button>
</form>
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
<Link
href="/collection"
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
view === "landscape"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Landscape layout"
>
<RectangleHorizontal className="w-3.5 h-3.5" /> L
</Link>
<Link
href="/collection?view=portrait"
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
view === "portrait"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Portrait layout"
>
<RectangleVertical className="w-3.5 h-3.5" /> P
</Link>
</div>
</div>
</div>
{items.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<FolderHeart className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">No collections yet.</p>
</div>
) : (
<ReorderableCollectionsIndex items={items} view={view} />
)}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { rawDb } from "@/lib/db/client";
import { listImages } from "@/lib/db/queries";
import { MasonryGrid } from "@/components/grid/MasonryGrid";
import { RegisterVisible } from "@/components/select/RegisterVisible";
import { FilterBar } from "@/components/grid/FilterBar";
import { resolveSort } from "@/lib/sortServer";
import { parseFilterCriteria, statusToFlags } from "@/lib/filters";
export const dynamic = "force-dynamic";
export default async function GenrePage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
const sp = await searchParams;
const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined);
const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined;
const criteria = parseFilterCriteria(sp);
const g = rawDb.prepare(`SELECT id, name, slug FROM genres WHERE slug = ?`).get(decodeURIComponent(slug)) as { id: number; name: string; slug: string } | undefined;
if (!g) notFound();
const items = listImages({
genreId: g.id,
sort,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
});
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<Link href="/genres" className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] mb-4">
<ArrowLeft className="w-4 h-4" /> All genres
</Link>
<div className="mb-4">
<h1 className="text-3xl font-semibold tracking-tight mb-1 text-[var(--color-cyan)]">#{g.name}</h1>
<p className="text-[var(--color-fg-dim)]">{items.length} cover{items.length === 1 ? "" : "s"}</p>
</div>
<FilterBar current={{ kind: "genre", name: g.name }} criteria={criteria} sort={sort} />
<RegisterVisible ids={items.map((i) => i.id)} />
<MasonryGrid images={items} />
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import { listAllGenres } from "@/lib/db/queries";
import { Hash } from "lucide-react";
export const dynamic = "force-dynamic";
export default function GenresPage() {
const items = listAllGenres();
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<div className="mb-6">
<h1 className="text-3xl font-semibold tracking-tight">Genres</h1>
<p className="text-[var(--color-fg-dim)] mt-1">{items.length} total</p>
</div>
{items.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<Hash className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">No genres yet.</p>
</div>
) : (
<div className="flex flex-wrap gap-2">
{items.map((g) => (
<Link key={g.id} href={`/genres/${g.slug}`} className="flex items-center gap-2 px-3 py-1.5 rounded-full glass glass-hover text-sm">
<span className="text-[var(--color-cyan)]">{g.name}</span>
<span className="text-xs font-mono text-[var(--color-fg-muted)]">{g.count}</span>
</Link>
))}
</div>
)}
</div>
);
}
+199
View File
@@ -0,0 +1,199 @@
@import "tailwindcss" source(none);
/* Tailwind v4's auto-content-discovery picks up files in .next/types,
* which Next regenerates on every dev rebuild. That triggered a CSS
* regen → HMR rebuild → routes regen → loop. Explicit @source decls
* (with the `source(none)` modifier above to disable auto-discovery)
* scope scanning to just our project source. */
@source "../app/**/*.{ts,tsx,mdx}";
@source "../components/**/*.{ts,tsx,mdx}";
@source "../lib/**/*.{ts,tsx}";
@theme {
--color-bg-0: oklch(0.13 0.025 280);
--color-bg-1: oklch(0.17 0.04 285);
--color-bg-2: oklch(0.22 0.05 290);
--color-glass: color-mix(in oklch, white 6%, transparent);
--color-glass-strong: color-mix(in oklch, white 10%, transparent);
--color-glass-border: color-mix(in oklch, white 14%, transparent);
--color-glass-border-strong: color-mix(in oklch, white 22%, transparent);
--color-fg: oklch(0.97 0.01 280);
--color-fg-dim: oklch(0.72 0.025 280);
--color-fg-muted: oklch(0.55 0.02 280);
--color-cyan: oklch(0.82 0.16 200);
--color-cyan-glow: oklch(0.78 0.18 200);
--color-accent-primary: var(--color-cyan);
--color-accent-primary-glow: var(--color-cyan-glow);
--color-violet: oklch(0.72 0.22 305);
--color-violet-glow: oklch(0.68 0.24 305);
--color-mint: oklch(0.80 0.16 155);
--color-coral: oklch(0.72 0.20 25);
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, "SF Mono", Menlo, monospace;
--shadow-glow-cyan: 0 0 32px color-mix(in oklch, var(--color-cyan-glow) 35%, transparent);
--shadow-glow-violet: 0 0 32px color-mix(in oklch, var(--color-violet-glow) 35%, transparent);
/* ---------------------------------------------------------------
Detail / panel rhythm tokens — the "snug+1" system.
Apply via Tailwind utilities: e.g. p-card, gap-card, gap-section,
gap-chip, mb-label, gap-stat. Do NOT use raw px values for these
concepts in app code — keep the source of truth here.
--------------------------------------------------------------- */
--spacing-card: 15px; /* card interior padding */
--spacing-card-gap: 9px; /* gap between sibling cards */
--spacing-section: 15px; /* gap between sections inside a card */
--spacing-chip: 7px; /* gap inside chip clusters / pill grids / button bars */
--spacing-label: 7px; /* gap from a label-mono header to its content */
--spacing-stat: 5px; /* gap from a hero-stat label to its big number */
--spacing-stat-gap: 13px; /* horizontal gap between hero-stat columns */
}
@layer base {
html, body { height: 100%; }
html { scrollbar-gutter: stable; }
body {
background:
radial-gradient(ellipse 80% 60% at 20% 0%, color-mix(in oklch, var(--color-violet) 18%, transparent) 0%, transparent 60%),
radial-gradient(ellipse 70% 60% at 100% 30%, color-mix(in oklch, var(--color-cyan) 14%, transparent) 0%, transparent 55%),
linear-gradient(180deg, var(--color-bg-0) 0%, var(--color-bg-1) 100%);
background-attachment: fixed;
color: var(--color-fg);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
::selection {
background: color-mix(in oklch, var(--color-cyan) 40%, transparent);
color: white;
}
* { border-color: var(--color-glass-border); }
/* scrollbar — always visible thumb */
html {
scrollbar-color: color-mix(in oklch, var(--color-fg-dim) 70%, transparent) color-mix(in oklch, var(--color-fg-dim) 12%, transparent);
}
::-webkit-scrollbar { width: 12px; height: 12px; background: transparent; }
::-webkit-scrollbar-track {
background: color-mix(in oklch, var(--color-fg-dim) 12%, transparent);
}
::-webkit-scrollbar-thumb {
background: color-mix(in oklch, var(--color-fg-dim) 70%, transparent);
border-radius: 999px;
border: 2px solid transparent;
background-clip: padding-box;
transition: background-color 200ms ease;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklch, var(--color-fg-dim) 90%, transparent);
background-clip: padding-box;
}
}
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes play-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--color-cyan) 50%, transparent); }
100% { box-shadow: 0 0 0 14px color-mix(in oklch, var(--color-cyan) 0%, transparent); }
}
/* While the video player modal is open, kill native HTML5 drag site-wide.
The native <video> controls' shadow-DOM sliders sometimes let the drag
gesture escape onto whatever's beneath the cursor (usually a card
image), which the browser then starts dragging out. The rule reverses
when the modal unmounts and removes the class. */
body.video-player-active * {
-webkit-user-drag: none;
user-drag: none;
}
@layer components {
.fade-in { animation: fade-in var(--fade-duration) ease both; }
html[data-fade="off"] .fade-in { animation: none; }
.glass {
background: var(--color-glass);
backdrop-filter: blur(20px) saturate(140%);
-webkit-backdrop-filter: blur(20px) saturate(140%);
border: 1px solid var(--color-glass-border);
}
.glass-strong {
background: var(--color-glass-strong);
backdrop-filter: blur(24px) saturate(150%);
-webkit-backdrop-filter: blur(24px) saturate(150%);
border: 1px solid var(--color-glass-border-strong);
}
.glass-hover {
transition: border-color 200ms ease, box-shadow 250ms ease, transform 200ms ease;
}
.glass-hover:hover {
border-color: color-mix(in oklch, var(--color-cyan) 50%, var(--color-glass-border));
box-shadow: var(--shadow-glow-cyan);
}
.text-gradient-accent {
background: linear-gradient(120deg, var(--color-cyan) 0%, var(--color-violet) 100%);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
}
.grid-noise {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.4'/></svg>");
opacity: 0.04;
pointer-events: none;
}
.cover-hero-frame {
transition:
border-color 180ms ease,
box-shadow 220ms ease;
}
.cover-hero-hover {
isolation: isolate;
overflow: hidden;
border-radius: inherit;
transition:
filter 220ms ease;
}
.cover-hero-hover::after {
content: "";
position: absolute;
inset: 0;
z-index: 6;
pointer-events: none;
opacity: 0;
border-radius: inherit;
background:
radial-gradient(
ellipse at center,
transparent 30%,
rgba(0, 0, 0, 0.34) 74%,
rgba(0, 0, 0, 0.68) 100%
);
transition: opacity 180ms ease;
}
@media (hover: hover) {
.cover-hero-frame:hover {
border-color: color-mix(in oklch, var(--color-accent-primary) 76%, var(--color-glass-border));
box-shadow:
0 0 0 1px color-mix(in oklch, var(--color-accent-primary) 34%, transparent),
0 0 30px color-mix(in oklch, var(--color-accent-primary-glow) 40%, transparent);
}
.group:hover .cover-hero-hover {
filter: saturate(1.06);
}
.group:hover .cover-hero-hover::after {
opacity: 1;
}
}
}
+33
View File
@@ -0,0 +1,33 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { getImageIdByCode, getNeighborImageIds } from "@/lib/db/queries";
import { ImageNav } from "@/components/image/ImageNav";
export default async function IdLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ code: string }>;
}) {
const { code } = await params;
const decoded = decodeURIComponent(code);
const id = getImageIdByCode(decoded);
const neighbors = id != null ? getNeighborImageIds(id) : { prev: null, next: null };
return (
<div className="max-w-[1600px] mx-auto px-6 pt-6">
<div className="flex items-center justify-between mb-4">
<Link href="/" className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]">
<ArrowLeft className="w-4 h-4" /> Back to library
</Link>
<ImageNav
prev={neighbors.prev}
next={neighbors.next}
randomEndpoint={`/image/random${id != null ? `?exclude=${id}` : ""}`}
/>
</div>
{children}
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { notFound } from "next/navigation";
import { getImageIdByCode } from "@/lib/db/queries";
import { ImageDetailView } from "@/components/image/ImageDetailView";
export const dynamic = "force-dynamic";
export default async function IdPage({ params }: { params: Promise<{ code: string }> }) {
const { code } = await params;
const decoded = decodeURIComponent(code);
const id = getImageIdByCode(decoded);
if (id == null) notFound();
return <ImageDetailView imageId={id} />;
}
+34
View File
@@ -0,0 +1,34 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { getNeighborImageIds } from "@/lib/db/queries";
import { ImageNav } from "@/components/image/ImageNav";
export default async function ImageLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const numId = Number(id);
const neighbors = Number.isFinite(numId)
? getNeighborImageIds(numId)
: { prev: null, next: null };
return (
<div className="max-w-[1600px] mx-auto px-6 pt-6">
<div className="flex items-center justify-between mb-4">
<Link href="/" className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]">
<ArrowLeft className="w-4 h-4" /> Back to library
</Link>
<ImageNav
prev={neighbors.prev}
next={neighbors.next}
randomEndpoint={`/image/random?exclude=${numId}`}
/>
</div>
{children}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
export default function Loading() {
return (
<div className="pb-6">
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(380px,460px)] gap-6">
<div className="aspect-[3/4] glass rounded-2xl animate-pulse" />
<aside className="space-y-4">
<div className="glass rounded-2xl p-4 h-28 animate-pulse" />
<div className="glass rounded-2xl p-4 h-32 animate-pulse" />
<div className="grid grid-cols-3 gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="glass rounded-lg h-14 animate-pulse" />
))}
</div>
</aside>
</div>
<div className="glass rounded-2xl mt-6 h-40 animate-pulse" />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { notFound } from "next/navigation";
import { ImageDetailView } from "@/components/image/ImageDetailView";
import { getImageDetail } from "@/lib/db/queries";
export const dynamic = "force-dynamic";
export default async function ImagePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const numId = Number(id);
if (!Number.isFinite(numId)) notFound();
if (!getImageDetail(numId)) notFound();
return <ImageDetailView imageId={numId} />;
}
+13
View File
@@ -0,0 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { getRandomImage } from "@/lib/db/queries";
import { coverHref } from "@/lib/utils";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const exclude = Number(req.nextUrl.searchParams.get("exclude")) || undefined;
const img = getRandomImage(exclude);
if (!img) return NextResponse.redirect(new URL("/", req.url));
return NextResponse.redirect(new URL(coverHref(img), req.url));
}
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { getLabelBySlug, listImages } from "@/lib/db/queries";
import { MasonryGrid } from "@/components/grid/MasonryGrid";
import { RegisterVisible } from "@/components/select/RegisterVisible";
import { FilterBar } from "@/components/grid/FilterBar";
import { resolveSort } from "@/lib/sortServer";
import { parseFilterCriteria, statusToFlags } from "@/lib/filters";
export const dynamic = "force-dynamic";
export default async function LabelPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
const sp = await searchParams;
const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined);
const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined;
const criteria = parseFilterCriteria(sp);
const label = getLabelBySlug(decodeURIComponent(slug));
if (!label) notFound();
const items = listImages({
labelId: label.id,
sort,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
});
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<Link href="/labels" className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] mb-4">
<ArrowLeft className="w-4 h-4" /> All labels
</Link>
<div className="mb-4">
<h1 className="text-3xl font-semibold tracking-tight mb-1">{label.name}</h1>
<p className="text-[var(--color-fg-dim)]">{items.length} cover{items.length === 1 ? "" : "s"}</p>
</div>
<FilterBar current={{ kind: "label", name: label.name }} criteria={criteria} sort={sort} />
<RegisterVisible ids={items.map((i) => i.id)} />
<MasonryGrid images={items} />
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Link from "next/link";
import { listAllLabels } from "@/lib/db/queries";
import { Tag } from "lucide-react";
export const dynamic = "force-dynamic";
export default function LabelsPage() {
const items = listAllLabels();
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<div className="mb-6">
<h1 className="text-3xl font-semibold tracking-tight">Labels</h1>
<p className="text-[var(--color-fg-dim)] mt-1">{items.length} total</p>
</div>
{items.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<Tag className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">No labels yet.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{items.map((label) => (
<Link
key={label.id}
href={`/labels/${label.slug}`}
className="glass glass-hover rounded-xl p-4 flex items-center justify-between gap-3"
>
<span className="font-medium truncate">{label.name}</span>
<span className="text-xs font-mono text-[var(--color-fg-muted)] tabular-nums">{label.count}</span>
</Link>
))}
</div>
)}
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import "./globals.css";
import { TopNav } from "@/components/shell/TopNav";
import path from "node:path";
import { SelectionProvider } from "@/components/select/SelectionProvider";
import { SelectionBar } from "@/components/select/SelectionBar";
import { UndoDeleteToastProvider } from "@/components/select/UndoDeleteToast";
import { SettingsProvider } from "@/components/settings/SettingsProvider";
import { SettingsPanelProvider } from "@/components/settings/SettingsPanelProvider";
import { SettingsPanel } from "@/components/settings/SettingsPanel";
import { TrashPanelProvider } from "@/components/trash/TrashPanelProvider";
import { TrashPanel } from "@/components/trash/TrashPanel";
import { WatchQueueProvider } from "@/components/queue/WatchQueueProvider";
import { QueuePanelProvider } from "@/components/queue/QueuePanelProvider";
import { QueuePanel } from "@/components/queue/QueuePanel";
import { VideoIndexProvider } from "@/components/video/VideoIndexProvider";
import { getAllAppSettings } from "@/lib/db/appSettings";
import { libraryStats, listTrashedImages } from "@/lib/db/queries";
import { BRAND } from "@/lib/brand";
export const metadata: Metadata = {
title: BRAND.name,
description: BRAND.description,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
const settings = getAllAppSettings();
const stats = libraryStats();
const panelData = {
defaultSort: settings.defaultSort,
stats,
libraryRoot: path.join(process.cwd(), "library"),
dbPath: path.join(process.cwd(), "data", "library.db"),
};
const trashData = {
images: listTrashedImages(),
retentionDays: settings.trashRetentionDays,
};
return (
<html
lang="en"
className={`${GeistSans.variable} ${GeistMono.variable}`}
data-fade={settings.fadeTransitions ? "on" : "off"}
style={{ "--fade-duration": `${settings.fadeDurationMs}ms` } as React.CSSProperties}
>
<body className="min-h-screen relative">
<div className="grid-noise fixed inset-0 -z-10" aria-hidden />
<SettingsProvider initial={settings}>
<SettingsPanelProvider>
<TrashPanelProvider>
<UndoDeleteToastProvider>
<SelectionProvider>
<WatchQueueProvider>
<QueuePanelProvider>
<VideoIndexProvider>
<TopNav />
<main className="relative pb-6">{children}</main>
<SelectionBar />
<SettingsPanel data={panelData} />
<TrashPanel data={trashData} />
<QueuePanel />
</VideoIndexProvider>
</QueuePanelProvider>
</WatchQueueProvider>
</SelectionProvider>
</UndoDeleteToastProvider>
</TrashPanelProvider>
</SettingsPanelProvider>
</SettingsProvider>
</body>
</html>
);
}
+146
View File
@@ -0,0 +1,146 @@
import { listImages, countImages, libraryStats, libraryLetterCounts } from "@/lib/db/queries";
import { LibraryGrid } from "@/components/grid/LibraryGrid";
import { getAppSetting } from "@/lib/db/appSettings";
import { UploadCard } from "@/components/ingest/UploadCard";
import { RegisterVisible } from "@/components/select/RegisterVisible";
import { FilterBar } from "@/components/grid/FilterBar";
import { LetterBar } from "@/components/grid/LetterBar";
import type { LibraryView } from "@/components/grid/ViewToggle";
import { resolveSort } from "@/lib/sortServer";
import { parseFilterCriteria, anyActive as hasAnyCriteria, statusToFlags } from "@/lib/filters";
import { Disc3 } from "lucide-react";
export const dynamic = "force-dynamic";
export default async function Home({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const criteria = parseFilterCriteria(sp);
const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined);
const rawLetter = (typeof sp.letter === "string" ? sp.letter : "").toUpperCase();
const letter = rawLetter === "#" ? "#" : (/^[A-Z]$/.test(rawLetter) ? rawLetter : null);
const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined;
const view: LibraryView = sp.view === "portrait" ? "portrait" : "landscape";
const anyActive = hasAnyCriteria(criteria) || letter != null || !!search;
// URL pagination — `page` is the anchor (1-based). Page size from
// user settings (Settings → Appearance → Items Per Page). Negative
// / non-numeric params clamp to 1.
const PAGE_SIZE = Math.max(25, Math.min(500, getAppSetting("coverPageSize") ?? 100));
const rawPage = typeof sp.page === "string" ? Number(sp.page) : NaN;
const page = Number.isFinite(rawPage) && rawPage >= 1 ? Math.floor(rawPage) : 1;
const offset = (page - 1) * PAGE_SIZE;
const filterOpts = {
sort,
letter: letter ?? undefined,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
};
const totalCount = countImages(filterOpts);
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
// Clamp out-of-range page back to last; cheap re-fetch since we only
// care about offset and we already have the count.
const effectivePage = Math.min(page, totalPages);
const effectiveOffset = (effectivePage - 1) * PAGE_SIZE;
const items = listImages({ ...filterOpts, limit: PAGE_SIZE, offset: effectiveOffset });
const stats = libraryStats();
const letterCounts = libraryLetterCounts({
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
search,
});
return (
<div className="max-w-[1600px] mx-auto px-6 py-8 fade-in">
<section className="mb-6 grid grid-cols-1 lg:grid-cols-[1fr_minmax(280px,360px)] gap-6 items-start">
<div>
<h1 className="text-4xl font-semibold tracking-tight">
Your <span className="text-gradient-accent">Cover Library</span>
</h1>
<p className="text-[var(--color-fg-dim)] mt-2 max-w-prose">
Drop cover images to import. Codes are parsed from filenames; metadata can be filled in
manually or seeded from a sibling <span className="font-mono">.nfo</span> file. Tag, collect,
rate, and search.
</p>
<div className="flex flex-wrap gap-6 mt-6 text-sm">
<Stat label="Covers" value={stats.images} />
<Stat label="Actresses" value={stats.actresses} />
<Stat label="Studios" value={stats.studios} />
<Stat label="Tags" value={stats.tags} />
<Stat label="Collections" value={stats.collections} />
</div>
</div>
<UploadCard />
</section>
<FilterBar current={{ kind: "all" }} criteria={criteria} sort={sort} view={view} />
<div className="my-6">
<LetterBar active={letter} counts={letterCounts} />
</div>
<RegisterVisible ids={items.map((i) => i.id)} />
{items.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<Disc3 className="w-10 h-10 mx-auto text-[var(--color-cyan)] mb-label" />
<h2 className="text-xl font-medium">
{anyActive ? "Nothing Matches" : "Nothing Here Yet"}
</h2>
<p className="text-[var(--color-fg-dim)] mt-1">
{anyActive ? "All filtered out — switch back to All." : "Drag a few covers above to get started."}
</p>
</div>
) : (
<div key={`${view}-${letter ?? "all"}-${effectivePage}`} className="fade-in">
<LibraryGrid
initialItems={items}
initialPage={effectivePage}
totalPages={totalPages}
totalCount={totalCount}
view={view}
infiniteScrollEnabled
/>
</div>
)}
</div>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div>
<div className="text-2xl font-mono font-semibold tabular-nums">{value}</div>
<div className="text-xs uppercase tracking-wider text-[var(--color-fg-muted)]">{label}</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { QueueView } from "@/components/queue/QueueView";
export const dynamic = "force-dynamic";
export default function QueuePage() {
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<QueueView />
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { Search as SearchIcon } from "lucide-react";
import { searchCovers } from "@/lib/db/queries";
import { MasonryGrid } from "@/components/grid/MasonryGrid";
import { RegisterVisible } from "@/components/select/RegisterVisible";
export const dynamic = "force-dynamic";
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string | string[] }>;
}) {
const { q } = await searchParams;
const query = (Array.isArray(q) ? q[0] ?? "" : q ?? "").trim();
const items = query ? searchCovers(query) : [];
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<div className="mb-6 flex items-center gap-3">
<SearchIcon className="w-6 h-6 text-[var(--color-cyan)]" />
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{query ? <>Results for <span className="text-gradient-accent">{query}</span></> : "Search"}
</h1>
<p className="text-sm text-[var(--color-fg-dim)] mt-0.5">
{query ? `${items.length} match${items.length === 1 ? "" : "es"}` : "Type a code, title, director or notes phrase in the top bar."}
</p>
</div>
</div>
{query && items.length === 0 && (
<div className="glass rounded-2xl p-card text-center text-[var(--color-fg-dim)]">
No covers match. Try a shorter or different query.
</div>
)}
{items.length > 0 && (
<>
<RegisterVisible ids={items.map((i) => i.id)} />
<MasonryGrid images={items} />
</>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Trash2 } from "lucide-react";
import { getSeriesBySlug, listImages } from "@/lib/db/queries";
import { MasonryGrid } from "@/components/grid/MasonryGrid";
import { RegisterVisible } from "@/components/select/RegisterVisible";
import { FilterBar } from "@/components/grid/FilterBar";
import { resolveSort } from "@/lib/sortServer";
import { deleteSeries, renameSeries } from "@/app/actions/entities";
import { EntityRenameInline } from "@/components/entities/EntityRenameInline";
import { parseFilterCriteria, statusToFlags } from "@/lib/filters";
export const dynamic = "force-dynamic";
export default async function SeriesPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
const sp = await searchParams;
const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined);
const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined;
const criteria = parseFilterCriteria(sp);
const s = getSeriesBySlug(decodeURIComponent(slug));
if (!s) notFound();
const items = listImages({
seriesId: s.id,
sort,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
});
const remove = async () => {
"use server";
await deleteSeries(s.id);
};
const rename = async (name: string) => {
"use server";
return await renameSeries(s.id, name);
};
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<Link href="/series" className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] mb-4">
<ArrowLeft className="w-4 h-4" /> All series
</Link>
<div className="mb-4">
<div className="flex items-center gap-2">
<h1 className="text-3xl font-semibold tracking-tight mb-1">{s.name}</h1>
<EntityRenameInline initialName={s.name} onRename={rename} redirectBase="/series/" />
<form action={remove}>
<button className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-lg glass text-[var(--color-fg-muted)] hover:text-[var(--color-coral)] hover:border-[var(--color-coral)]/30">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</form>
</div>
<p className="text-[var(--color-fg-dim)]">{items.length} cover{items.length === 1 ? "" : "s"}</p>
</div>
<FilterBar current={{ kind: "series", name: s.name }} criteria={criteria} sort={sort} />
<RegisterVisible ids={items.map((i) => i.id)} />
<MasonryGrid images={items} />
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import Link from "next/link";
import { listAllSeries } from "@/lib/db/queries";
import { createSeriesAction } from "@/app/actions/entities";
import { Film, Plus, ArrowDownAZ, Hash } from "lucide-react";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
export default async function SeriesIndexPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const sort: "az" | "count" = sp.sort === "count" ? "count" : "az";
const raw = listAllSeries();
const items = sort === "count"
? [...raw].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
: [...raw].sort((a, b) => a.name.localeCompare(b.name));
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Series</h1>
<p className="text-[var(--color-fg-dim)] mt-1">{items.length} total</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
<Link
href="/series"
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
sort === "az"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Sort A → Z"
>
<ArrowDownAZ className="w-3.5 h-3.5" /> A-Z
</Link>
<Link
href="/series?sort=count"
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
sort === "count"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Sort by cover count"
>
<Hash className="w-3.5 h-3.5" /> Count
</Link>
</div>
<form action={createSeriesAction} className="flex items-center gap-2">
<input
name="name"
placeholder="New series"
required
maxLength={120}
className="glass rounded-lg px-3 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] w-56"
/>
<button className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium">
<Plus className="w-4 h-4" /> Create
</button>
</form>
</div>
</div>
{items.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<Film className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">No series yet. Create one above or add from any cover.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{items.map((s) => (
<Link key={s.id} href={`/series/${s.slug}`} className="glass glass-hover rounded-xl p-4 flex items-center justify-between gap-3">
<span className="font-medium truncate">{s.name}</span>
<span className="text-xs font-mono text-[var(--color-fg-muted)] tabular-nums">{s.count}</span>
</Link>
))}
</div>
)}
</div>
);
}
+81
View File
@@ -0,0 +1,81 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Trash2 } from "lucide-react";
import { getStudioBySlug, listImages } from "@/lib/db/queries";
import { MasonryGrid } from "@/components/grid/MasonryGrid";
import { RegisterVisible } from "@/components/select/RegisterVisible";
import { FilterBar } from "@/components/grid/FilterBar";
import { resolveSort } from "@/lib/sortServer";
import { deleteStudio, renameStudio } from "@/app/actions/entities";
import { EntityRenameInline } from "@/components/entities/EntityRenameInline";
import { parseFilterCriteria, statusToFlags } from "@/lib/filters";
export const dynamic = "force-dynamic";
export default async function StudioPage({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { slug } = await params;
const sp = await searchParams;
const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined);
const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined;
const criteria = parseFilterCriteria(sp);
const s = getStudioBySlug(decodeURIComponent(slug));
if (!s) notFound();
const items = listImages({
studioId: s.id,
sort,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
});
const remove = async () => {
"use server";
await deleteStudio(s.id);
};
const rename = async (name: string) => {
"use server";
return await renameStudio(s.id, name);
};
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<Link href="/studios" className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] mb-4">
<ArrowLeft className="w-4 h-4" /> All studios
</Link>
<div className="mb-4">
<div className="flex items-center gap-2">
<h1 className="text-3xl font-semibold tracking-tight mb-1">{s.name}</h1>
<EntityRenameInline initialName={s.name} onRename={rename} redirectBase="/studios/" />
<form action={remove}>
<button className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-lg glass text-[var(--color-fg-muted)] hover:text-[var(--color-coral)] hover:border-[var(--color-coral)]/30">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</form>
</div>
<p className="text-[var(--color-fg-dim)]">{items.length} cover{items.length === 1 ? "" : "s"}</p>
</div>
<FilterBar current={{ kind: "studio", name: s.name }} criteria={criteria} sort={sort} />
<RegisterVisible ids={items.map((i) => i.id)} />
<MasonryGrid images={items} />
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import { listAllStudios } from "@/lib/db/queries";
import { createStudioAction } from "@/app/actions/entities";
import { Building2, Plus, ArrowDownAZ, Hash } from "lucide-react";
import { cn } from "@/lib/utils";
export const dynamic = "force-dynamic";
export default async function StudiosPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const sort: "az" | "count" = sp.sort === "count" ? "count" : "az";
const raw = listAllStudios();
const items = sort === "count"
? [...raw].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
: [...raw].sort((a, b) => a.name.localeCompare(b.name));
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Studios</h1>
<p className="text-[var(--color-fg-dim)] mt-1">{items.length} total</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
<Link
href="/studios"
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
sort === "az"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Sort A → Z"
>
<ArrowDownAZ className="w-3.5 h-3.5" /> A-Z
</Link>
<Link
href="/studios?sort=count"
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
sort === "count"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Sort by cover count"
>
<Hash className="w-3.5 h-3.5" /> Count
</Link>
</div>
<form action={createStudioAction} className="flex items-center gap-2">
<input
name="name"
placeholder="New studio"
required
maxLength={80}
className="glass rounded-lg px-3 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] w-56"
/>
<button className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium">
<Plus className="w-4 h-4" /> Create
</button>
</form>
</div>
</div>
{items.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<Building2 className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">No studios yet. Create one above or add from any cover.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{items.map((s) => (
<Link
key={s.id}
href={`/studios/${s.slug}`}
className="glass glass-hover rounded-xl p-4 flex items-center justify-between gap-3"
>
<span className="font-medium truncate">{s.name}</span>
<span className="text-xs font-mono text-[var(--color-fg-muted)] tabular-nums">{s.count}</span>
</Link>
))}
</div>
)}
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { BatchGeneratorClient } from "@/components/subtitles/BatchGeneratorClient";
export const dynamic = "force-dynamic";
export default function SubtitlesBatchPage() {
return (
<div className="max-w-[1600px] mx-auto px-6 pt-6 pb-12">
<BatchGeneratorClient />
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Trash2 } from "lucide-react";
import { rawDb } from "@/lib/db/client";
import { listImages, listAllTagCategories } from "@/lib/db/queries";
import { MasonryGrid } from "@/components/grid/MasonryGrid";
import { RegisterVisible } from "@/components/select/RegisterVisible";
import { FilterBar } from "@/components/grid/FilterBar";
import { UploadCard } from "@/components/ingest/UploadCard";
import { resolveSort } from "@/lib/sortServer";
import { deleteTag, renameTag } from "@/app/actions/entities";
import { EntityRenameInline } from "@/components/entities/EntityRenameInline";
import { TagCategoryPicker } from "@/components/categories/TagCategoryPicker";
import { parseFilterCriteria, statusToFlags } from "@/lib/filters";
export const dynamic = "force-dynamic";
export default async function TagPage({
params,
searchParams,
}: {
params: Promise<{ name: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { name } = await params;
const sp = await searchParams;
const sort = await resolveSort(typeof sp.sort === "string" ? sp.sort : undefined);
const search = (typeof sp.q === "string" ? sp.q.trim() : "") || undefined;
const criteria = parseFilterCriteria(sp);
const decoded = decodeURIComponent(name);
const tag = rawDb.prepare(`SELECT id, category_id FROM tags WHERE name = ?`).get(decoded) as
| { id: number; category_id: number | null }
| undefined;
if (!tag) notFound();
const categories = listAllTagCategories();
const items = listImages({
tagId: tag.id,
sort,
search,
...statusToFlags(criteria.status),
marks: criteria.marks,
actressIds: criteria.ids.actresses,
actressMode: criteria.mode.actresses,
studioIds: criteria.ids.studios,
seriesIds: criteria.ids.series,
genreIds: criteria.ids.genres,
genreMode: criteria.mode.genres,
collectionIds: criteria.ids.collections,
collectionMode: criteria.mode.collections,
tagIds: criteria.ids.tags,
tagMode: criteria.mode.tags,
categoryIds: criteria.ids.categories,
categoryMode: criteria.mode.categories,
});
const remove = async () => {
"use server";
await deleteTag(decoded);
};
const rename = async (name: string) => {
"use server";
return await renameTag(decoded, name);
};
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<Link href="/tag" className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] mb-4">
<ArrowLeft className="w-4 h-4" /> All tags
</Link>
<div className="flex items-start justify-between gap-6 mb-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-semibold tracking-tight mb-1">
<span className="text-[var(--color-violet)]">#{decoded}</span>
</h1>
<EntityRenameInline
initialName={decoded}
onRename={rename}
redirectBase="/tag/"
redirectKey="name"
/>
<form action={remove}>
<button className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-lg glass text-[var(--color-fg-muted)] hover:text-[var(--color-coral)] hover:border-[var(--color-coral)]/30">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</form>
</div>
<p className="text-[var(--color-fg-dim)]">{items.length} image{items.length === 1 ? "" : "s"}</p>
<div className="mt-3">
<TagCategoryPicker
tagId={tag.id}
currentCategoryId={tag.category_id}
categories={categories.map((c) => ({ id: c.id, name: c.name, slug: c.slug, color: c.color }))}
/>
</div>
</div>
<div className="w-[300px] flex-shrink-0">
<UploadCard autoAssign={{ tagName: decoded }} />
</div>
</div>
<FilterBar current={{ kind: "tag", name: decoded }} criteria={criteria} sort={sort} />
<RegisterVisible ids={items.map((i) => i.id)} />
<MasonryGrid images={items} />
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import { listAllTags, listAllTagCategories, type TagSort } from "@/lib/db/queries";
import { createTagAction } from "@/app/actions/tags";
import { Tag, Plus, ArrowDownAZ, Hash } from "lucide-react";
import { cn } from "@/lib/utils";
import { TagImportButton } from "@/components/tag/TagImportButton";
import { TagsList } from "@/components/tag/TagsList";
export const dynamic = "force-dynamic";
export default async function TagsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const sp = await searchParams;
const sort: TagSort = sp.sort === "count" ? "count" : "az";
const tags = listAllTags(sort);
const categories = listAllTagCategories("az");
return (
<div className="max-w-[1600px] mx-auto px-6 py-6 fade-in">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-semibold tracking-tight">Tags</h1>
<p className="text-[var(--color-fg-dim)] mt-1">{tags.length} total</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
<Link
href="/tag"
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
sort === "az"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Sort A → Z"
>
<ArrowDownAZ className="w-3.5 h-3.5" /> A-Z
</Link>
<Link
href="/tag?sort=count"
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
sort === "count"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Sort by image count"
>
<Hash className="w-3.5 h-3.5" /> Count
</Link>
</div>
<TagImportButton
existingTagNames={tags.map((t) => t.name)}
existingCategoryNames={categories.map((c) => c.name)}
/>
<form action={createTagAction} className="flex items-center gap-2">
<input
name="name"
placeholder="New tag"
required
maxLength={48}
className="glass rounded-lg px-3 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] w-56"
/>
<button className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium">
<Plus className="w-4 h-4" /> Create
</button>
</form>
</div>
</div>
{tags.length === 0 ? (
<div className="glass rounded-2xl p-card text-center">
<Tag className="w-8 h-8 mx-auto text-[var(--color-fg-dim)] mb-label" />
<p className="text-[var(--color-fg-dim)]">No tags yet. Create one above or add from any image.</p>
</div>
) : (
<TagsList tags={tags.map((t) => ({
id: t.id,
name: t.name,
count: t.count,
categoryId: t.categoryId,
categoryName: t.categoryName,
categoryColor: t.categoryColor,
}))} sort={sort} />
)}
</div>
);
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Plus, Minus, X, Loader2, Sparkles } from "lucide-react";
import { CategoryIcon } from "./CategoryIcon";
import { useActressSelection } from "./ActressSelectionProvider";
import {
bulkAddCategory,
bulkRemoveCategory,
createActressCategory,
} from "@/app/actions/actressCategories";
import type { ActressCategory } from "@/lib/db/queries";
const PALETTE = ["#fbbf24", "#22d3ee", "#a78bfa", "#f472b6", "#34d399", "#fb7185", "#f97316", "#60a5fa"];
export function ActressBulkBar({ categories }: { categories: ActressCategory[] }) {
const sel = useActressSelection();
const router = useRouter();
const [, start] = useTransition();
const [busy, setBusy] = useState(false);
const [openMenu, setOpenMenu] = useState<"add" | "remove" | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState(PALETTE[0]);
const ref = useRef<HTMLDivElement>(null);
// Click outside to close menus.
useEffect(() => {
const onDoc = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpenMenu(null);
setCreating(false);
}
};
if (openMenu || creating) document.addEventListener("mousedown", onDoc);
return () => document.removeEventListener("mousedown", onDoc);
}, [openMenu, creating]);
const empty = sel.ids.size === 0;
const selectedIds = Array.from(sel.ids);
function runAdd(categoryId: number) {
setBusy(true);
setOpenMenu(null);
start(async () => {
try {
await bulkAddCategory(selectedIds, categoryId);
router.refresh();
} finally {
setBusy(false);
}
});
}
function runRemove(categoryId: number) {
setBusy(true);
setOpenMenu(null);
start(async () => {
try {
await bulkRemoveCategory(selectedIds, categoryId);
router.refresh();
} finally {
setBusy(false);
}
});
}
async function createAndAdd() {
if (busy) return;
const name = newName.trim();
if (!name) return;
setBusy(true);
try {
const created = await createActressCategory({ name, color: newColor, icon: "tag", priority: 50 });
if (created) {
await bulkAddCategory(selectedIds, created.id);
router.refresh();
}
setCreating(false);
setNewName("");
setOpenMenu(null);
} catch (err) {
console.error("[createAndAdd] failed:", err);
} finally {
setBusy(false);
}
}
return (
<div
ref={ref}
aria-hidden={empty}
className={`flex items-center gap-2 px-2.5 py-1 rounded-full border border-[var(--color-cyan)]/40 bg-[var(--color-cyan)]/5 transition-opacity ${empty ? "invisible" : ""}`}
>
<div className="flex items-center gap-1.5 px-1">
<Sparkles className="w-3.5 h-3.5 text-[var(--color-cyan)]" />
<span className="text-xs font-medium tabular-nums">{sel.ids.size}</span>
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">selected</span>
</div>
<div className="relative">
<button
type="button"
onClick={() => { setOpenMenu(openMenu === "add" ? null : "add"); setCreating(false); }}
disabled={busy}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-full glass glass-hover disabled:opacity-50"
>
{busy && openMenu !== "remove" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
Add
</button>
{openMenu === "add" && (
<Menu>
{categories.map((c) => (
<MenuItem key={c.id} onClick={() => runAdd(c.id)} icon={<CategoryIcon name={c.icon} className="w-3.5 h-3.5" />} color={c.color}>
{c.name}
</MenuItem>
))}
<div className="border-t border-[var(--color-glass-border)] my-1" />
{creating ? (
<div className="px-2 py-2 space-y-2">
<input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") createAndAdd(); if (e.key === "Escape") setCreating(false); }}
placeholder="Category name"
maxLength={32}
className="w-full glass rounded-md px-2 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
<div className="flex flex-wrap gap-1">
{PALETTE.map((p) => (
<button
key={p}
type="button"
onClick={() => setNewColor(p)}
className={`w-5 h-5 rounded-full border-2 ${newColor === p ? "border-white" : "border-transparent"}`}
style={{ background: p }}
aria-label={`Color ${p}`}
/>
))}
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setCreating(false)}
className="flex-1 text-xs px-2 py-1.5 rounded-md glass glass-hover"
>
Cancel
</button>
<button
type="button"
onClick={createAndAdd}
disabled={!newName.trim() || busy}
className="flex-1 text-xs px-2 py-1.5 rounded-md bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
>
Create & Assign
</button>
</div>
</div>
) : (
<button
type="button"
onClick={() => setCreating(true)}
className="w-full flex items-center gap-2 text-xs px-3 py-2 hover:bg-[var(--color-glass)] text-[var(--color-cyan)]"
>
<Plus className="w-3.5 h-3.5" /> New Category
</button>
)}
</Menu>
)}
</div>
<div className="relative">
<button
type="button"
onClick={() => { setOpenMenu(openMenu === "remove" ? null : "remove"); setCreating(false); }}
disabled={busy}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-full glass glass-hover disabled:opacity-50"
>
{busy && openMenu === "remove" ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Minus className="w-3.5 h-3.5" />}
Remove
</button>
{openMenu === "remove" && (
<Menu>
{categories.map((c) => (
<MenuItem key={c.id} onClick={() => runRemove(c.id)} icon={<CategoryIcon name={c.icon} className="w-3.5 h-3.5" />} color={c.color}>
{c.name}
</MenuItem>
))}
</Menu>
)}
</div>
<button
type="button"
onClick={() => sel.clear()}
title="Clear selection"
className="flex items-center gap-1 text-xs px-2 py-1 rounded-full text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
);
}
function Menu({ children }: { children: React.ReactNode }) {
return (
<div className="absolute top-full mt-2 left-0 min-w-[220px] rounded-xl border border-[var(--color-glass-border)] bg-[var(--color-bg-0)] shadow-2xl py-1 overflow-hidden max-h-[60vh] overflow-y-auto">
{children}
</div>
);
}
function MenuItem({
icon, color, children, onClick,
}: { icon?: React.ReactNode; color?: string | null; children: React.ReactNode; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="w-full flex items-center gap-2 text-sm px-3 py-2 hover:bg-[var(--color-glass)] text-left"
style={color ? { color } : undefined}
>
<span className="shrink-0">{icon}</span>
<span className="truncate">{children}</span>
</button>
);
}
+286
View File
@@ -0,0 +1,286 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Pencil, User, Star, Gem, Check } from "lucide-react";
import { ActressPortraitEditor } from "./ActressPortraitEditor";
import { CategoryIcon } from "./CategoryIcon";
import { useActressSelection } from "./ActressSelectionProvider";
import type { ActressCategory, ActressAllPortraits } from "@/lib/db/queries";
import { portraitUrl } from "@/lib/assetUrls";
import { toggleActressCategory } from "@/app/actions/actressCategories";
import { cn } from "@/lib/utils";
export interface ActressCardData {
id: number;
name: string;
slug: string;
count: number;
portraitPath: string | null;
portraitZoom: number;
portraitOffsetX: number;
portraitOffsetY: number;
categories: ActressCategory[];
portraits: ActressAllPortraits;
}
const PHI = 1.618;
const CARD_W = 240;
const CARD_H_PORTRAIT = Math.round(CARD_W * PHI);
const CARD_W_LANDSCAPE = Math.round(CARD_W * PHI);
const CARD_H_LANDSCAPE = CARD_W;
// Mirror the editor's canonical frame so cqw-based offsets line up.
const FRAME_H = 360;
const CANONICAL_PORTRAIT_W = Math.round(FRAME_H / PHI);
const CANONICAL_LANDSCAPE_W = Math.round(FRAME_H * PHI);
export function ActressCard({
actress,
builtins,
orderedIds,
view = "portrait",
}: {
actress: ActressCardData;
builtins: { favoriteId?: number; vipId?: number };
orderedIds: number[];
view?: "portrait" | "landscape";
}) {
const [editing, setEditing] = useState(false);
const router = useRouter();
const [, start] = useTransition();
const sel = useActressSelection();
const selected = sel.has(actress.id);
const anySelected = sel.ids.size > 0;
const ringCat = actress.categories[0];
const ringColor = ringCat?.color ?? (selected ? "var(--color-cyan)" : null);
// Optimistic per-category overrides. Map<categoryId, on?>; presence in
// `pending` disables the button while the server action is in flight.
const [optimistic, setOptimistic] = useState<Map<number, boolean>>(new Map());
const [pending, setPending] = useState<Set<number>>(new Set());
const isOnInServer = (id: number) => actress.categories.some((c) => c.id === id);
const isOn = (id: number) => optimistic.get(id) ?? isOnInServer(id);
const isFavorite = builtins.favoriteId != null && isOn(builtins.favoriteId);
const isVip = builtins.vipId != null && isOn(builtins.vipId);
// Per-view portrait selection. Landscape uses the L slot strictly —
// no fallback to P1, so missing L portraits surface as the empty
// user-icon state and the user knows to upload one.
const isLandscape = view === "landscape";
const slotData = isLandscape
? {
path: actress.portraits.ph.path,
zoom: actress.portraits.ph.zoom,
offsetX: actress.portraits.ph.offsetX,
offsetY: actress.portraits.ph.offsetY,
slot: "h" as const,
}
: {
path: actress.portraitPath,
zoom: actress.portraitZoom,
offsetX: actress.portraitOffsetX,
offsetY: actress.portraitOffsetY,
slot: "1" as const,
};
// In landscape mode, cards fill their grid column instead of being a
// fixed pixel width — the parent grid controls how many fit per row.
const cardW = isLandscape ? "100%" : CARD_W;
const cardH = isLandscape ? undefined : CARD_H_PORTRAIT;
const hasImg = !!slotData.path;
function toggleCat(e: React.MouseEvent, categoryId: number | undefined) {
e.preventDefault();
e.stopPropagation();
if (categoryId == null) return;
if (pending.has(categoryId)) return; // ignore double-clicks while in flight
const next = !isOn(categoryId);
setOptimistic((m) => new Map(m).set(categoryId, next));
setPending((s) => new Set(s).add(categoryId));
start(async () => {
try {
await toggleActressCategory(actress.id, categoryId);
router.refresh();
} catch (err) {
// Revert the optimistic flip on failure.
console.error("[toggleActressCategory] failed:", err);
setOptimistic((m) => {
const n = new Map(m);
n.delete(categoryId);
return n;
});
} finally {
setPending((s) => {
const n = new Set(s);
n.delete(categoryId);
return n;
});
}
});
}
function handleCheckbox(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) sel.selectRangeTo(actress.id, orderedIds);
else sel.toggle(actress.id);
}
function handleCardClick(e: React.MouseEvent) {
if (anySelected) {
e.preventDefault();
if (e.shiftKey) sel.selectRangeTo(actress.id, orderedIds);
else sel.toggle(actress.id);
}
}
return (
<>
<div
className={cn(
"cover-hero-frame group relative rounded-2xl overflow-hidden glass glass-hover",
anySelected && !selected && "opacity-70 hover:opacity-100",
)}
style={{
width: cardW,
boxShadow: selected && ringColor
? `0 0 0 4px ${ringColor}, 0 0 24px -2px ${ringColor}`
: undefined,
border: "none",
}}
>
<Link href={`/actress/${actress.slug}`} onClick={handleCardClick} className="block">
<div
className="cover-hero-hover relative bg-[var(--color-bg-1)] overflow-hidden"
style={
isLandscape
? { aspectRatio: `${PHI} / 1`, containerType: "inline-size" } // 1.618 : 1
: { height: cardH, containerType: "inline-size" }
}
>
{hasImg ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={portraitUrl({ path: slotData.path!, slug: actress.slug, slot: slotData.slot })}
alt={actress.name}
draggable={false}
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none"
style={{
transform: `translate(-50%, -50%) translate(${slotData.offsetX / (isLandscape ? CANONICAL_LANDSCAPE_W : CANONICAL_PORTRAIT_W) * 100}cqw, ${slotData.offsetY / (isLandscape ? CANONICAL_LANDSCAPE_W : CANONICAL_PORTRAIT_W) * 100}cqw) scale(${slotData.zoom})`,
width: "100cqw",
height: "auto",
}}
/>
) : (
<div className="absolute inset-0 grid place-items-center">
<User className="w-12 h-12 text-[var(--color-fg-muted)]" />
</div>
)}
{actress.categories.length > 0 && (
<div className="absolute top-2 left-2 z-10 flex flex-wrap gap-1 max-w-[60%]">
{actress.categories.map((c) => (
<span
key={c.id}
className="flex items-center gap-1 text-[11px] uppercase tracking-wider font-mono font-semibold px-2.5 py-0.5 rounded-full bg-black/80 backdrop-blur-md shadow-md"
style={{
color: c.color ?? "#fff",
border: `1px solid ${c.color ?? "#888"}aa`,
textShadow: "0 1px 2px rgba(0,0,0,0.9)",
}}
title={c.name}
>
<CategoryIcon name={c.icon} className="w-3 h-3" />
{c.name}
</span>
))}
</div>
)}
<div className="absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-black/85 via-black/45 to-transparent p-3 pt-10">
<div className="text-base font-medium text-white truncate">{actress.name}</div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)]">
{actress.count} cover{actress.count === 1 ? "" : "s"}
</div>
</div>
</div>
</Link>
<button
type="button"
onClick={handleCheckbox}
aria-label={selected ? "Deselect" : "Select"}
title={selected ? "Deselect (Shift+click for range)" : "Select (Shift+click for range)"}
className={cn(
"absolute top-2 right-2 w-8 h-8 grid place-items-center rounded-md transition-all backdrop-blur-md border-2 z-10",
selected
? "bg-[var(--color-cyan)] border-[var(--color-cyan)] text-black shadow-[var(--shadow-glow-cyan)]"
: "bg-black/40 border-white/50 text-transparent",
!selected && !anySelected && "opacity-0 group-hover:opacity-100",
)}
>
<Check className="w-4 h-4" strokeWidth={3} />
</button>
<div className="absolute bottom-2 right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity z-10">
{builtins.vipId != null && (
<button
type="button"
onClick={(e) => toggleCat(e, builtins.vipId)}
disabled={builtins.vipId != null && pending.has(builtins.vipId)}
title={isVip ? "Unmark VIP" : "Mark VIP"}
className={cn(
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
"hover:scale-110 hover:ring-2 hover:ring-cyan-300 hover:shadow-lg active:scale-95",
isVip
? "bg-cyan-400/40 text-cyan-200 hover:bg-cyan-400/60"
: "bg-black/70 text-white hover:bg-cyan-400/30 hover:text-cyan-200",
)}
>
<Gem className="w-4 h-4" />
</button>
)}
{builtins.favoriteId != null && (
<button
type="button"
onClick={(e) => toggleCat(e, builtins.favoriteId)}
disabled={builtins.favoriteId != null && pending.has(builtins.favoriteId)}
title={isFavorite ? "Unmark Favorite" : "Mark Favorite"}
className={cn(
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
"hover:scale-110 hover:ring-2 hover:ring-amber-300 hover:shadow-lg active:scale-95",
isFavorite
? "bg-amber-400/40 text-amber-200 hover:bg-amber-400/60"
: "bg-black/70 text-white hover:bg-amber-400/30 hover:text-amber-200",
)}
>
<Star className={cn("w-4 h-4", isFavorite && "fill-amber-200")} />
</button>
)}
<button
type="button"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); setEditing(true); }}
title="Edit portrait"
className={cn(
"w-8 h-8 grid place-items-center rounded-md cursor-pointer transition-all duration-150",
"bg-black/70 text-white",
"hover:scale-110 hover:ring-2 hover:ring-[var(--color-cyan)] hover:bg-[var(--color-cyan)]/30 hover:text-[var(--color-cyan)] hover:shadow-lg active:scale-95",
)}
>
<Pencil className="w-4 h-4" />
</button>
</div>
</div>
{editing && (
<ActressPortraitEditor
actressId={actress.id}
actressName={actress.name}
initial={actress.portraits}
onClose={() => setEditing(false)}
/>
)}
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useState } from "react";
import { Plus, Upload } from "lucide-react";
import { createActressAction } from "@/app/actions/entities";
import { ActressImportDialog } from "./ActressImportDialog";
export function ActressCreateBar() {
const [importing, setImporting] = useState(false);
return (
<>
<div className="flex items-center gap-2">
<form action={createActressAction} className="flex items-center gap-2">
<input
name="name"
placeholder="New Actress"
required
maxLength={80}
className="glass rounded-lg px-3 py-1.5 text-sm outline-none focus:border-[var(--color-cyan)] w-56"
/>
<button className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium">
<Plus className="w-4 h-4" /> Create
</button>
</form>
<button
type="button"
onClick={() => setImporting(true)}
className="flex items-center gap-1 text-sm px-3 py-1.5 rounded-lg glass glass-hover"
title="Bulk import a list of actresses"
>
<Upload className="w-4 h-4" /> Import
</button>
</div>
{importing && <ActressImportDialog onClose={() => setImporting(false)} />}
</>
);
}
+348
View File
@@ -0,0 +1,348 @@
"use client";
import { useMemo, useState } from "react";
import { Search, X, CheckSquare, RectangleVertical, RectangleHorizontal } from "lucide-react";
import { ActressCard, type ActressCardData } from "./ActressCard";
import { CategoryIcon } from "./CategoryIcon";
import { ActressSelectionProvider, useActressSelection } from "./ActressSelectionProvider";
import { ActressBulkBar } from "./ActressBulkBar";
import { reverseName } from "@/lib/jav/nameUtils";
import type { ActressCategory } from "@/lib/db/queries";
import { cn } from "@/lib/utils";
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const NON_LATIN = "#";
function bucketFor(name: string): string {
const c = name.trim().slice(0, 1).toUpperCase();
return c >= "A" && c <= "Z" ? c : NON_LATIN;
}
interface ActressFull extends ActressCardData {
altNames?: string | null;
}
export function ActressDirectory(props: { items: ActressFull[]; categories: ActressCategory[] }) {
return (
<ActressSelectionProvider>
<DirectoryInner {...props} />
</ActressSelectionProvider>
);
}
function DirectoryInner({
items,
categories,
}: {
items: ActressFull[];
categories: ActressCategory[];
}) {
const sel = useActressSelection();
const [query, setQuery] = useState("");
const [activeLetter, setActiveLetter] = useState<string | null>(null);
// null = ALL, "unassigned" = actresses with no categories, number = category id
const [activeCategoryId, setActiveCategoryId] = useState<number | "unassigned" | null>(null);
// P (default) uses portraits.p1, L uses portraits.ph (golden landscape).
const [view, setView] = useState<"portrait" | "landscape">("portrait");
const builtins = useMemo(() => ({
favoriteId: categories.find((c) => c.slug === "favorite")?.id,
vipId: categories.find((c) => c.slug === "vip")?.id,
}), [categories]);
const enriched = useMemo(() => items.map((a) => {
const reversed = reverseName(a.name);
const altParts = (a.altNames ?? "").split(/[,、,]/).map((s) => s.trim()).filter(Boolean);
const haystack = [a.name, reversed ?? "", ...altParts].join(" ").toLowerCase();
return { actress: a, haystack, bucket: bucketFor(a.name) };
}), [items]);
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
const searched = tokens.length === 0
? enriched
: enriched.filter(({ haystack }) => tokens.every((t) => haystack.includes(t)));
const categoryFiltered = activeCategoryId == null
? searched
: activeCategoryId === "unassigned"
? searched.filter(({ actress }) => actress.categories.length === 0)
: searched.filter(({ actress }) => actress.categories.some((c) => c.id === activeCategoryId));
const unassignedCount = useMemo(
() => searched.reduce((n, e) => (e.actress.categories.length === 0 ? n + 1 : n), 0),
[searched],
);
// Pill display order: ALL · VIP · Favorite · Not Assigned · everything
// else. VIP and Favorite are seeded built-ins identified by slug; if
// they're absent for any reason we just skip them.
const orderedCategories = useMemo(() => {
const vip = categories.find((c) => c.slug === "vip");
const fav = categories.find((c) => c.slug === "favorite");
const rest = categories.filter((c) => c.slug !== "favorite" && c.slug !== "vip");
return [...(vip ? [vip] : []), ...(fav ? [fav] : []), ...rest];
}, [categories]);
const counts = useMemo(() => {
const m: Record<string, number> = {};
for (const e of categoryFiltered) m[e.bucket] = (m[e.bucket] ?? 0) + 1;
return m;
}, [categoryFiltered]);
const categoryCounts = useMemo(() => {
const m: Record<number, number> = {};
for (const e of searched) {
for (const c of e.actress.categories) m[c.id] = (m[c.id] ?? 0) + 1;
}
return m;
}, [searched]);
const visible = activeLetter
? categoryFiltered.filter((e) => e.bucket === activeLetter)
: categoryFiltered;
const grouped = useMemo(() => {
const groups: Record<string, ActressFull[]> = {};
for (const { actress, bucket } of visible) {
(groups[bucket] ??= []).push(actress);
}
for (const k of Object.keys(groups)) {
groups[k].sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
}
return groups;
}, [visible]);
const orderedBuckets = [...LETTERS, NON_LATIN].filter((b) => (grouped[b]?.length ?? 0) > 0);
// Visible IDs in the exact rendered order — used for shift-click range and "select all visible".
const orderedIds = useMemo(() => {
const out: number[] = [];
for (const b of orderedBuckets) for (const a of grouped[b]) out.push(a.id);
return out;
}, [orderedBuckets, grouped]);
const allVisibleSelected = orderedIds.length > 0 && orderedIds.every((id) => sel.has(id));
const renderCategoryPill = (c: ActressCategory) => {
const n = categoryCounts[c.id] ?? 0;
const enabled = n > 0;
const active = activeCategoryId === c.id;
const color = c.color ?? "var(--color-cyan)";
return (
<button
key={c.id}
type="button"
disabled={!enabled}
onClick={() => setActiveCategoryId(active ? null : c.id)}
className={`flex items-center justify-center gap-1.5 text-xs font-mono font-medium px-3 py-1.5 rounded-full border transition-colors min-w-[140px] ${
active
? "border-transparent"
: enabled
? "glass glass-hover"
: "text-[var(--color-fg-muted)]/40 cursor-not-allowed"
}`}
style={active ? { background: color, color: "#000" } : undefined}
>
<CategoryIcon name={c.icon} className="w-3 h-3" />
{c.name}
{enabled && (
<span className={`tabular-nums ${active ? "text-black/70" : "text-[var(--color-fg-muted)]"}`}>{n}</span>
)}
</button>
);
};
return (
<div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<div className="relative flex-1">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] pointer-events-none" />
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter Cast — Name, Reversed, Alt Names…"
className="w-full glass rounded-lg pl-9 pr-9 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
aria-label="Clear filter"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<button
type="button"
onClick={() => allVisibleSelected ? sel.clear() : sel.selectMany(orderedIds)}
disabled={orderedIds.length === 0}
className={`flex items-center justify-center gap-1.5 text-xs px-3 py-2 rounded-lg transition-colors disabled:opacity-40 w-[180px] ${
allVisibleSelected ? "bg-[var(--color-cyan)] text-black font-medium" : "glass glass-hover"
}`}
>
<CheckSquare className="w-3.5 h-3.5" />
{allVisibleSelected ? "Deselect All Visible" : "Select All Visible"}
{orderedIds.length > 0 && (
<span className={`tabular-nums ${allVisibleSelected ? "text-black/70" : "text-[var(--color-fg-muted)]"}`}>
{orderedIds.length}
</span>
)}
</button>
</div>
{categories.length > 0 && (
<div className="flex items-start gap-1.5">
<div className="flex flex-wrap items-center gap-1.5 flex-1 min-w-0">
<button
type="button"
onClick={() => setActiveCategoryId(null)}
className={`text-xs font-mono font-medium px-3 py-1.5 rounded-full border transition-colors ${
activeCategoryId === null ? "bg-[var(--color-cyan)] text-black border-transparent" : "glass glass-hover"
}`}
>
ALL
<span className={`ml-1.5 tabular-nums ${activeCategoryId === null ? "text-black/70" : "text-[var(--color-fg-muted)]"}`}>
{searched.length}
</span>
</button>
{orderedCategories
.filter((c) => c.slug === "favorite" || c.slug === "vip")
.map((c) => renderCategoryPill(c))}
<button
type="button"
disabled={unassignedCount === 0}
onClick={() => setActiveCategoryId(activeCategoryId === "unassigned" ? null : "unassigned")}
className={`flex items-center justify-center text-xs font-mono font-medium px-3 py-1.5 rounded-full border transition-colors min-w-[140px] ${
activeCategoryId === "unassigned"
? "bg-[var(--color-coral)] text-black border-transparent"
: unassignedCount > 0
? "glass glass-hover"
: "text-[var(--color-fg-muted)]/40 cursor-not-allowed"
}`}
title="Actresses with no category assigned"
>
Not Assigned
<span className={`ml-1.5 tabular-nums ${activeCategoryId === "unassigned" ? "text-black/70" : "text-[var(--color-fg-muted)]"}`}>
{unassignedCount}
</span>
</button>
{orderedCategories
.filter((c) => c.slug !== "favorite" && c.slug !== "vip")
.map((c) => {
return renderCategoryPill(c);
})}
</div>
<div className="shrink-0 flex justify-end items-center gap-2">
<ActressBulkBar categories={categories} />
<div className="flex items-center rounded-lg glass overflow-hidden text-xs">
<button
type="button"
onClick={() => setView("portrait")}
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
view === "portrait"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Portrait view (default — uses P1 portrait slot)"
>
<RectangleVertical className="w-3.5 h-3.5" /> P
</button>
<button
type="button"
onClick={() => setView("landscape")}
className={cn(
"flex items-center gap-1 px-2.5 py-1.5",
view === "landscape"
? "bg-[var(--color-cyan)] text-black font-medium"
: "text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]",
)}
title="Landscape view (uses L portrait slot)"
>
<RectangleHorizontal className="w-3.5 h-3.5" /> L
</button>
</div>
</div>
</div>
)}
<div className="flex items-stretch gap-1 mt-4 mb-4 w-full">
<button
type="button"
onClick={() => setActiveLetter(null)}
className={`flex-1 flex flex-col items-center justify-center font-mono py-2 rounded-lg border transition-colors ${
activeLetter === null ? "bg-[var(--color-cyan)] text-black border-transparent" : "glass glass-hover"
}`}
>
<span className="text-base font-semibold leading-none">ALL</span>
<span className={`text-[10px] font-semibold tabular-nums mt-0.5 ${activeLetter === null ? "text-black/70" : "text-[var(--color-fg-muted)]"}`}>
{categoryFiltered.length}
</span>
</button>
{[...LETTERS, NON_LATIN].map((L) => {
const n = counts[L] ?? 0;
const enabled = n > 0;
const active = activeLetter === L;
return (
<button
key={L}
type="button"
disabled={!enabled}
onClick={() => setActiveLetter(active ? null : L)}
className={`flex-1 flex flex-col items-center justify-center font-mono py-2 rounded-lg border transition-colors ${
active
? "bg-[var(--color-cyan)] text-black border-transparent"
: enabled
? "glass glass-hover"
: "border-transparent text-[var(--color-fg-muted)]/40 cursor-not-allowed"
}`}
>
<span className="text-base font-semibold leading-none">{L}</span>
<span className={`text-[10px] font-semibold tabular-nums mt-0.5 ${
active ? "text-black/70" : enabled ? "text-[var(--color-fg-muted)]" : "text-transparent"
}`}>
{enabled ? n : 0}
</span>
</button>
);
})}
</div>
{orderedBuckets.length === 0 && (
<div className="glass rounded-2xl p-card text-center text-[var(--color-fg-dim)] text-sm">
No matches.
</div>
)}
{activeLetter === null ? (
<div key={view} className={"fade-in " + (view === "landscape"
? "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4"
: "flex flex-wrap gap-4")}>
{orderedBuckets.flatMap((b) => grouped[b]).map((a) => (
<ActressCard key={a.id} actress={a} builtins={builtins} orderedIds={orderedIds} view={view} />
))}
</div>
) : (
<div key={view} className="fade-in space-y-8">
{orderedBuckets.map((b) => (
<section key={b} id={`letter-${b}`} className="scroll-mt-20">
<h2 className="text-sm font-mono uppercase tracking-wider text-[var(--color-fg-muted)] mb-3">
{b}
<span className="ml-2 text-[var(--color-fg-dim)] tabular-nums">{grouped[b].length}</span>
</h2>
<div className={view === "landscape"
? "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4"
: "flex flex-wrap gap-4"}>
{grouped[b].map((a) => (
<ActressCard key={a.id} actress={a} builtins={builtins} orderedIds={orderedIds} view={view} />
))}
</div>
</section>
))}
</div>
)}
</div>
);
}
+403
View File
@@ -0,0 +1,403 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Pencil, User, Image as ImageIcon, ArrowLeft } from "lucide-react";
import { ActressPortraitEditor } from "./ActressPortraitEditor";
import { ActressMetaEditor } from "./ActressMetaEditor";
import { CategoryIcon } from "./CategoryIcon";
import { buildAltNameChips } from "@/lib/jav/nameUtils";
import type { ActressCategory, ActressAllPortraits, PortraitSlotKey } from "@/lib/db/queries";
import { toggleActressCategory } from "@/app/actions/actressCategories";
import { reorderActressPortraitSlots } from "@/app/actions/actressPortrait";
import { portraitUrl } from "@/lib/assetUrls";
interface Props {
actress: {
id: number;
name: string;
slug: string;
altNames: string | null;
notes: string | null;
portraits: ActressAllPortraits;
categories: ActressCategory[];
bornOn: string | null;
heightCm: number | null;
weightKg: number | null;
cupSize: string | null;
};
coverCount: number;
allCategories: ActressCategory[];
}
function computeAge(bornOn: string | null): number | null {
if (!bornOn) return null;
const d = new Date(bornOn);
if (Number.isNaN(d.getTime())) return null;
const now = new Date();
let age = now.getFullYear() - d.getFullYear();
const m = now.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--;
return age >= 0 ? age : null;
}
const PHI = 1.618;
const FRAME_H = 308;
const PORTRAIT_W = Math.floor(FRAME_H / PHI);
const HORIZ_W = Math.floor(FRAME_H * PHI);
const SLOT_LABEL: Record<PortraitSlotKey, string> = { "1": "P1", "2": "P2", "3": "P3", "4": "P4", "h": "L" };
export function ActressHero({ actress, coverCount, allCategories }: Props) {
const [editingMeta, setEditingMeta] = useState(false);
const [editingSlot, setEditingSlot] = useState<PortraitSlotKey | null>(null);
const router = useRouter();
const [, start] = useTransition();
const altChips = buildAltNameChips(actress.name, actress.altNames);
const ringColor = actress.categories[0]?.color ?? null;
const activeIds = new Set(actress.categories.map((c) => c.id));
const orderedCategories = (() => {
const list = [...allCategories];
const vipIdx = list.findIndex((c) => c.slug === "vip");
const favIdx = list.findIndex((c) => c.slug === "favorite");
if (vipIdx !== -1 && favIdx !== -1 && vipIdx > favIdx) {
const [vip] = list.splice(vipIdx, 1);
list.splice(favIdx, 0, vip);
}
return list;
})();
function toggleCat(id: number) {
start(async () => {
await toggleActressCategory(actress.id, id);
router.refresh();
});
}
const dragSlotRef = useRef<PortraitSlotKey | null>(null);
const [dragSlot, setDragSlotState] = useState<PortraitSlotKey | null>(null);
function setDragSlot(s: PortraitSlotKey | null) {
dragSlotRef.current = s;
setDragSlotState(s);
}
const [overSlot, setOverSlot] = useState<PortraitSlotKey | null>(null);
const [optimistic, setOptimistic] = useState<ActressAllPortraits | null>(null);
const portraits = optimistic ?? actress.portraits;
useEffect(() => { setOptimistic(null); }, [actress.portraits]);
function handleDrop(target: PortraitSlotKey) {
const src = dragSlotRef.current;
setDragSlot(null);
setOverSlot(null);
if (!src || src === target) return;
if (src === "h" || target === "h") return;
const order: PortraitSlotKey[] = ["1", "2", "3", "4"];
const keyMap: Record<"1" | "2" | "3" | "4", "p1" | "p2" | "p3" | "p4"> = { "1": "p1", "2": "p2", "3": "p3", "4": "p4" };
const arr = order.map((k) => portraits[keyMap[k as "1" | "2" | "3" | "4"]]);
const srcIdx = order.indexOf(src);
const destIdx = order.indexOf(target);
const [moved] = arr.splice(srcIdx, 1);
arr.splice(destIdx, 0, moved);
setOptimistic({ ...portraits, p1: arr[0], p2: arr[1], p3: arr[2], p4: arr[3] });
start(async () => {
await reorderActressPortraitSlots(actress.id, src, target);
router.refresh();
});
}
return (
<>
<div className="flex items-center justify-between mb-4">
<Link
href="/actress"
className="inline-flex items-center gap-1 text-sm text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]"
>
<ArrowLeft className="w-4 h-4" /> All Actresses
</Link>
<button
type="button"
onClick={() => setEditingMeta(true)}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg glass glass-hover shrink-0"
>
<Pencil className="w-3.5 h-3.5" /> Edit
</button>
</div>
<div
className="glass rounded-2xl p-card mb-6 flex flex-col gap-section"
style={ringColor ? { boxShadow: `0 0 0 2px ${ringColor}, 0 0 24px -4px ${ringColor}66` } : undefined}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-3xl font-semibold tracking-tight text-[var(--color-violet)] break-words">{actress.name}</h1>
{altChips.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{altChips.map((c) => (
<span
key={c.value}
title={c.auto ? "Auto-generated reversed name (used for search)" : undefined}
className={`text-xs px-2 py-0.5 rounded-full border font-mono ${
c.auto
? "border-[var(--color-cyan)]/30 text-[var(--color-cyan)] bg-[var(--color-cyan)]/5"
: "border-[var(--color-glass-border)] text-[var(--color-fg-dim)]"
}`}
>
{c.value}
</span>
))}
</div>
)}
</div>
<div className="text-right shrink-0">
<Stat label="Covers" value={coverCount} />
</div>
</div>
<div className="flex flex-wrap gap-3 items-stretch justify-center">
<PortraitFrame
slot="1"
data={portraits.p1}
width={PORTRAIT_W}
height={FRAME_H}
onEdit={() => setEditingSlot("1")}
/>
<PortraitFrame
slot="2"
data={portraits.p2}
width={PORTRAIT_W}
height={FRAME_H}
onEdit={() => setEditingSlot("2")}
/>
<PortraitFrame
slot="3"
data={portraits.p3}
width={PORTRAIT_W}
height={FRAME_H}
onEdit={() => setEditingSlot("3")}
/>
<PortraitFrame
slot="4"
data={portraits.p4}
width={PORTRAIT_W}
height={FRAME_H}
onEdit={() => setEditingSlot("4")}
/>
<PortraitFrame
slot="h"
data={portraits.ph}
width={HORIZ_W}
height={FRAME_H}
onEdit={() => setEditingSlot("h")}
/>
<BioPanel actress={actress} width={PORTRAIT_W} height={FRAME_H} />
</div>
<div className="min-w-0 flex flex-col">
{allCategories.length > 0 && (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-label">Categories</div>
<div className="flex flex-wrap gap-chip">
{orderedCategories.map((c) => {
const active = activeIds.has(c.id);
const color = c.color ?? "#888";
return (
<button
key={c.id}
type="button"
onClick={() => toggleCat(c.id)}
className="flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full font-mono transition-all"
style={
active
? { background: `${color}25`, color, border: `1px solid ${color}aa` }
: { background: "transparent", color: "var(--color-fg-muted)", border: "1px solid var(--color-glass-border)" }
}
>
<CategoryIcon name={c.icon} className="w-3 h-3" />
{c.name}
</button>
);
})}
</div>
</div>
)}
{actress.notes && (
<p className="text-sm text-[var(--color-fg-dim)] mt-4 leading-relaxed whitespace-pre-wrap max-w-prose">
{actress.notes}
</p>
)}
</div>
</div>
{editingMeta && (
<ActressMetaEditor
actressId={actress.id}
initial={{
name: actress.name,
altNames: actress.altNames,
notes: actress.notes,
bornOn: actress.bornOn,
heightCm: actress.heightCm,
weightKg: actress.weightKg,
cupSize: actress.cupSize,
}}
onClose={() => setEditingMeta(false)}
/>
)}
{editingSlot && (
<ActressPortraitEditor
actressId={actress.id}
actressName={actress.name}
initial={portraits}
initialSlot={editingSlot}
onClose={() => setEditingSlot(null)}
/>
)}
</>
);
function PortraitFrame({
slot,
data,
width,
height,
onEdit,
}: {
slot: PortraitSlotKey;
data: ActressAllPortraits[keyof ActressAllPortraits];
width: number;
height: number;
onEdit: () => void;
}) {
const reorderable = slot !== "h";
const isDragging = dragSlot === slot;
const isDropTarget = reorderable && overSlot === slot && dragSlot !== null && dragSlot !== "h" && dragSlot !== slot;
return (
<div
className="relative shrink-0 rounded-xl overflow-hidden bg-[var(--color-bg-1)] group"
style={{
width,
height,
cursor: reorderable ? "grab" : undefined,
opacity: isDragging ? 0.4 : 1,
outline: isDropTarget ? "2px dashed var(--color-cyan)" : undefined,
outlineOffset: isDropTarget ? "-2px" : undefined,
transition: "opacity 120ms ease",
}}
draggable={reorderable}
onDragStart={(e) => {
if (!reorderable) return;
dragSlotRef.current = slot;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", slot);
requestAnimationFrame(() => setDragSlotState(slot));
}}
onDragEnd={() => { dragSlotRef.current = null; setDragSlotState(null); setOverSlot(null); }}
onDragOver={(e) => {
if (!reorderable) return;
const ds = dragSlotRef.current;
if (ds && ds !== "h" && ds !== slot) {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}
}}
onDragEnter={() => { if (reorderable) setOverSlot(slot); }}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setOverSlot((s) => (s === slot ? null : s));
}}
onDrop={(e) => { e.preventDefault(); handleDrop(slot); }}
>
{data.path ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={portraitUrl({ path: data.path!, slug: actress.slug, slot })}
alt=""
draggable={false}
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none"
style={{
transform: `translate(-50%, -50%) translate(${data.offsetX}px, ${data.offsetY}px) scale(${data.zoom})`,
width,
height: "auto",
}}
/>
) : (
<div className="absolute inset-0 grid place-items-center">
<User className="w-12 h-12 text-[var(--color-fg-muted)]" />
</div>
)}
<div className="absolute top-1.5 left-1.5 text-[9px] uppercase tracking-wider font-mono text-white px-1.5 py-0.5 rounded bg-black/60 backdrop-blur-sm pointer-events-none">
{SLOT_LABEL[slot]}
</div>
<div className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 group-hover:opacity-100 group-hover:bg-black/40 transition-all pointer-events-none">
<button
type="button"
onClick={onEdit}
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-black/60 text-white text-xs font-medium pointer-events-auto"
draggable={false}
>
<ImageIcon className="w-3.5 h-3.5" /> Edit
</button>
</div>
</div>
);
}
}
function BioPanel({
actress,
width,
height,
}: {
actress: { bornOn: string | null; heightCm: number | null; weightKg: number | null; cupSize: string | null };
width: number;
height: number;
}) {
const age = computeAge(actress.bornOn);
return (
<div
className="shrink-0 rounded-xl bg-[var(--color-bg-1)]/40 border border-[var(--color-glass-border)] p-3 flex flex-col gap-3"
style={{ width, height }}
>
<Section title="Personal">
<BioRow label="Age" value={age != null ? String(age) : null} />
<BioRow label="Born" value={actress.bornOn} />
</Section>
<Section title="Body">
<BioRow label="Height" value={actress.heightCm != null ? `${actress.heightCm} cm` : null} />
<BioRow label="Weight" value={actress.weightKg != null ? `${actress.weightKg} kg` : null} />
<BioRow label="Cup Size" value={actress.cupSize} />
</Section>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)] mb-1.5">{title}</div>
<div className="space-y-1">{children}</div>
</div>
);
}
function BioRow({ label, value }: { label: string; value: string | null }) {
return (
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-[var(--color-fg-muted)]">{label}</span>
<span className={`font-mono tabular-nums ${value ? "text-[var(--color-fg)]" : "text-[var(--color-fg-muted)]/40"}`}>
{value ?? "—"}
</span>
</div>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div>
<div className="text-2xl font-mono font-semibold tabular-nums">{value}</div>
<div className="text-[10px] uppercase tracking-wider text-[var(--color-fg-muted)]">{label}</div>
</div>
);
}
+245
View File
@@ -0,0 +1,245 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { X, Upload, Users, AlertCircle, Check, Loader2, Star, Gem } from "lucide-react";
import {
previewActressImport,
commitActressImport,
type ImportResult,
} from "@/app/actions/actressImport";
import { listActressCategoriesAction } from "@/app/actions/actressCategoriesQuery";
import type { ActressCategory } from "@/lib/db/queries";
import { cn } from "@/lib/utils";
interface Props {
onClose: () => void;
}
export function ActressImportDialog({ onClose }: Props) {
const router = useRouter();
const [text, setText] = useState("");
const [preview, setPreview] = useState<ImportResult | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [categories, setCategories] = useState<ActressCategory[]>([]);
const [defaultCategoryId, setDefaultCategoryId] = useState<number | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const previewSeq = useRef(0);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
useEffect(() => {
listActressCategoriesAction().then(setCategories).catch(() => {});
}, []);
const favoriteCat = categories.find((c) => c.slug === "favorite");
const vipCat = categories.find((c) => c.slug === "vip");
// Debounced preview as the user types.
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
const requestText = text;
const requestId = ++previewSeq.current;
if (!requestText.trim()) { setPreview(null); setError(null); return; }
debounceRef.current = setTimeout(async () => {
try {
const r = await previewActressImport(requestText);
if (previewSeq.current !== requestId) return;
setPreview(r);
setError(null);
} catch (e) {
if (previewSeq.current !== requestId) return;
setError((e as Error).message);
}
}, 300);
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
}, [text]);
async function onFile(file: File) {
const t = await file.text();
setText(t);
}
async function commit() {
if (!preview || preview.added === 0) return;
setBusy(true);
try {
const defaults = defaultCategoryId != null ? [defaultCategoryId] : [];
await commitActressImport(text, defaults);
router.refresh();
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
if (typeof document === "undefined") return null;
return createPortal(
<div
className="fixed inset-x-0 bottom-0 top-16 z-50 flex items-center justify-center px-4 py-4 fade-in overflow-y-auto"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-[var(--color-bg-0)] rounded-2xl border border-[var(--color-glass-border)] shadow-2xl p-5 w-[min(720px,calc(100vw-32px))] max-h-[calc(100vh-120px)] flex flex-col">
<div className="flex items-center justify-between mb-3 shrink-0">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-[var(--color-cyan)]" />
<div>
<div className="text-base font-medium">Import Actresses</div>
<div className="text-[11px] text-[var(--color-fg-muted)]">
One name per line. Optionally <span className="font-mono">Name | alt names | categories</span>
</div>
</div>
</div>
<button onClick={onClose} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex items-center gap-2 mb-3 shrink-0">
<button
type="button"
onClick={() => fileRef.current?.click()}
className="flex items-center gap-1.5 text-sm px-3 py-2 rounded-lg glass glass-hover"
>
<Upload className="w-4 h-4" /> Choose File
</button>
<span className="text-xs text-[var(--color-fg-muted)]">.txt, .csv (one per line)</span>
<input
ref={fileRef}
type="file"
accept=".txt,.csv,text/plain,text/csv"
hidden
onChange={(e) => { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = ""; }}
/>
</div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Ichika Matsumoto\nAiba Reika | 愛葉れいか | Favorite\nYui Hatano | | VIP, Watchlist`}
rows={8}
className="w-full bg-[var(--color-bg-0)]/40 rounded-lg p-3 text-xs font-mono outline-none border border-[var(--color-glass-border)] focus:border-[var(--color-cyan)] resize-y leading-relaxed shrink-0"
/>
{(favoriteCat || vipCat) && (
<div className="flex items-center gap-2 mt-3 shrink-0">
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Mark All As</span>
<button
type="button"
onClick={() => setDefaultCategoryId((v) => v === null ? null : null)}
className={cn(
"text-xs px-3 py-1 rounded-full font-mono transition-colors",
defaultCategoryId === null ? "bg-[var(--color-cyan)] text-black font-medium" : "glass glass-hover",
)}
>
None
</button>
{vipCat && (
<button
type="button"
onClick={() => setDefaultCategoryId((v) => v === vipCat.id ? null : vipCat.id)}
className={cn(
"flex items-center gap-1.5 text-xs px-3 py-1 rounded-full font-mono transition-colors",
defaultCategoryId === vipCat.id
? "bg-cyan-400/40 text-cyan-100 font-medium ring-1 ring-cyan-300"
: "glass glass-hover",
)}
>
<Gem className="w-3 h-3" /> VIP
</button>
)}
{favoriteCat && (
<button
type="button"
onClick={() => setDefaultCategoryId((v) => v === favoriteCat.id ? null : favoriteCat.id)}
className={cn(
"flex items-center gap-1.5 text-xs px-3 py-1 rounded-full font-mono transition-colors",
defaultCategoryId === favoriteCat.id
? "bg-amber-400/40 text-amber-100 font-medium ring-1 ring-amber-300"
: "glass glass-hover",
)}
>
<Star className="w-3 h-3" /> Favorite
</button>
)}
</div>
)}
{error && (
<div className="mt-3 flex items-start gap-2 text-xs text-red-300 shrink-0">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" /> {error}
</div>
)}
{preview && (
<div className="mt-4 flex-1 min-h-0 flex flex-col">
<div className="flex items-center gap-3 text-xs mb-2 shrink-0">
<span className="flex items-center gap-1 text-[var(--color-mint)]">
<Check className="w-3.5 h-3.5" /> {preview.added} new
</span>
<span className="text-[var(--color-fg-muted)]">·</span>
<span className="text-[var(--color-fg-dim)]">{preview.skipped} already exist</span>
{preview.newCategories.length > 0 && (
<>
<span className="text-[var(--color-fg-muted)]">·</span>
<span className="text-[var(--color-cyan)]">
will create categories: {preview.newCategories.join(", ")}
</span>
</>
)}
</div>
<div className="glass rounded-xl overflow-y-auto flex-1 min-h-0">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-[var(--color-bg-0)]/95 backdrop-blur">
<tr className="text-left text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
<th className="px-3 py-2 w-20">Status</th>
<th className="px-3 py-2">Name</th>
<th className="px-3 py-2">Alt Names</th>
<th className="px-3 py-2">Categories</th>
</tr>
</thead>
<tbody>
{preview.lines.filter((l) => l.status !== "blank").map((l, i) => (
<tr key={i} className="border-t border-[var(--color-glass-border)]/30">
<td className="px-3 py-1.5">
{l.status === "new" && <span className="text-[var(--color-mint)]">+ new</span>}
{l.status === "exists" && <span className="text-[var(--color-fg-muted)]">skip</span>}
{l.status === "error" && <span className="text-red-300">error</span>}
</td>
<td className="px-3 py-1.5 font-medium">{l.name}</td>
<td className="px-3 py-1.5 text-[var(--color-fg-dim)] font-mono">{l.altNames ?? ""}</td>
<td className="px-3 py-1.5 text-[var(--color-fg-dim)] font-mono">{l.categories.join(", ")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex items-center gap-2 mt-4 pt-4 border-t border-[var(--color-glass-border)] shrink-0">
<button onClick={onClose} className="flex-1 text-sm px-3 py-2 rounded-lg glass glass-hover">
Cancel
</button>
<button
onClick={commit}
disabled={busy || !preview || preview.added === 0}
className="flex-1 flex items-center justify-center gap-1.5 text-sm px-3 py-2 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : null}
{preview ? `Import ${preview.added} actress${preview.added === 1 ? "" : "es"}` : "Import"}
</button>
</div>
</div>
</div>,
document.body,
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { X, Loader2 } from "lucide-react";
import { updateActressMeta } from "@/app/actions/actressMeta";
interface Props {
actressId: number;
initial: {
name: string;
altNames: string | null;
notes: string | null;
bornOn?: string | null;
heightCm?: number | null;
weightKg?: number | null;
cupSize?: string | null;
};
onClose: () => void;
}
export function ActressMetaEditor({ actressId, initial, onClose }: Props) {
const router = useRouter();
const [name, setName] = useState(initial.name);
const [altNames, setAltNames] = useState(initial.altNames ?? "");
const [notes, setNotes] = useState(initial.notes ?? "");
const [bornOn, setBornOn] = useState(initial.bornOn ?? "");
const [height, setHeight] = useState(initial.heightCm != null ? String(initial.heightCm) : "");
const [weight, setWeight] = useState(initial.weightKg != null ? String(initial.weightKg) : "");
const [cup, setCup] = useState(initial.cupSize ?? "");
const [busy, setBusy] = useState(false);
const [, start] = useTransition();
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
async function save() {
setBusy(true);
try {
const r = await updateActressMeta(actressId, {
name,
altNames,
notes,
bornOn: bornOn || null,
heightCm: height ? Number(height) : null,
weightKg: weight ? Number(weight) : null,
cupSize: cup || null,
});
router.refresh();
onClose();
if (r && r.slug) router.push(`/actress/${r.slug}`);
} finally {
setBusy(false);
}
}
if (typeof document === "undefined") return null;
return createPortal(
<div
className="fixed inset-x-0 bottom-0 top-16 z-50 flex items-center justify-center px-4 py-4 fade-in overflow-y-auto"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-[var(--color-bg-0)] border border-[var(--color-glass-border)] shadow-2xl rounded-2xl p-5 w-[min(820px,calc(100vw-32px))]">
<div className="flex items-center justify-between mb-4">
<div className="text-base font-medium">Edit Actress</div>
<button onClick={onClose} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]">
<X className="w-5 h-5" />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 items-stretch">
<div className="rounded-xl border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/30 p-4 flex flex-col gap-3">
<SectionHeader>Identity</SectionHeader>
<Field label="Name">
<input
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={80}
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
</Field>
<Field label="Alt Names" hint="comma-separated · used for search (kanji, romaji, nicknames). Reversed name is added automatically.">
<input
value={altNames}
onChange={(e) => setAltNames(e.target.value)}
placeholder="松本いちか"
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
</Field>
<div className="flex-1 flex flex-col">
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1">Notes</div>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="flex-1 min-h-[140px] w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)] resize-none"
/>
</div>
</div>
<div className="rounded-xl border border-[var(--color-glass-border)] bg-[var(--color-bg-1)]/30 p-4 flex flex-col gap-3">
<SectionHeader>Personal</SectionHeader>
<Field label="Born" hint="YYYY-MM-DD · age is computed from this">
<input
type="date"
value={bornOn}
onChange={(e) => setBornOn(e.target.value)}
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
</Field>
<div className="pt-2"><SectionHeader>Body</SectionHeader></div>
<div className="grid grid-cols-3 gap-2">
<Field label="Height (cm)">
<input
type="number"
inputMode="numeric"
value={height}
onChange={(e) => setHeight(e.target.value)}
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
</Field>
<Field label="Weight (kg)">
<input
type="number"
inputMode="numeric"
value={weight}
onChange={(e) => setWeight(e.target.value)}
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
</Field>
<Field label="Cup Size">
<input
value={cup}
onChange={(e) => setCup(e.target.value)}
placeholder="C"
maxLength={6}
className="w-full glass rounded-lg px-3 py-2 text-sm outline-none focus:border-[var(--color-cyan)]"
/>
</Field>
</div>
</div>
</div>
<div className="flex items-center gap-2 mt-5 pt-4 border-t border-[var(--color-glass-border)]">
<button
onClick={onClose}
className="flex-1 text-sm px-3 py-2 rounded-lg glass glass-hover"
>
Cancel
</button>
<button
onClick={save}
disabled={busy || !name.trim()}
className="flex-1 flex items-center justify-center gap-1.5 text-sm px-3 py-2 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
>
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : null} Save
</button>
</div>
</div>
</div>,
document.body,
);
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-cyan)]">{children}</div>
);
}
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1">{label}</div>
{children}
{hint && <div className="text-[10px] text-[var(--color-fg-muted)] mt-1">{hint}</div>}
</div>
);
}
@@ -0,0 +1,328 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { X, Upload, Trash2, Loader2, Move, ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
import { setActressPortraitTransform, clearActressPortrait } from "@/app/actions/actressPortrait";
import type { ActressAllPortraits, PortraitSlotKey } from "@/lib/db/queries";
import { portraitUrl } from "@/lib/assetUrls";
interface Props {
actressId: number;
actressName: string;
initial: ActressAllPortraits;
initialSlot?: PortraitSlotKey;
onClose: () => void;
}
const PHI = 1.618;
const FRAME_H = 360;
const PORTRAIT_H = FRAME_H;
const PORTRAIT_W = Math.round(FRAME_H / PHI);
const HORIZ_H = FRAME_H;
const HORIZ_W = Math.round(FRAME_H * PHI);
const SLOT_LABELS: Record<PortraitSlotKey, string> = { "1": "P1", "2": "P2", "3": "P3", "4": "P4", "h": "L" };
const SLOT_KEYS: PortraitSlotKey[] = ["1", "2", "3", "4", "h"];
function slotKey(s: PortraitSlotKey): keyof ActressAllPortraits {
return s === "h" ? "ph" : (`p${s}` as "p1" | "p2" | "p3" | "p4");
}
export function ActressPortraitEditor({ actressId, actressName, initial, initialSlot = "1", onClose }: Props) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [slot, setSlot] = useState<PortraitSlotKey>(initialSlot);
const [slots, setSlots] = useState<ActressAllPortraits>(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, start] = useTransition();
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
const cur = slots[slotKey(slot)];
const isHorizontal = slot === "h";
const W = isHorizontal ? HORIZ_W : PORTRAIT_W;
const H = isHorizontal ? HORIZ_H : PORTRAIT_H;
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
useEffect(() => {
const ACCEPTED = new Set(["image/jpeg", "image/png", "image/webp"]);
const onPaste = (e: ClipboardEvent) => {
if (busy) return;
const items = e.clipboardData?.items;
if (!items) return;
let imageItem: DataTransferItem | null = null;
let unsupported: string | null = null;
for (const it of items) {
if (it.kind !== "file") continue;
if (ACCEPTED.has(it.type)) { imageItem = it; break; }
if (it.type.startsWith("image/")) unsupported = it.type;
}
if (imageItem) {
e.preventDefault();
const file = imageItem.getAsFile();
if (file) uploadFile(file);
} else if (unsupported) {
e.preventDefault();
setError("Unsupported image format — paste JPEG, PNG, or WebP");
}
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [busy, slot]);
function patchSlot(patch: Partial<ActressAllPortraits[keyof ActressAllPortraits]>) {
setSlots((s) => ({ ...s, [slotKey(slot)]: { ...s[slotKey(slot)], ...patch } }));
}
async function uploadFile(file: File) {
setBusy(true);
setError(null);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/actress-portrait/${actressId}?slot=${slot}`, { method: "POST", body: fd });
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error ?? `upload failed (${res.status})`);
}
const j = await res.json();
patchSlot({ path: j.portraitPath, zoom: 1, offsetX: 0, offsetY: 0 });
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function onPointerDown(e: React.PointerEvent) {
if (!cur.path) return;
(e.target as Element).setPointerCapture(e.pointerId);
dragRef.current = { x: e.clientX, y: e.clientY, ox: cur.offsetX, oy: cur.offsetY };
}
function onPointerMove(e: React.PointerEvent) {
if (!dragRef.current) return;
const dx = e.clientX - dragRef.current.x;
const dy = e.clientY - dragRef.current.y;
patchSlot({ offsetX: dragRef.current.ox + dx, offsetY: dragRef.current.oy + dy });
}
function onPointerUp() { dragRef.current = null; }
const previewRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = previewRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
if (!cur.path) return;
e.preventDefault();
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
patchSlot({ zoom: Math.max(0.5, Math.min(5, cur.zoom * factor)) });
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cur.path, cur.zoom, slot]);
async function save() {
setBusy(true);
try {
await setActressPortraitTransform(actressId, slot, { zoom: cur.zoom, offsetX: cur.offsetX, offsetY: cur.offsetY });
router.refresh();
onClose();
} finally {
setBusy(false);
}
}
function reset() { patchSlot({ zoom: 1, offsetX: 0, offsetY: 0 }); }
async function removePortrait() {
if (!confirm(`Remove ${SLOT_LABELS[slot]}?`)) return;
setBusy(true);
try {
await clearActressPortrait(actressId, slot);
patchSlot({ path: null, zoom: 1, offsetX: 0, offsetY: 0 });
router.refresh();
} finally {
setBusy(false);
}
}
if (typeof document === "undefined") return null;
return createPortal(
<div
className="fixed inset-x-0 bottom-0 top-16 z-50 flex items-center justify-center px-4 py-4 fade-in"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
className="bg-[var(--color-bg-0)] rounded-2xl border border-[var(--color-glass-border)] shadow-2xl p-4"
style={{ width: `min(${isHorizontal ? 800 : 480}px, calc(100vw - 32px))` }}
>
<div className="flex items-center justify-between mb-3">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Portraits</div>
<div className="text-base font-medium truncate">{actressName}</div>
</div>
<button onClick={onClose} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] shrink-0">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex items-center gap-1 mb-3">
{SLOT_KEYS.map((k) => {
const active = k === slot;
const has = !!slots[slotKey(k)].path;
return (
<button
key={k}
type="button"
onClick={() => setSlot(k)}
className={`flex items-center gap-1.5 text-xs font-mono font-semibold px-3 py-1.5 rounded-lg border transition-colors ${
active ? "bg-[var(--color-cyan)] text-black border-transparent" : "glass glass-hover"
}`}
>
{SLOT_LABELS[k]}
<span className={`w-1.5 h-1.5 rounded-full ${has ? "bg-emerald-400" : "bg-white/20"}`} />
</button>
);
})}
</div>
<div className="flex gap-4 items-stretch">
{cur.path ? (
<div
ref={previewRef}
className="relative shrink-0 rounded-xl overflow-hidden bg-black/30 select-none border-2 border-dashed"
style={{
width: W,
height: H,
cursor: "grab",
containerType: "inline-size",
borderColor: "color-mix(in oklch, var(--color-violet) 60%, transparent)",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={cur.path ? portraitUrl({ path: cur.path, slot }) : ""}
alt=""
draggable={false}
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none border-2 border-dashed box-border"
style={{
transform: `translate(-50%, -50%) translate(${cur.offsetX / W * 100}cqw, ${cur.offsetY / W * 100}cqw) scale(${cur.zoom})`,
width: "100cqw",
height: "auto",
borderColor: "color-mix(in oklch, var(--color-cyan) 70%, transparent)",
}}
/>
<div className="absolute bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] uppercase tracking-wider font-mono text-white flex items-center gap-1 pointer-events-none whitespace-nowrap px-1.5 py-0.5 rounded-md bg-black/70 backdrop-blur-sm">
<Move className="w-3 h-3" /> drag · scroll
</div>
</div>
) : (
<button
type="button"
onClick={() => fileRef.current?.click()}
className="shrink-0 rounded-xl border border-dashed border-[var(--color-glass-border-strong)] text-center text-sm text-[var(--color-fg-muted)] hover:border-[var(--color-cyan)] hover:text-[var(--color-fg-dim)] transition-colors flex flex-col items-center justify-center gap-2"
style={{ width: W, height: H }}
>
<Upload className="w-6 h-6" />
Click Or Paste Image
</button>
)}
<div className="flex-1 min-w-0 flex flex-col justify-between">
<div className="space-y-2.5">
<div className="flex items-center gap-2">
<ZoomOut className="w-4 h-4 text-[var(--color-fg-muted)] shrink-0" />
<input
type="range"
min={0.5}
max={5}
step={0.01}
value={cur.zoom}
onChange={(e) => patchSlot({ zoom: Number(e.target.value) })}
className="flex-1 accent-[var(--color-cyan)] min-w-0"
disabled={!cur.path}
/>
<ZoomIn className="w-4 h-4 text-[var(--color-fg-muted)] shrink-0" />
</div>
<div className="text-[10px] font-mono text-[var(--color-fg-dim)] tabular-nums text-right -mt-1">
{cur.zoom.toFixed(2)}x
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busy}
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
>
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
{cur.path ? "Replace" : "Upload"}
</button>
<button
type="button"
onClick={reset}
disabled={!cur.path || busy}
title="Reset zoom & position"
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
{cur.path && (
<button
type="button"
onClick={removePortrait}
disabled={busy}
title="Remove"
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
hidden
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadFile(f); e.target.value = ""; }}
/>
</div>
{error && <div className="text-xs text-red-400">{error}</div>}
</div>
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)]">
<button
type="button"
onClick={onClose}
className="flex-1 text-xs px-3 py-1.5 rounded-lg glass glass-hover"
>
Cancel
</button>
<button
type="button"
onClick={save}
disabled={busy || !cur.path}
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
>
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null} Save
</button>
</div>
</div>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,97 @@
"use client";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
type Ctx = {
ids: Set<number>;
has: (id: number) => boolean;
toggle: (id: number) => void;
selectMany: (ids: number[]) => void;
setMany: (ids: number[]) => void;
selectRangeTo: (id: number, orderedIds: number[]) => void;
clear: () => void;
lastClickedId: number | null;
};
const ActressSelectCtx = createContext<Ctx | null>(null);
export function ActressSelectionProvider({ children }: { children: React.ReactNode }) {
const [ids, setIds] = useState<Set<number>>(new Set());
const [lastClickedId, setLastClickedId] = useState<number | null>(null);
const toggle = useCallback((id: number) => {
setIds((cur) => {
const next = new Set(cur);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
setLastClickedId(id);
}, []);
const selectMany = useCallback((newIds: number[]) => {
setIds((cur) => {
const next = new Set(cur);
newIds.forEach((i) => next.add(i));
return next;
});
}, []);
const setMany = useCallback((newIds: number[]) => {
setIds(new Set(newIds));
}, []);
const selectRangeTo = useCallback((id: number, orderedIds: number[]) => {
const last = lastClickedId;
if (last == null) {
setIds((cur) => {
const next = new Set(cur);
next.add(id);
return next;
});
setLastClickedId(id);
return;
}
const a = orderedIds.indexOf(last);
const b = orderedIds.indexOf(id);
if (a === -1 || b === -1) {
setIds((cur) => {
const next = new Set(cur);
next.add(id);
return next;
});
setLastClickedId(id);
return;
}
const [start, end] = a < b ? [a, b] : [b, a];
const range = orderedIds.slice(start, end + 1);
setIds((cur) => {
const next = new Set(cur);
range.forEach((i) => next.add(i));
return next;
});
setLastClickedId(id);
}, [lastClickedId]);
const clear = useCallback(() => {
setIds(new Set());
setLastClickedId(null);
}, []);
const value = useMemo<Ctx>(() => ({
ids,
has: (id) => ids.has(id),
toggle,
selectMany,
setMany,
selectRangeTo,
clear,
lastClickedId,
}), [ids, toggle, selectMany, setMany, selectRangeTo, clear, lastClickedId]);
return <ActressSelectCtx.Provider value={value}>{children}</ActressSelectCtx.Provider>;
}
export function useActressSelection() {
const ctx = useContext(ActressSelectCtx);
if (!ctx) throw new Error("useActressSelection must be used within ActressSelectionProvider");
return ctx;
}
+19
View File
@@ -0,0 +1,19 @@
import { Star, Gem, Crown, Heart, Bookmark, Tag, Award, Flame, Eye, EyeOff } from "lucide-react";
const ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
star: Star,
gem: Gem,
crown: Crown,
heart: Heart,
bookmark: Bookmark,
tag: Tag,
award: Award,
flame: Flame,
eye: Eye,
"eye-off": EyeOff,
};
export function CategoryIcon({ name, className }: { name: string | null; className?: string }) {
const Icon = (name ? ICONS[name] : undefined) ?? Tag;
return <Icon className={className} />;
}
+54
View File
@@ -0,0 +1,54 @@
import Link from "next/link";
import { Users, User } from "lucide-react";
import type { CoStar } from "@/lib/db/queries";
import { portraitUrl } from "@/lib/assetUrls";
export function CoStarsRow({ actressName, costars }: { actressName: string; costars: CoStar[] }) {
if (costars.length === 0) return null;
return (
<section className="my-6">
<div className="flex items-center gap-2 mb-3">
<Users className="w-4 h-4 text-[var(--color-fg-muted)]" />
<h2 className="text-xs uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
Frequent co-stars
</h2>
<span className="text-[10px] font-mono text-[var(--color-fg-muted)]">
({costars.length})
</span>
</div>
<div className="flex flex-wrap gap-2">
{costars.map((c) => (
<Link
key={c.id}
href={`/actress/${c.slug}`}
title={`${c.shared} cover${c.shared === 1 ? "" : "s"} with ${actressName}`}
className="group flex items-center gap-2 pl-1 pr-3 py-1 rounded-full glass glass-hover"
>
<span className="relative w-7 h-7 rounded-full overflow-hidden bg-[var(--color-bg-2)] shrink-0">
{c.portraitPath ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={portraitUrl({ path: c.portraitPath, slug: c.slug, slot: "1" })}
alt={c.name}
draggable={false}
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none"
style={{
transform: `translate(-50%, -50%) translate(${c.portraitOffsetX}px, ${c.portraitOffsetY}px) scale(${c.portraitZoom})`,
width: 28,
height: "auto",
}}
/>
) : (
<span className="absolute inset-0 grid place-items-center text-[var(--color-fg-muted)]">
<User className="w-3.5 h-3.5" />
</span>
)}
</span>
<span className="text-sm">{c.name}</span>
<span className="text-[10px] font-mono text-[var(--color-cyan)]">{c.shared}</span>
</Link>
))}
</div>
</section>
);
}
@@ -0,0 +1,334 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { X, Upload, Trash2, Loader2, Move, ZoomIn, ZoomOut, RotateCcw } from "lucide-react";
import { setCategoryCoverTransform, clearCategoryCover, type CategoryCoverSlot } from "@/app/actions/categoryCover";
import { categoryCoverUrl } from "@/lib/assetUrls";
interface CoverState {
path: string | null;
zoom: number;
offsetX: number;
offsetY: number;
}
interface Props {
categoryId: number;
categoryName: string;
initial: { portrait: CoverState; landscape: CoverState };
initialSlot?: CategoryCoverSlot;
onClose: () => void;
}
const PHI = 1.618;
const FRAME_H = 360;
const PORTRAIT_H = FRAME_H;
const PORTRAIT_W = Math.round(FRAME_H / PHI);
const LANDSCAPE_H = FRAME_H;
const LANDSCAPE_W = Math.round(FRAME_H * PHI);
const SLOT_LABELS: Record<CategoryCoverSlot, string> = { portrait: "P", landscape: "L" };
const SLOT_KEYS: CategoryCoverSlot[] = ["portrait", "landscape"];
export function CategoryCoverEditor({ categoryId, categoryName, initial, initialSlot = "portrait", onClose }: Props) {
const router = useRouter();
const fileRef = useRef<HTMLInputElement>(null);
const [slot, setSlot] = useState<CategoryCoverSlot>(initialSlot);
const [slots, setSlots] = useState(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, start] = useTransition();
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
const cur = slots[slot];
const isLandscape = slot === "landscape";
const W = isLandscape ? LANDSCAPE_W : PORTRAIT_W;
const H = isLandscape ? LANDSCAPE_H : PORTRAIT_H;
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
useEffect(() => {
const ACCEPTED = new Set(["image/jpeg", "image/png", "image/webp"]);
const onPaste = (e: ClipboardEvent) => {
if (busy) return;
const items = e.clipboardData?.items;
if (!items) return;
let imageItem: DataTransferItem | null = null;
let unsupported: string | null = null;
for (const it of items) {
if (it.kind !== "file") continue;
if (ACCEPTED.has(it.type)) { imageItem = it; break; }
if (it.type.startsWith("image/")) unsupported = it.type;
}
if (imageItem) {
e.preventDefault();
const file = imageItem.getAsFile();
if (file) uploadFile(file);
} else if (unsupported) {
e.preventDefault();
setError("Unsupported image format — paste JPEG, PNG, or WebP");
}
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [busy, slot]);
function patchSlot(patch: Partial<CoverState>) {
setSlots((s) => ({ ...s, [slot]: { ...s[slot], ...patch } }));
}
async function uploadFile(file: File) {
setBusy(true);
setError(null);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/category-cover/${categoryId}?slot=${slot}`, { method: "POST", body: fd });
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error ?? `upload failed (${res.status})`);
}
const j = await res.json();
patchSlot({ path: j.coverPath, zoom: 1, offsetX: 0, offsetY: 0 });
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
function onPointerDown(e: React.PointerEvent) {
if (!cur.path) return;
(e.target as Element).setPointerCapture(e.pointerId);
dragRef.current = { x: e.clientX, y: e.clientY, ox: cur.offsetX, oy: cur.offsetY };
}
function onPointerMove(e: React.PointerEvent) {
if (!dragRef.current) return;
const dx = e.clientX - dragRef.current.x;
const dy = e.clientY - dragRef.current.y;
patchSlot({ offsetX: dragRef.current.ox + dx, offsetY: dragRef.current.oy + dy });
}
function onPointerUp() { dragRef.current = null; }
const previewRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = previewRef.current;
if (!el) return;
const handler = (e: WheelEvent) => {
if (!cur.path) return;
e.preventDefault();
const factor = e.deltaY < 0 ? 1.08 : 1 / 1.08;
patchSlot({ zoom: Math.max(0.5, Math.min(5, cur.zoom * factor)) });
};
el.addEventListener("wheel", handler, { passive: false });
return () => el.removeEventListener("wheel", handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cur.path, cur.zoom, slot]);
async function save() {
setBusy(true);
try {
await setCategoryCoverTransform(categoryId, slot, {
zoom: cur.zoom, offsetX: cur.offsetX, offsetY: cur.offsetY,
});
router.refresh();
onClose();
} finally {
setBusy(false);
}
}
function reset() { patchSlot({ zoom: 1, offsetX: 0, offsetY: 0 }); }
async function removeCover() {
if (!confirm(`Remove ${SLOT_LABELS[slot]} cover for "${categoryName}"?`)) return;
setBusy(true);
try {
await clearCategoryCover(categoryId, slot);
patchSlot({ path: null, zoom: 1, offsetX: 0, offsetY: 0 });
router.refresh();
} finally {
setBusy(false);
}
}
if (typeof document === "undefined") return null;
return createPortal(
<div
className="fixed inset-x-0 bottom-0 top-16 z-50 flex items-center justify-center px-4 py-4 fade-in"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
className="bg-[var(--color-bg-0)] rounded-2xl border border-[var(--color-glass-border)] shadow-2xl p-4"
style={{ width: `min(${isLandscape ? 800 : 480}px, calc(100vw - 32px))` }}
>
<div className="flex items-center justify-between mb-3">
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">Category Cover</div>
<div className="text-base font-medium truncate">{categoryName}</div>
</div>
<button onClick={onClose} className="text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] shrink-0">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex items-center gap-1 mb-3">
{SLOT_KEYS.map((k) => {
const active = k === slot;
const has = !!slots[k].path;
return (
<button
key={k}
type="button"
onClick={() => setSlot(k)}
className={`flex items-center gap-1.5 text-xs font-mono font-semibold px-3 py-1.5 rounded-lg border transition-colors ${
active ? "bg-[var(--color-cyan)] text-black border-transparent" : "glass glass-hover"
}`}
>
{SLOT_LABELS[k]}
<span className={`w-1.5 h-1.5 rounded-full ${has ? "bg-emerald-400" : "bg-white/20"}`} />
</button>
);
})}
</div>
<div className="flex gap-4 items-stretch">
{cur.path ? (
<div
ref={previewRef}
className="relative shrink-0 rounded-xl overflow-hidden bg-black/30 select-none border-2 border-dashed"
style={{
width: W,
height: H,
cursor: "grab",
containerType: "inline-size",
borderColor: "color-mix(in oklch, var(--color-violet) 60%, transparent)",
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={categoryCoverUrl(cur.path)}
alt=""
draggable={false}
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none border-2 border-dashed box-border"
style={{
// cqw scales offsets with frame width so the grid card
// reproduces this preview at any column size.
transform: `translate(-50%, -50%) translate(${cur.offsetX / W * 100}cqw, ${cur.offsetY / W * 100}cqw) scale(${cur.zoom})`,
width: "100cqw",
height: "auto",
borderColor: "color-mix(in oklch, var(--color-cyan) 70%, transparent)",
}}
/>
<div className="absolute bottom-1.5 left-1/2 -translate-x-1/2 text-[9px] uppercase tracking-wider font-mono text-white flex items-center gap-1 pointer-events-none whitespace-nowrap px-1.5 py-0.5 rounded-md bg-black/70 backdrop-blur-sm">
<Move className="w-3 h-3" /> drag · scroll
</div>
</div>
) : (
<button
type="button"
onClick={() => fileRef.current?.click()}
className="shrink-0 rounded-xl border border-dashed border-[var(--color-glass-border-strong)] text-center text-sm text-[var(--color-fg-muted)] hover:border-[var(--color-cyan)] hover:text-[var(--color-fg-dim)] transition-colors flex flex-col items-center justify-center gap-2"
style={{ width: W, height: H }}
>
<Upload className="w-6 h-6" />
Click Or Paste Image
</button>
)}
<div className="flex-1 min-w-0 flex flex-col justify-between">
<div className="space-y-2.5">
<div className="flex items-center gap-2">
<ZoomOut className="w-4 h-4 text-[var(--color-fg-muted)] shrink-0" />
<input
type="range"
min={0.5}
max={5}
step={0.01}
value={cur.zoom}
onChange={(e) => patchSlot({ zoom: Number(e.target.value) })}
className="flex-1 accent-[var(--color-cyan)] min-w-0"
disabled={!cur.path}
/>
<ZoomIn className="w-4 h-4 text-[var(--color-fg-muted)] shrink-0" />
</div>
<div className="text-[10px] font-mono text-[var(--color-fg-dim)] tabular-nums text-right -mt-1">
{cur.zoom.toFixed(2)}x
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={busy}
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg glass glass-hover disabled:opacity-50"
>
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
{cur.path ? "Replace" : "Upload"}
</button>
<button
type="button"
onClick={reset}
disabled={!cur.path || busy}
title="Reset zoom & position"
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg glass glass-hover disabled:opacity-40"
>
<RotateCcw className="w-3.5 h-3.5" />
</button>
{cur.path && (
<button
type="button"
onClick={removeCover}
disabled={busy}
title="Remove"
className="flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 disabled:opacity-40"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
hidden
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadFile(f); e.target.value = ""; }}
/>
</div>
{error && <div className="text-xs text-red-400">{error}</div>}
</div>
<div className="flex items-center gap-2 pt-3 border-t border-[var(--color-glass-border)]">
<button
type="button"
onClick={onClose}
className="flex-1 text-xs px-3 py-1.5 rounded-lg glass glass-hover"
>
Cancel
</button>
<button
type="button"
onClick={save}
disabled={busy || !cur.path}
className="flex-1 flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
>
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : null} Save
</button>
</div>
</div>
</div>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { Pencil, ImagePlus } from "lucide-react";
import { CategoryCoverEditor } from "./CategoryCoverEditor";
import { categoryCoverUrl } from "@/lib/assetUrls";
import type { CategoryCoverSlot } from "@/app/actions/categoryCover";
interface CoverState {
path: string | null;
zoom: number;
offsetX: number;
offsetY: number;
}
interface Props {
categoryId: number;
categoryName: string;
categoryColor: string | null;
portrait: CoverState;
landscape: CoverState;
}
const PHI = 1.618;
// Match heights so the two slots sit on a clean baseline; widths follow
// the golden ratio for each orientation.
const FRAME_H = 240;
const PORTRAIT_H = FRAME_H;
const PORTRAIT_W = Math.round(FRAME_H / PHI);
const LANDSCAPE_H = FRAME_H;
const LANDSCAPE_W = Math.round(FRAME_H * PHI);
export function CategoryCoverPanel({ categoryId, categoryName, categoryColor, portrait, landscape }: Props) {
const [open, setOpen] = useState<CategoryCoverSlot | null>(null);
return (
<>
<div className="flex flex-wrap gap-4">
<CoverSlot
label="Portrait"
width={PORTRAIT_W}
height={PORTRAIT_H}
state={portrait}
color={categoryColor}
name={categoryName}
onEdit={() => setOpen("portrait")}
/>
<CoverSlot
label="Landscape"
width={LANDSCAPE_W}
height={LANDSCAPE_H}
state={landscape}
color={categoryColor}
name={categoryName}
onEdit={() => setOpen("landscape")}
/>
</div>
{open && (
<CategoryCoverEditor
categoryId={categoryId}
categoryName={categoryName}
initial={{ portrait, landscape }}
initialSlot={open}
onClose={() => setOpen(null)}
/>
)}
</>
);
}
function CoverSlot({
label,
width,
height,
state,
color,
name,
onEdit,
}: {
label: string;
width: number;
height: number;
state: CoverState;
color: string | null;
name: string;
onEdit: () => void;
}) {
return (
<div className="space-y-1.5">
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">{label}</div>
<button
type="button"
onClick={onEdit}
className="group relative rounded-xl overflow-hidden border border-[var(--color-glass-border-strong)] hover:border-[var(--color-cyan)] transition-colors"
style={{ width, height }}
>
{state.path ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={categoryCoverUrl(state.path)}
alt=""
draggable={false}
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none"
style={{
transform: `translate(-50%, -50%) translate(${state.offsetX}px, ${state.offsetY}px) scale(${state.zoom})`,
width,
height: "auto",
}}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors flex items-center justify-center">
<span className="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-md bg-black/70 backdrop-blur-sm">
<Pencil className="w-3 h-3" /> Edit
</span>
</div>
</>
) : (
<CategoryCoverPlaceholder name={name} color={color} />
)}
</button>
</div>
);
}
function CategoryCoverPlaceholder({ name, color }: { name: string; color: string | null }) {
const accent = color ?? "var(--color-fg-muted)";
return (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-2 text-center px-3"
style={{
background: `linear-gradient(135deg, color-mix(in oklch, ${accent} 25%, var(--color-bg-1)) 0%, var(--color-bg-1) 70%)`,
}}
>
<div className="w-2.5 h-2.5 rounded-full" style={{ background: accent }} />
<div className="text-sm font-medium truncate max-w-full" style={{ color: accent }}>{name}</div>
<div className="flex items-center gap-1 text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
<ImagePlus className="w-3 h-3" /> add cover
</div>
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Pencil } from "lucide-react";
import { CategoryCoverEditor } from "./CategoryCoverEditor";
import { categoryCoverUrl } from "@/lib/assetUrls";
import { cn } from "@/lib/utils";
import type { CategoryCoverSlot } from "@/app/actions/categoryCover";
// Mirror the editor's canonical frame so cqw-based offsets line up.
const PHI = 1.618;
const FRAME_H = 360;
const CANONICAL_PORTRAIT_W = Math.round(FRAME_H / PHI);
const CANONICAL_LANDSCAPE_W = Math.round(FRAME_H * PHI);
export interface CategoryGridCardProps {
id: number;
slug: string;
name: string;
color: string | null;
description: string | null;
tagCount: number;
imageCount: number;
view: "portrait" | "landscape";
portrait: { path: string | null; zoom: number; offsetX: number; offsetY: number };
landscape: { path: string | null; zoom: number; offsetX: number; offsetY: number };
}
export function CategoryGridCard(props: CategoryGridCardProps) {
const { view, portrait, landscape, name, color, slug, description, tagCount, imageCount, id } = props;
const [editing, setEditing] = useState<CategoryCoverSlot | null>(null);
const cur = view === "portrait" ? portrait : landscape;
const aspect = view === "portrait" ? "aspect-[1/1.618]" : "aspect-[1.618/1]";
const canonicalW = view === "portrait" ? CANONICAL_PORTRAIT_W : CANONICAL_LANDSCAPE_W;
function openEditor(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setEditing(view);
}
return (
<>
<Link
href={`/category/${slug}`}
className={cn(
"group relative block rounded-2xl overflow-hidden glass glass-hover",
aspect,
)}
style={{ containerType: "inline-size" }}
>
{cur.path ? (
<div className="absolute inset-0 overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={categoryCoverUrl(cur.path)}
alt=""
draggable={false}
className="absolute top-1/2 left-1/2 max-w-none origin-center pointer-events-none h-auto"
style={{
transform: `translate(-50%, -50%) translate(${cur.offsetX / canonicalW * 100}cqw, ${cur.offsetY / canonicalW * 100}cqw) scale(${cur.zoom})`,
width: "100cqw",
}}
/>
</div>
) : (
<Placeholder name={name} color={color} />
)}
<button
type="button"
onClick={openEditor}
aria-label={`Edit ${view} cover`}
title={`Edit ${view} cover`}
className={cn(
"absolute bottom-3 right-3 z-20 w-8 h-8 grid place-items-center rounded-md",
"bg-black/60 backdrop-blur-md text-white border border-white/20",
"opacity-0 group-hover:opacity-100 transition-all",
"hover:bg-[var(--color-cyan)]/30 hover:border-[var(--color-cyan)] hover:text-[var(--color-cyan)]",
"hover:scale-110 hover:shadow-lg active:scale-95",
)}
>
<Pencil className="w-4 h-4" />
</button>
<div className="absolute inset-x-0 bottom-0 p-3 pt-10 bg-gradient-to-t from-black/85 via-black/55 to-transparent">
<div className="flex items-center gap-2 min-w-0">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ background: color ?? "var(--color-fg-muted)" }}
/>
<div className="font-semibold truncate text-white">{name}</div>
</div>
{view === "landscape" && description && (
<p className="text-[11px] text-white/70 mt-1 line-clamp-2">{description}</p>
)}
<div className="text-[10px] font-mono text-white/60 tabular-nums mt-1">
{tagCount} tag{tagCount === 1 ? "" : "s"} · {imageCount} cover{imageCount === 1 ? "" : "s"}
</div>
</div>
</Link>
{editing && (
<CategoryCoverEditor
categoryId={id}
categoryName={name}
initial={{ portrait, landscape }}
initialSlot={editing}
onClose={() => setEditing(null)}
/>
)}
</>
);
}
function Placeholder({ name, color }: { name: string; color: string | null }) {
const accent = color ?? "var(--color-fg-muted)";
return (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
background: `linear-gradient(135deg, color-mix(in oklch, ${accent} 30%, var(--color-bg-1)) 0%, var(--color-bg-1) 80%)`,
}}
>
<div
className="text-center px-3 text-2xl font-semibold tracking-tight uppercase opacity-70"
style={{
color: accent,
textShadow: "0 2px 8px rgba(0,0,0,0.4)",
}}
>
{name}
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More