Huntarr/templates/layout.html

1182 lines
64 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Huntarr{% if search_query %} · {{ search_query }}{% endif %}</title>
<meta name="description" content="Browse StashDB and request scenes via Whisparr.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Inter', 'sans-serif'] },
fontSize: { '2xs': ['10px', '14px'] },
}
}
}
</script>
<style>
*, *::before, *::after { box-sizing: border-box; }
html { background: #030507; scroll-behavior: smooth; }
body {
background: #030507;
color: #94a3b8;
font-family: 'Inter', sans-serif;
-webkit-font-smoothing: antialiased;
min-height: 100dvh;
}
/* Scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 2px; }
/* Safe area padding for iPhone notch etc */
.safe-bottom { padding-bottom: max(env(safe-area-inset-bottom), 12px); }
.safe-top { padding-top: env(safe-area-inset-top); }
/* ── Top bar ── */
#topbar {
background: rgba(3, 5, 7, 0.94);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid #0f172a;
}
/* ── Bottom nav dock ── */
#bottom-nav {
background: rgba(3, 5, 7, 0.96);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid #0f172a;
}
/* ── Filter Sheet ── */
#filter-sheet {
transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.3s ease;
}
#filter-sheet.sheet-hidden {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
}
#filter-sheet.sheet-visible {
transform: translateY(0);
opacity: 1;
pointer-events: all;
}
/* Desktop sidebar filter — stays fixed, never in document flow */
@media (min-width: 768px) {
#filter-sheet {
transform: none !important;
opacity: 1 !important;
pointer-events: all !important;
position: fixed !important;
top: 3rem;
left: 0;
bottom: 0;
width: 14rem;
border-radius: 0;
border-top: none;
border-left: none;
border-bottom: none;
}
}
/* ── Scene card ── */
.scene-card { transition: opacity 0.15s ease; }
.scene-card:active { opacity: 0.85; }
/* ── Image fallback ── */
.img-wrap img { transition: opacity 0.2s ease; }
.img-fallback {
background: #0d1117;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
/* ── Request button ── */
.btn-request {
background: #f1f5f9;
color: #0f172a;
transition: background 0.1s ease, color 0.1s ease;
}
.btn-request:hover { background: #ffffff; }
.btn-request:active { background: #e2e8f0; }
/* ── Status badges (flat) ── */
.badge-library { color: #10b981; background: rgba(16,185,129,0.06); border: 1px solid rgba(16,185,129,0.12); }
.badge-tracking { color: #f59e0b; background: rgba(245,158,11,0.06); border: 1px solid rgba(245,158,11,0.12); }
/* ── Nav tab active ── */
.tab-active { color: #f1f5f9; }
.tab-inactive { color: #475569; }
.tab-inactive:hover { color: #94a3b8; }
/* ── Bottom nav item ── */
.bnav-active { color: #f1f5f9; }
.bnav-inactive { color: #334155; }
/* ── Tag pill ── */
.tag-pill {
background: #0d1117;
border: 1px solid #1a2035;
color: #64748b;
transition: background 0.1s, color 0.1s, border-color 0.1s;
}
.tag-pill:hover { background: #151e2e; color: #cbd5e1; border-color: #2a3a58; }
/* ── Input / Select ── */
input[type="text"], input[type="url"], input[type="password"], select {
background: #0c1117;
border: 1px solid #1e293b;
color: #cbd5e1;
border-radius: 6px;
font-size: 12px;
outline: none;
transition: border-color 0.15s;
-webkit-appearance: none;
}
input:focus, select:focus { border-color: #334155; }
input::placeholder { color: #334155; }
/* select arrow */
select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='%23475569' d='M4.5 6l3.5 4 3.5-4H4.5z'/%3e%3c/svg%3e");
background-position: right 8px center;
background-repeat: no-repeat;
background-size: 14px;
padding-right: 28px;
}
/* ── Modal ── */
#settings-modal { background: rgba(0,0,0,0.7); backdrop-filter: blur(8px); }
.modal-panel {
background: #080c12;
border: 1px solid #1e293b;
}
/* ── Checkbox custom ── */
input[type="checkbox"] {
width: 14px; height: 14px;
border: 1px solid #334155;
border-radius: 3px;
background: #0c1117;
cursor: pointer;
appearance: none;
-webkit-appearance: none;
flex-shrink: 0;
position: relative;
}
input[type="checkbox"]:checked {
background: #334155;
border-color: #475569;
}
input[type="checkbox"]:checked::after {
content: '';
position: absolute;
left: 3px; top: 1px;
width: 5px; height: 8px;
border: 2px solid #f1f5f9;
border-top: none; border-left: none;
transform: rotate(45deg);
}
/* ── Toast ── */
.toast { transition: transform 0.25s ease, opacity 0.25s ease; }
.toast-in { transform: translateY(0); opacity: 1; }
.toast-out { transform: translateY(8px); opacity: 0; }
/* ── Performer hover ── */
.perf-card:hover .perf-ring { border-color: #334155; }
.perf-card:hover .perf-name { color: #f1f5f9; }
/* ── Spinner ── */
@keyframes spin { to { transform: rotate(360deg); } }
.spin { animation: spin 0.7s linear infinite; }
</style>
</head>
<body class="antialiased">
<!-- ╔══════════════════════════════════════════════════════════╗
║ TOP BAR ║
╚══════════════════════════════════════════════════════════╝ -->
<header id="topbar" class="fixed top-0 left-0 right-0 z-40 h-12 safe-top flex items-center px-4 gap-3">
<!-- Brand -->
<a href="/" class="flex items-center gap-2 shrink-0 mr-2">
<div class="w-6 h-6 bg-slate-100 rounded flex items-center justify-center shrink-0">
<svg class="w-3.5 h-3.5 text-slate-950" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 4v16M17 4v16M3 8h4m10 0h4M3 16h4m10 0h4M4 20h16a1 1 0 001-1V5a1 1 0 00-1-1H4a1 1 0 00-1 1v14a1 1 0 001 1z"/>
</svg>
</div>
<span class="text-xs font-bold text-slate-100 tracking-tight hidden sm:block">HUNTARR</span>
</a>
<!-- Desktop Tabs (hidden on mobile — replaced by bottom nav) -->
<nav class="hidden md:flex items-center gap-0.5 shrink-0">
{% for tid, tlabel in [('scenes','Scenes'),('performers','Performers'),('studios','Studios'),('tags','Tags')] %}
<a href="/?tab={{ tid }}"
class="px-3 py-1.5 rounded text-xs font-semibold tracking-tight transition-colors {{ 'tab-active' if active_tab == tid else 'tab-inactive' }}">
{{ tlabel }}
</a>
{% endfor %}
</nav>
<div class="w-px h-4 bg-slate-900 hidden md:block shrink-0"></div>
<!-- Filter toggle — desktop -->
{% if active_tab == 'scenes' %}
<button id="filter-toggle-btn" onclick="toggleFilterDesktop()"
class="hidden md:flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-slate-900 text-2xs font-semibold text-slate-500 hover:text-slate-300 hover:border-slate-800 transition-all shrink-0">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
</svg>
Filters
</button>
{% endif %}
<!-- Search -->
<form action="/" method="GET" class="flex-1 min-w-0">
<input type="hidden" name="tab" value="{{ active_tab }}">
<input type="hidden" name="sort" value="{{ sort }}">
{% if active_tab == 'scenes' and missing_only %}<input type="hidden" name="missing_only" value="true">{% endif %}
<div class="relative">
<svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-slate-700 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input type="text" name="search" value="{{ search_query or '' }}"
placeholder="Search {{ active_tab }}…"
class="w-full pl-7 pr-3 py-1.5 text-xs rounded-md">
</div>
</form>
<!-- Right cluster -->
<div class="flex items-center gap-2 shrink-0">
<!-- Count -->
<span class="hidden lg:block text-2xs font-mono text-slate-700">{{ "{:,}".format(count) }}</span>
<!-- Status dots -->
<div class="hidden md:flex items-center gap-2">
<span class="flex items-center gap-1 text-2xs font-medium {{ 'text-emerald-600' if stashdb_status.configured else 'text-slate-800' }}">
<span class="w-1.5 h-1.5 rounded-full {{ 'bg-emerald-500' if stashdb_status.configured else 'bg-slate-800' }}"></span>
StashDB
</span>
<span class="flex items-center gap-1 text-2xs font-medium {{ 'text-emerald-600' if whisparr_status.online else 'text-slate-800' }}">
<span class="w-1.5 h-1.5 rounded-full {{ 'bg-emerald-500' if whisparr_status.online else 'bg-slate-800' }}"></span>
Whisparr
</span>
</div>
<!-- Mobile filter trigger (scenes only) -->
{% if active_tab == 'scenes' %}
<button onclick="openFilterSheet()" class="md:hidden w-8 h-8 flex items-center justify-center rounded border border-slate-900 text-slate-500">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"/>
</svg>
</button>
{% endif %}
<!-- Settings -->
<button onclick="openSettingsModal()" class="w-8 h-8 flex items-center justify-center rounded border border-slate-900 text-slate-600 hover:text-slate-400 hover:border-slate-800 transition-all">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
</div>
</header>
<!-- ╔══════════════════════════════════════════════════════════╗
║ DESKTOP FILTER STRIP — below topbar when open ║
╚══════════════════════════════════════════════════════════╝ -->
{% if active_tab in ['performers', 'studios', 'tags'] %}
<div class="hidden md:flex fixed top-12 left-0 right-0 z-20 h-9 items-center px-4 gap-4 border-b border-slate-900" style="background: rgba(3,5,7,0.97);">
<form action="/" method="GET" class="flex items-center gap-3">
<input type="hidden" name="tab" value="{{ active_tab }}">
{% if search_query %}<input type="hidden" name="search" value="{{ search_query }}">{% endif %}
<span class="text-2xs font-semibold text-slate-700 uppercase tracking-widest shrink-0">Sort</span>
<select name="sort" onchange="this.form.submit()" class="px-2 py-1 text-2xs rounded">
{% if active_tab == 'performers' %}
<option value="NAME_ASC" {% if sort == 'NAME_ASC' %}selected{% endif %}>Name AZ</option>
<option value="NAME_DESC" {% if sort == 'NAME_DESC' %}selected{% endif %}>Name ZA</option>
<option value="BIRTHDATE_ASC" {% if sort == 'BIRTHDATE_ASC' %}selected{% endif %}>Birthdate ↑</option>
<option value="SCENE_COUNT_DESC" {% if sort == 'SCENE_COUNT_DESC' %}selected{% endif %}>Scene Count ↓</option>
{% elif active_tab == 'studios' %}
<option value="NAME_ASC" {% if sort == 'NAME_ASC' %}selected{% endif %}>Name AZ</option>
<option value="NAME_DESC" {% if sort == 'NAME_DESC' %}selected{% endif %}>Name ZA</option>
<option value="CREATED_AT_DESC" {% if sort == 'CREATED_AT_DESC' %}selected{% endif %}>Newest</option>
{% elif active_tab == 'tags' %}
<option value="NAME_ASC" {% if sort == 'NAME_ASC' %}selected{% endif %}>Name AZ</option>
<option value="NAME_DESC" {% if sort == 'NAME_DESC' %}selected{% endif %}>Name ZA</option>
{% endif %}
</select>
</form>
<span class="text-2xs font-mono text-slate-800">{{ "{:,}".format(count) }}</span>
</div>
{% endif %}
<!-- ╔══════════════════════════════════════════════════════════╗
║ FILTER SHEET — mobile bottom sheet / desktop sidebar ║
╚══════════════════════════════════════════════════════════╝ -->
{% if active_tab == 'scenes' %}
<!-- Mobile backdrop -->
<div id="sheet-backdrop" onclick="closeFilterSheet()"
class="md:hidden hidden fixed inset-0 z-40" style="background: rgba(0,0,0,0.5);"></div>
<!-- Filter panel -->
<div id="filter-sheet"
class="sheet-hidden
fixed
bottom-0 md:bottom-auto md:top-12
left-0 right-0 md:right-auto
z-50 md:z-30
md:w-56 md:h-[calc(100vh-3rem)] md:overflow-y-auto md:border-r md:border-slate-900
rounded-t-2xl md:rounded-none
bg-slate-950 md:bg-[#030507]">
<!-- Mobile handle -->
<div class="md:hidden flex justify-center pt-3 pb-1">
<div class="w-8 h-1 bg-slate-800 rounded-full"></div>
</div>
<div class="p-4 md:p-5 flex flex-col gap-5">
<!-- Mobile header -->
<div class="md:hidden flex items-center justify-between">
<span class="text-xs font-bold text-slate-300 tracking-tight">Filters</span>
<button onclick="closeFilterSheet()" class="text-slate-600 hover:text-slate-400 text-xs">Done</button>
</div>
<!-- Desktop header -->
<div class="hidden md:flex items-center justify-between">
<span class="text-2xs font-bold text-slate-700 uppercase tracking-widest">Filters</span>
<span class="text-2xs font-mono text-slate-800">{{ "{:,}".format(count) }}</span>
</div>
<form id="filter-form" action="/" method="GET" class="flex flex-col gap-4">
<input type="hidden" name="tab" value="scenes">
{% if search_query %}<input type="hidden" name="search" value="{{ search_query }}">{% endif %}
<!-- Sort -->
<div class="flex flex-col gap-1.5">
<label class="text-2xs font-semibold text-slate-700 uppercase tracking-widest">Sort By</label>
<select name="sort" onchange="this.form.submit()" class="w-full px-3 py-2 text-xs">
<option value="DATE_DESC" {% if sort == 'DATE_DESC' %}selected{% endif %}>Date — Newest</option>
<option value="DATE_ASC" {% if sort == 'DATE_ASC' %}selected{% endif %}>Date — Oldest</option>
<option value="TITLE_ASC" {% if sort == 'TITLE_ASC' %}selected{% endif %}>Title AZ</option>
<option value="TITLE_DESC" {% if sort == 'TITLE_DESC' %}selected{% endif %}>Title ZA</option>
<option value="TRENDING_DESC" {% if sort == 'TRENDING_DESC' %}selected{% endif %}>Trending</option>
<option value="CREATED_AT_DESC" {% if sort == 'CREATED_AT_DESC' %}selected{% endif %}>Added Newest</option>
<option value="UPDATED_AT_DESC" {% if sort == 'UPDATED_AT_DESC' %}selected{% endif %}>Updated Newest</option>
</select>
</div>
<!-- Missing Only -->
<label class="flex items-center gap-3 cursor-pointer py-2 border-t border-slate-900">
<input type="checkbox" name="missing_only" value="true" onchange="this.form.submit()" {% if missing_only %}checked{% endif %}>
<div>
<span class="text-xs font-semibold text-slate-400 block">Missing Only</span>
<span class="text-2xs text-slate-700">Hide items already in library</span>
</div>
</label>
</form>
<!-- Quick Tags -->
<div class="flex flex-col gap-2 border-t border-slate-900 pt-4">
<span class="text-2xs font-semibold text-slate-700 uppercase tracking-widest">Quick Tags</span>
<div class="flex flex-wrap gap-1">
{% for t in ["Anal","Blowjob","POV","Creampie","MILF","VR","Teen","Threesome","Lesbian","Outdoor","Interracial","Amateur"] %}
<a href="/?tab=scenes&search={{ t|urlencode }}&sort=DATE_DESC"
class="tag-pill text-2xs px-2 py-0.5 rounded font-medium">{{ t }}</a>
{% endfor %}
</div>
</div>
<!-- Quick Studios -->
<div class="flex flex-col gap-2 border-t border-slate-900 pt-4">
<span class="text-2xs font-semibold text-slate-700 uppercase tracking-widest">Studios</span>
<div class="flex flex-wrap gap-1">
{% for s in ["Brazzers","Vixen","Blacked","Deeper","Naughty America","Mofos","Evil Angel","Reality Kings"] %}
<a href="/?tab=scenes&search={{ s|urlencode }}&sort=DATE_DESC"
class="tag-pill text-2xs px-2 py-0.5 rounded font-medium">{{ s }}</a>
{% endfor %}
</div>
</div>
<!-- Mobile spacer -->
<div class="md:hidden h-2 safe-bottom"></div>
</div>
</div>
{% endif %}
<!-- ╔══════════════════════════════════════════════════════════╗
║ MAIN CONTENT ║
╚══════════════════════════════════════════════════════════╝ -->
<main id="main-content" class="pt-12 pb-20 md:pb-8 px-2 md:px-4 min-h-dvh transition-[padding] duration-300"
style="{% if active_tab in ['performers','studios','tags'] %}padding-top: calc(3rem + 2.25rem);{% endif %}">
<!-- ─── EMPTY STATE ─── -->
{% if not items %}
<div class="flex flex-col items-center justify-center min-h-[60vh] gap-4 text-center px-6">
<div class="w-12 h-12 bg-slate-900 border border-slate-900 rounded-xl flex items-center justify-center">
<svg class="w-5 h-5 text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<div>
<p class="text-sm font-semibold text-slate-400">No {{ active_tab }} found</p>
<p class="text-xs text-slate-700 mt-1">
{% if not stashdb_status.configured %}Configure your StashDB API key in settings.
{% else %}Try a different search or adjust filters.{% endif %}
</p>
</div>
{% if not stashdb_status.configured %}
<button onclick="openSettingsModal()"
class="px-4 py-2 text-xs font-semibold rounded bg-slate-100 text-slate-950 hover:bg-white transition-colors mt-2">
Open Settings
</button>
{% endif %}
</div>
<!-- ─── SCENES GRID ─── -->
{% elif active_tab == 'scenes' %}
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-1.5 md:gap-3 pt-2 md:pt-4">
{% for scene in items %}
{% set img = scene.images[0].url if scene.images else '' %}
<div class="scene-card group relative flex flex-col rounded-lg overflow-hidden cursor-pointer"
style="background:#0d1117; border:1px solid #1a2035;"
onclick="openSceneDrawer('{{ scene.id }}')">
<!-- Poster -->
<div class="img-wrap aspect-[3/4] relative overflow-hidden" style="background:#090d13;">
{% if img %}
<img src="{{ img }}" alt="{{ scene.title or '' }}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.style.display='none';this.nextElementSibling.classList.remove('hidden');">
{% endif %}
<div class="img-fallback absolute inset-0 {% if img %}hidden{% endif %}">
<svg class="w-6 h-6 text-slate-800" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1"><path stroke-linecap="round" stroke-linejoin="round" d="M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"/></svg>
</div>
<!-- Library badge overlay (top-left) -->
{% if scene.library_status == 'Active/In Library' %}
<span class="absolute top-1.5 left-1.5 badge-library text-2xs font-semibold px-1.5 py-0.5 rounded flex items-center gap-1">
<svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
<span class="hidden sm:inline">Library</span>
</span>
{% elif scene.library_status == 'Tracking/Searching' %}
<span class="absolute top-1.5 left-1.5 badge-tracking text-2xs font-semibold px-1.5 py-0.5 rounded">Tracking</span>
{% endif %}
<!-- Hover overlay cue -->
<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-200" style="background:rgba(0,0,0,0.35);">
<svg class="w-6 h-6 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</div>
</div>
<!-- Card body -->
<div class="flex flex-col gap-1 p-2">
<p class="text-2xs font-semibold text-slate-300 line-clamp-2 leading-snug select-text">
{{ scene.title or '(Untitled)' }}
</p>
<div class="flex items-center justify-between gap-1">
<span class="text-2xs font-mono text-slate-700 shrink-0">{{ scene.date or '' }}</span>
{% if scene.studio %}
<span class="text-2xs text-slate-700 truncate text-right">{{ scene.studio.name }}</span>
{% endif %}
</div>
<!-- Action (stopPropagation so click doesn't also open drawer) -->
<div class="request-wrap mt-0.5" onclick="event.stopPropagation()">
{% if scene.library_status == 'Active/In Library' %}
<span class="w-full badge-library text-2xs font-semibold px-2 py-1 rounded flex items-center justify-center gap-1">
<svg class="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
In Library
</span>
{% elif scene.library_status == 'Tracking/Searching' %}
<span class="w-full badge-tracking text-2xs font-semibold px-2 py-1 rounded flex items-center justify-center gap-1">
<svg class="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Tracking
</span>
{% else %}
<button onclick="getScene('{{ scene.id }}', this)"
class="btn-request w-full text-2xs font-bold px-2 py-1 rounded flex items-center justify-center gap-1">
<svg class="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
Request
</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
<!-- ─── PERFORMERS GRID ─── -->
{% elif active_tab == 'performers' %}
<div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 2xl:grid-cols-12 gap-2 md:gap-3 pt-9 md:pt-3">
{% for p in items %}
{% set img = p.images[0].url if p.images else '' %}
<a href="/?tab=scenes&search={{ p.name|urlencode }}&sort=DATE_DESC"
class="perf-card group flex flex-col items-center gap-1.5 p-2.5 rounded-lg transition-all" style="background:#0d1117; border:1px solid #1a2035;">
<!-- Avatar -->
<div class="perf-ring w-12 h-12 sm:w-14 sm:h-14 rounded-full overflow-hidden shrink-0 transition-colors" style="background:#111620; border:1px solid #1a2035;">
{% if img %}
<img src="{{ img }}" alt="{{ p.name }}"
class="w-full h-full object-cover"
loading="lazy"
onerror="this.style.display='none';this.nextElementSibling.classList.remove('hidden');">
{% endif %}
<div class="img-fallback w-full h-full {% if img %}hidden{% endif %}">
<span class="text-base font-bold text-slate-700">{{ p.name[0]|upper }}</span>
</div>
</div>
<p class="perf-name text-2xs font-semibold text-slate-500 group-hover:text-slate-200 truncate w-full text-center transition-colors leading-tight">
{{ p.name }}
</p>
{% if p.gender %}
<span class="text-[9px] text-slate-700 uppercase tracking-wide">{{ p.gender }}</span>
{% endif %}
</a>
{% endfor %}
</div>
<!-- ─── STUDIOS GRID ─── -->
{% elif active_tab == 'studios' %}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 2xl:grid-cols-8 gap-2 md:gap-3 pt-9 md:pt-3">
{% for studio in items %}
{% set img = studio.images[0].url if studio.images else '' %}
<a href="/?tab=scenes&search={{ studio.name|urlencode }}&sort=DATE_DESC"
class="group flex flex-col rounded-lg overflow-hidden transition-all" style="background:#0d1117; border:1px solid #1a2035;">
<div class="aspect-video relative overflow-hidden flex items-center justify-center p-4" style="background:#090d13;">
{% if img %}
<img src="{{ img }}" alt="{{ studio.name }}"
class="max-w-full max-h-full object-contain opacity-80 group-hover:opacity-100 transition-opacity"
loading="lazy"
onerror="this.style.display='none';this.nextElementSibling.classList.remove('hidden');">
{% endif %}
<div class="img-fallback absolute inset-0 {% if img %}hidden{% endif %}">
<svg class="w-7 h-7 text-slate-800" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"/>
</svg>
</div>
</div>
<div class="px-3 py-2" style="border-top:1px solid #1a2035;">
<p class="text-2xs font-semibold text-slate-500 group-hover:text-slate-300 truncate transition-colors">{{ studio.name }}</p>
</div>
</a>
{% endfor %}
</div>
<!-- ─── TAGS ─── -->
{% elif active_tab == 'tags' %}
<div class="pt-3 md:pt-4">
<!-- Alphabet index bar -->
<div class="flex flex-wrap gap-1 mb-4 pb-3" style="border-bottom:1px solid #1a2035;">
{% set letters = ['#','A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'] %}
{% for letter in letters %}
<a href="#tag-{{ letter }}"
class="text-[10px] font-bold px-1.5 py-0.5 rounded transition-colors"
style="color:#334155; background:#0d1117; border:1px solid #1a2035;"
onmouseover="this.style.color='#94a3b8'" onmouseout="this.style.color='#334155'">
{{ letter }}
</a>
{% endfor %}
</div>
<!-- Tag cloud -->
<div class="flex flex-wrap gap-x-2 gap-y-1.5">
{% set ns = namespace(current_letter='') %}
{% for tag in items %}
{% set first = tag.name[0]|upper if tag.name else '#' %}
{% set bucket = first if first in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' else '#' %}
{% if bucket != ns.current_letter %}
{% set ns.current_letter = bucket %}
<span id="tag-{{ bucket }}" class="w-full mt-3 mb-1 text-[10px] font-bold tracking-widest uppercase" style="color:#1e2a40; scroll-margin-top:84px;">{{ bucket }}</span>
{% endif %}
<a href="/?tab=scenes&search={{ tag.name|urlencode }}&sort=DATE_DESC"
class="tag-pill text-[10px] md:text-2xs font-medium px-2 py-0.5 rounded">
{{ tag.name }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- ─── PAGINATION ─── -->
{% if total_pages > 1 %}
<div class="flex items-center justify-center gap-1 mt-8 flex-wrap">
{% set sp = [1, page-3]|max %}
{% set ep = [total_pages, page+3]|min %}
{% if page > 1 %}
<a href="/?tab={{ active_tab }}&page={{ page-1 }}&sort={{ sort }}&missing_only={{ 'true' if missing_only else 'false' }}&search={{ search_query or '' }}"
class="h-8 px-3 flex items-center text-2xs font-semibold rounded border border-slate-900 text-slate-500 hover:text-slate-300 hover:border-slate-800 transition-all gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/></svg>
</a>
{% endif %}
{% if sp > 1 %}
<a href="/?tab={{ active_tab }}&page=1&sort={{ sort }}&missing_only={{ 'true' if missing_only else 'false' }}&search={{ search_query or '' }}"
class="w-8 h-8 flex items-center justify-center rounded border text-2xs font-mono font-semibold {{ 'bg-slate-100 border-slate-300 text-slate-950' if page == 1 else 'border-slate-900 text-slate-500 hover:border-slate-800 hover:text-slate-300' }} transition-all">1</a>
{% if sp > 2 %}<span class="text-slate-800 text-2xs px-0.5 font-mono"></span>{% endif %}
{% endif %}
{% for p in range(sp, ep+1) %}
<a href="/?tab={{ active_tab }}&page={{ p }}&sort={{ sort }}&missing_only={{ 'true' if missing_only else 'false' }}&search={{ search_query or '' }}"
class="w-8 h-8 flex items-center justify-center rounded border text-2xs font-mono font-semibold {{ 'bg-slate-100 border-transparent text-slate-950' if page == p else 'border-slate-900 text-slate-500 hover:border-slate-800 hover:text-slate-300' }} transition-all">{{ p }}</a>
{% endfor %}
{% if ep < total_pages %}
{% if ep < total_pages - 1 %}<span class="text-slate-800 text-2xs px-0.5 font-mono"></span>{% endif %}
<a href="/?tab={{ active_tab }}&page={{ total_pages }}&sort={{ sort }}&missing_only={{ 'true' if missing_only else 'false' }}&search={{ search_query or '' }}"
class="w-8 h-8 flex items-center justify-center rounded border text-2xs font-mono font-semibold {{ 'bg-slate-100 border-transparent text-slate-950' if page == total_pages else 'border-slate-900 text-slate-500 hover:border-slate-800 hover:text-slate-300' }} transition-all">{{ total_pages }}</a>
{% endif %}
{% if page < total_pages %}
<a href="/?tab={{ active_tab }}&page={{ page+1 }}&sort={{ sort }}&missing_only={{ 'true' if missing_only else 'false' }}&search={{ search_query or '' }}"
class="h-8 px-3 flex items-center text-2xs font-semibold rounded border border-slate-900 text-slate-500 hover:text-slate-300 hover:border-slate-800 transition-all gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
</a>
{% endif %}
</div>
{% endif %}
</main>
<!-- ╔══════════════════════════════════════════════════════════╗
║ MOBILE BOTTOM NAVIGATION DOCK ║
╚══════════════════════════════════════════════════════════╝ -->
<nav id="bottom-nav" class="md:hidden fixed bottom-0 left-0 right-0 z-30 safe-bottom flex items-center">
{% for tid, tlabel, icon_d in [
('scenes','Scenes','M15 10l4.553-2.069A1 1 0 0121 8.87v6.26a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z'),
('performers','People','M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z'),
('studios','Studios','M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'),
('tags','Tags','M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z')
] %}
<a href="/?tab={{ tid }}"
class="flex-1 flex flex-col items-center justify-center gap-0.5 py-2 {{ 'bnav-active' if active_tab == tid else 'bnav-inactive' }} transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="{{ '2' if active_tab == tid else '1.5' }}">
<path stroke-linecap="round" stroke-linejoin="round" d="{{ icon_d }}"/>
</svg>
<span class="text-[9px] font-semibold tracking-wide {{ 'text-slate-100' if active_tab == tid else 'text-slate-700' }}">{{ tlabel }}</span>
</a>
{% endfor %}
</nav>
<!-- ╔══════════════════════════════════════════════════════════╗
║ SETTINGS MODAL ║
╚══════════════════════════════════════════════════════════╝ -->
<div id="settings-modal" class="hidden fixed inset-0 z-50 items-center justify-center p-4">
<div class="modal-panel w-full max-w-md rounded-2xl shadow-2xl flex flex-col overflow-hidden">
<div class="px-5 py-4 border-b border-slate-900 flex items-center justify-between">
<h3 class="text-xs font-bold text-slate-200 tracking-tight">Settings</h3>
<button onclick="closeSettingsModal()" class="text-slate-700 hover:text-slate-400 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form onsubmit="saveSettings(event)" class="p-5 flex flex-col gap-4 overflow-y-auto max-h-[80dvh]">
<!-- Section macro -->
<div class="flex flex-col gap-3">
<span class="text-2xs font-bold text-slate-700 uppercase tracking-widest">StashDB</span>
<div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">GraphQL URL</label>
<input type="url" name="stashdb_url" value="{{ config.stashdb_url }}" class="w-full px-3 py-2">
</div>
<div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">API Key</label>
<input type="password" name="stashdb_api_key" value="{{ config.stashdb_api_key }}" placeholder="API Key" class="w-full px-3 py-2">
</div>
</div>
<div class="border-t border-slate-900 pt-4 flex flex-col gap-3">
<span class="text-2xs font-bold text-slate-700 uppercase tracking-widest">Whisparr</span>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">Root Folder</label>
<select id="whisparr_root_folder_select" name="whisparr_root_folder" data-current="{{ config.whisparr_root_folder }}" class="w-full px-3 py-2">
<option value="">— Auto —</option>
</select>
</div>
<div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">Quality</label>
<select id="whisparr_quality_profile_select" name="whisparr_quality_profile_id" data-current="{{ config.whisparr_quality_profile_id }}" class="w-full px-3 py-2">
<option value="">— Auto —</option>
</select>
</div>
</div>
</div>
<div class="border-t border-slate-900 pt-4 flex flex-col gap-3">
<span class="text-2xs font-bold text-slate-700 uppercase tracking-widest">Jellyfin</span>
<div class="flex flex-col gap-2">
<label class="text-2xs font-semibold text-slate-600 uppercase tracking-widest">Web URL</label>
<input type="url" name="jellyfin_url" value="{{ config.jellyfin_url }}" placeholder="http://localhost:8096" class="w-full px-3 py-2">
</div>
</div>
<div class="flex justify-end gap-2 pt-2 border-t border-slate-900">
<button type="button" onclick="closeSettingsModal()"
class="px-4 py-2 text-2xs font-semibold rounded border border-slate-900 text-slate-500 hover:text-slate-300 hover:border-slate-800 transition-all">
Cancel
</button>
<button type="submit"
class="px-4 py-2 text-2xs font-semibold rounded bg-slate-100 hover:bg-white text-slate-950 transition-colors">
Save Settings
</button>
</div>
</form>
</div>
</div>
<!-- ╔══════════════════════════════════════════════════════════╗
║ SCENE DETAIL DRAWER ║
╚══════════════════════════════════════════════════════════╝ -->
<div id="scene-backdrop" onclick="closeSceneDrawer()"
class="hidden fixed inset-0 z-[55]" style="background:rgba(0,0,0,0.6);"></div>
<aside id="scene-drawer"
class="fixed top-0 right-0 bottom-0 z-[56] w-full max-w-md flex flex-col"
style="background:#080c12; border-left:1px solid #1a2035; transform:translateX(100%); transition:transform 0.3s cubic-bezier(0.32,0.72,0,1);">
<!-- Drawer Header -->
<div class="flex items-center justify-between px-4 py-3" style="border-bottom:1px solid #1a2035; background:#080c12;">
<span class="text-xs font-bold text-slate-300 tracking-tight">Scene Detail</span>
<button onclick="closeSceneDrawer()" class="text-slate-600 hover:text-slate-300 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<!-- Loading skeleton -->
<div id="drawer-loading" class="flex-1 flex items-center justify-center">
<svg class="w-6 h-6 text-slate-700 spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"></circle>
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
<!-- Drawer Content (populated by JS) -->
<div id="drawer-content" class="hidden flex-1 overflow-y-auto flex flex-col">
<!-- Hero poster + gradient -->
<div id="drawer-poster-wrap" class="relative w-full shrink-0" style="height:220px; background:#090d13;">
<img id="drawer-poster" src="" alt="" class="w-full h-full object-cover">
<div class="absolute inset-0" style="background:linear-gradient(to bottom, transparent 40%, #080c12 100%);"></div>
<!-- Status badge -->
<div id="drawer-badge" class="absolute top-3 left-3"></div>
</div>
<!-- Body -->
<div class="px-4 pb-6 flex flex-col gap-4 -mt-6 relative">
<!-- Title + studio -->
<div class="flex flex-col gap-1">
<h2 id="drawer-title" class="text-sm font-bold text-slate-100 leading-snug select-text"></h2>
<div class="flex items-center gap-2">
<span id="drawer-studio" class="text-xs text-slate-500"></span>
<span class="text-slate-800 text-xs">·</span>
<span id="drawer-date" class="text-xs font-mono text-slate-600"></span>
<span class="text-slate-800 text-xs">·</span>
<span id="drawer-duration" class="text-xs font-mono text-slate-600"></span>
</div>
</div>
<!-- Action button -->
<div id="drawer-action-wrap" class="w-full"></div>
<!-- Description -->
<div id="drawer-details-wrap" class="hidden">
<p class="text-2xs font-semibold text-slate-700 uppercase tracking-widest mb-1.5">Description</p>
<p id="drawer-details" class="text-xs text-slate-400 leading-relaxed select-text"></p>
</div>
<!-- Performers -->
<div id="drawer-performers-wrap" class="hidden">
<p class="text-2xs font-semibold text-slate-700 uppercase tracking-widest mb-2">Performers</p>
<div id="drawer-performers" class="flex flex-wrap gap-2"></div>
</div>
<!-- Tags -->
<div id="drawer-tags-wrap" class="hidden">
<p class="text-2xs font-semibold text-slate-700 uppercase tracking-widest mb-2">Tags</p>
<div id="drawer-tags" class="flex flex-wrap gap-1"></div>
</div>
<!-- External Links -->
<div id="drawer-links-wrap" class="hidden">
<p class="text-2xs font-semibold text-slate-700 uppercase tracking-widest mb-2">Links</p>
<div id="drawer-links" class="flex flex-col gap-1.5"></div>
</div>
</div>
</div>
</aside>
<!-- Toast Container -->
<div id="toast-container" class="fixed bottom-20 md:bottom-4 right-4 z-[60] flex flex-col gap-2 pointer-events-none items-end"></div>
<!-- ╔══════════════════════════════════════════════════════════╗
║ JAVASCRIPT ║
╚══════════════════════════════════════════════════════════╝ -->
<script>
/* ── Filter Sheet (mobile) ── */
function openFilterSheet() {
document.getElementById('filter-sheet').classList.replace('sheet-hidden','sheet-visible');
document.getElementById('sheet-backdrop').classList.remove('hidden');
document.body.style.overflow = 'hidden';
}
function closeFilterSheet() {
const s = document.getElementById('filter-sheet');
if (s) s.classList.replace('sheet-visible','sheet-hidden');
const b = document.getElementById('sheet-backdrop');
if (b) b.classList.add('hidden');
document.body.style.overflow = '';
}
/* Desktop filter toggle */
let desktopFilterOpen = false;
function toggleFilterDesktop() {
desktopFilterOpen = !desktopFilterOpen;
const sheet = document.getElementById('filter-sheet');
const main = document.getElementById('main-content');
if (!sheet) return;
if (desktopFilterOpen) {
sheet.classList.replace('sheet-hidden','sheet-visible');
if (main) main.style.paddingLeft = '14rem';
} else {
sheet.classList.replace('sheet-visible','sheet-hidden');
if (main) main.style.paddingLeft = '';
}
}
/* Auto-open filter on desktop for scenes tab */
(function initFilterPanel() {
const sheet = document.getElementById('filter-sheet');
if (!sheet) return;
const isDesktop = window.matchMedia('(min-width: 768px)').matches;
if (isDesktop) {
desktopFilterOpen = true;
sheet.classList.replace('sheet-hidden','sheet-visible');
const main = document.getElementById('main-content');
if (main) main.style.paddingLeft = '14rem';
}
})();
/* ── Settings Modal ── */
function openSettingsModal() {
document.getElementById('settings-modal').classList.remove('hidden');
document.getElementById('settings-modal').classList.add('flex');
fetchWhisparrOptions();
}
function closeSettingsModal() {
document.getElementById('settings-modal').classList.add('hidden');
document.getElementById('settings-modal').classList.remove('flex');
}
document.getElementById('settings-modal').addEventListener('click', e => {
if (e.target === document.getElementById('settings-modal')) closeSettingsModal();
});
/* ── Whisparr Loader ── */
async function fetchWhisparrOptions() {
const fSel = document.getElementById('whisparr_root_folder_select');
const pSel = document.getElementById('whisparr_quality_profile_select');
try {
const r = await fetch('/api/whisparr/profiles');
if (!r.ok) return;
const d = await r.json();
const cf = fSel.dataset.current;
fSel.innerHTML = '<option value="">— Auto —</option>';
(d.folders||[]).forEach(f => {
const o = document.createElement('option');
o.value = f.path;
const gb = Math.round((f.freeSpace||0)/1073741824);
o.textContent = `${f.path} (${gb}GB)`;
if (f.path === cf) o.selected = true;
fSel.appendChild(o);
});
const cp = pSel.dataset.current;
pSel.innerHTML = '<option value="">— Auto —</option>';
(d.profiles||[]).forEach(p => {
const o = document.createElement('option');
o.value = p.id;
o.textContent = p.name;
if (p.id.toString() === cp) o.selected = true;
pSel.appendChild(o);
});
} catch(e) { console.error(e); }
}
/* ── Save Settings ── */
async function saveSettings(e) {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target).entries());
try {
const r = await fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(data)
});
if (r.ok) {
toast('Saved', 'Settings updated.');
closeSettingsModal();
setTimeout(() => location.reload(), 900);
} else {
const err = await r.json();
toast('Error', err.detail || 'Save failed.', 'error');
}
} catch(err) { toast('Error', 'Unexpected error.', 'error'); }
}
/* ── Request Scene ── */
/* ── Scene Detail Drawer ── */
let currentDrawerSceneId = null;
function openSceneDrawer(stashId) {
currentDrawerSceneId = stashId;
const drawer = document.getElementById('scene-drawer');
const backdrop = document.getElementById('scene-backdrop');
const loading = document.getElementById('drawer-loading');
const content = document.getElementById('drawer-content');
// Reset state
loading.classList.remove('hidden');
content.classList.add('hidden');
backdrop.classList.remove('hidden');
drawer.style.transform = 'translateX(0)';
document.body.style.overflow = 'hidden';
// Fetch full metadata
fetch(`/api/scene/${stashId}`)
.then(r => r.ok ? r.json() : Promise.reject(r))
.then(scene => populateDrawer(scene))
.catch(err => {
loading.innerHTML = '<p class="text-xs text-slate-600">Failed to load scene.</p>';
console.error(err);
});
}
function closeSceneDrawer() {
document.getElementById('scene-drawer').style.transform = 'translateX(100%)';
document.getElementById('scene-backdrop').classList.add('hidden');
document.body.style.overflow = '';
currentDrawerSceneId = null;
}
function fmtDuration(secs) {
if (!secs) return '';
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
const s = secs % 60;
return h > 0
? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
: `${m}:${String(s).padStart(2,'0')}`;
}
function populateDrawer(scene) {
// Poster
const poster = document.getElementById('drawer-poster');
const posterImg = scene.images && scene.images[0] ? scene.images[0].url : '';
if (posterImg) {
poster.src = posterImg;
poster.style.display = 'block';
poster.onerror = () => { poster.style.display = 'none'; };
} else {
poster.style.display = 'none';
}
// Badge
const badge = document.getElementById('drawer-badge');
const status = scene.library_status || '';
if (status === 'Active/In Library') {
badge.innerHTML = '<span class="badge-library text-2xs font-semibold px-2 py-0.5 rounded flex items-center gap-1"><svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>In Library</span>';
} else if (status === 'Tracking/Searching') {
badge.innerHTML = '<span class="badge-tracking text-2xs font-semibold px-2 py-0.5 rounded">Tracking</span>';
} else {
badge.innerHTML = '';
}
// Title / studio / date / duration
document.getElementById('drawer-title').textContent = scene.title || '(Untitled)';
document.getElementById('drawer-studio').textContent = scene.studio ? scene.studio.name : '';
document.getElementById('drawer-date').textContent = scene.date || '';
document.getElementById('drawer-duration').textContent = fmtDuration(scene.duration);
// Action button
const actionWrap = document.getElementById('drawer-action-wrap');
if (status === 'Active/In Library') {
actionWrap.innerHTML = '<span class="w-full badge-library text-2xs font-bold px-3 py-2 rounded flex items-center justify-center gap-1.5"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>In Library</span>';
} else if (status === 'Tracking/Searching') {
actionWrap.innerHTML = '<span class="w-full badge-tracking text-2xs font-bold px-3 py-2 rounded flex items-center justify-center gap-1.5"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>Tracking</span>';
} else {
actionWrap.innerHTML = `<div class="request-wrap"><button onclick="getScene('${scene.id}', this)" class="btn-request w-full text-xs font-bold px-3 py-2 rounded flex items-center justify-center gap-1.5"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>Request Scene</button></div>`;
}
// Description
const detailsWrap = document.getElementById('drawer-details-wrap');
if (scene.details && scene.details.trim()) {
document.getElementById('drawer-details').textContent = scene.details;
detailsWrap.classList.remove('hidden');
} else {
detailsWrap.classList.add('hidden');
}
// Performers
const perfWrap = document.getElementById('drawer-performers-wrap');
const perfContainer = document.getElementById('drawer-performers');
const perfs = (scene.performers || []).filter(p => p.performer);
if (perfs.length) {
perfContainer.innerHTML = perfs.map(p => {
const img = p.performer.images && p.performer.images[0] ? p.performer.images[0].url : '';
const displayName = p.as || p.performer.name;
const avatarHtml = img
? `<img src="${img}" alt="${displayName}" class="w-full h-full object-cover" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';">
<span style="display:none;" class="w-full h-full flex items-center justify-center text-sm font-bold text-slate-600">${displayName[0].toUpperCase()}</span>`
: `<span class="w-full h-full flex items-center justify-center text-sm font-bold text-slate-600">${displayName[0].toUpperCase()}</span>`;
return `<a href="/?tab=scenes&search=${encodeURIComponent(p.performer.name)}&sort=DATE_DESC"
onclick="closeSceneDrawer()"
class="flex flex-col items-center gap-1.5 group" style="width:60px;">
<div class="w-10 h-10 rounded-full overflow-hidden shrink-0" style="background:#111620; border:1px solid #1a2035;">${avatarHtml}</div>
<span class="text-[9px] font-semibold text-slate-500 group-hover:text-slate-200 text-center leading-tight transition-colors" style="max-width:60px; word-break:break-word;">${displayName}</span>
</a>`;
}).join('');
perfWrap.classList.remove('hidden');
} else {
perfWrap.classList.add('hidden');
}
// Tags
const tagsWrap = document.getElementById('drawer-tags-wrap');
const tagsContainer = document.getElementById('drawer-tags');
const tags = (scene.tags || []);
if (tags.length) {
tagsContainer.innerHTML = tags.map(t =>
`<a href="/?tab=scenes&search=${encodeURIComponent(t.name)}&sort=DATE_DESC"
onclick="closeSceneDrawer()"
class="tag-pill text-[10px] font-medium px-2 py-0.5 rounded">#${t.name}</a>`
).join('');
tagsWrap.classList.remove('hidden');
} else {
tagsWrap.classList.add('hidden');
}
// Links
const linksWrap = document.getElementById('drawer-links-wrap');
const linksContainer = document.getElementById('drawer-links');
const urls = (scene.urls || []);
if (urls.length) {
linksContainer.innerHTML = urls.map(u => {
const siteName = u.site ? u.site.name : new URL(u.url).hostname;
return `<a href="${u.url}" target="_blank" rel="noopener noreferrer"
class="flex items-center gap-2 text-xs text-slate-500 hover:text-slate-300 transition-colors group">
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
<span class="truncate">${siteName}</span>
</a>`;
}).join('');
linksWrap.classList.remove('hidden');
} else {
linksWrap.classList.add('hidden');
}
// Show content
document.getElementById('drawer-loading').classList.add('hidden');
document.getElementById('drawer-content').classList.remove('hidden');
}
/* ── Request Scene ── */
async function getScene(stashId, btn) {
const wrap = btn.closest('.request-wrap');
const original = wrap.innerHTML;
/* Instant amber tracking state */
wrap.innerHTML = `
<span class="w-full badge-tracking text-2xs font-semibold px-2 py-1 rounded flex items-center justify-center gap-1">
<svg class="w-2.5 h-2.5 shrink-0 spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3"></circle>
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Searching…
</span>`;
try {
const r = await fetch(`/api/get-scene/${stashId}`, { method: 'POST' });
const result = await r.json();
if (r.ok) {
toast('Queued', result.message || 'Scene added to Whisparr.');
wrap.innerHTML = `
<span class="w-full badge-tracking text-2xs font-semibold px-2 py-1 rounded flex items-center justify-center gap-1">
<svg class="w-2.5 h-2.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
Tracking
</span>`;
} else {
wrap.innerHTML = original;
toast('Failed', result.detail || 'Could not add scene.', 'error');
}
} catch(err) {
console.error(err);
wrap.innerHTML = original;
toast('Error', 'Connection error.', 'error');
}
}
/* ── Toast ── */
function toast(title, message, type = 'success') {
const c = document.getElementById('toast-container');
const el = document.createElement('div');
const ok = type === 'success';
el.className = `toast toast-out pointer-events-auto max-w-xs rounded-xl border px-4 py-3 flex items-start gap-3 shadow-2xl ${
ok ? 'bg-slate-950 border-slate-900' : 'bg-slate-950 border-slate-900'
}`;
el.innerHTML = `
<span class="mt-0.5 shrink-0 ${ok ? 'text-emerald-500' : 'text-red-500'}">
${ok
? `<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`
: `<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`}
</span>
<div class="flex-1 select-text">
<p class="text-xs font-bold text-slate-200">${title}</p>
<p class="text-2xs text-slate-600 mt-0.5 leading-relaxed">${message}</p>
</div>
<button onclick="this.parentElement.remove()" class="text-slate-800 hover:text-slate-500 transition-colors shrink-0 mt-0.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>
</button>`;
c.appendChild(el);
requestAnimationFrame(() => requestAnimationFrame(() => el.classList.replace('toast-out','toast-in')));
setTimeout(() => {
el.classList.replace('toast-in','toast-out');
setTimeout(() => el.remove(), 260);
}, 4000);
}
</script>
</body>
</html>