1182 lines
64 KiB
HTML
1182 lines
64 KiB
HTML
<!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 A–Z</option>
|
||
<option value="NAME_DESC" {% if sort == 'NAME_DESC' %}selected{% endif %}>Name Z–A</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 A–Z</option>
|
||
<option value="NAME_DESC" {% if sort == 'NAME_DESC' %}selected{% endif %}>Name Z–A</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 A–Z</option>
|
||
<option value="NAME_DESC" {% if sort == 'NAME_DESC' %}selected{% endif %}>Name Z–A</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 A–Z</option>
|
||
<option value="TITLE_DESC" {% if sort == 'TITLE_DESC' %}selected{% endif %}>Title Z–A</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>
|