styles and shit cleanup
parent
00aa9a9bf1
commit
6c1c2970e8
@ -0,0 +1,186 @@
|
||||
/* =========================
|
||||
Theme / Base
|
||||
========================= */
|
||||
:root{
|
||||
--bg:#111; --card:#1a1a1a; --muted:#bbb; --text:#eee; --line:#333;
|
||||
--chip:#181818; --link:#4af; --link-hover:#7bb7ff;
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0;
|
||||
font-family:Arial,Helvetica,sans-serif;
|
||||
background:var(--bg);
|
||||
color:var(--text);
|
||||
}
|
||||
a{color:var(--link); text-decoration:none}
|
||||
a:hover{color:var(--link-hover)}
|
||||
|
||||
/* =========================
|
||||
Top Nav & Footer
|
||||
========================= */
|
||||
.topbar{
|
||||
position:sticky; top:0; z-index:100;
|
||||
background:#0d0d0d; border-bottom:1px solid var(--line);
|
||||
}
|
||||
.nav{
|
||||
max-width:1200px; margin:0 auto;
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
padding:12px 16px;
|
||||
}
|
||||
.nav .brand{color:#fff; font-weight:600}
|
||||
.nav .links{display:flex; gap:14px; flex-wrap:wrap}
|
||||
.nav .links a{color:var(--link)}
|
||||
.nav .links a:hover{color:var(--link-hover)}
|
||||
|
||||
.footer{
|
||||
margin-top:32px; border-top:1px solid var(--line);
|
||||
padding:16px; text-align:center; color:var(--muted)
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Layout Helpers
|
||||
========================= */
|
||||
.container{max-width:1200px; margin:24px auto; padding:0 16px}
|
||||
|
||||
/* =========================
|
||||
Controls / Forms
|
||||
========================= */
|
||||
.controls{
|
||||
margin:10px 0; display:flex; gap:10px;
|
||||
flex-wrap:wrap; justify-content:center
|
||||
}
|
||||
input[type="text"], input[type="date"], select{
|
||||
padding:8px; background:var(--chip); color:var(--text);
|
||||
border:1px solid var(--line); border-radius:4px;
|
||||
}
|
||||
button{
|
||||
padding:8px 12px; background:var(--chip); color:var(--text);
|
||||
border:1px solid var(--line); border-radius:4px; cursor:pointer
|
||||
}
|
||||
button:hover{background:#202020}
|
||||
|
||||
/* =========================
|
||||
Table (Dashboard)
|
||||
========================= */
|
||||
.table-wrap{overflow:auto}
|
||||
table{
|
||||
margin:auto; border-collapse:collapse; width:100%;
|
||||
background:var(--card)
|
||||
}
|
||||
th,td{border:1px solid var(--line); padding:10px; text-align:left}
|
||||
th{
|
||||
background:#222; position:sticky; top:48px; /* align with .topbar height */
|
||||
z-index:10
|
||||
}
|
||||
tr:nth-child(even){background:#181818}
|
||||
|
||||
/* =========================
|
||||
Pagination (shared macro)
|
||||
========================= */
|
||||
.pagination{margin:16px 0; text-align:center}
|
||||
.pagination a,.pagination span{
|
||||
display:inline-block; background:var(--chip); color:var(--text);
|
||||
border:1px solid var(--line); margin:0 5px; padding:6px 12px; text-decoration:none;
|
||||
border-radius:4px;
|
||||
}
|
||||
.pagination .active{background:#333}
|
||||
|
||||
/* =========================
|
||||
Status dots
|
||||
========================= */
|
||||
.status-dot{
|
||||
display:inline-block; width:10px; height:10px;
|
||||
border-radius:50%; margin-left:6px; vertical-align:middle
|
||||
}
|
||||
.dot-online{background:#22c55e}
|
||||
.dot-record{background:#ef4444}
|
||||
.dot-offline{background:#666}
|
||||
|
||||
/* =========================
|
||||
Grid container (shared)
|
||||
========================= */
|
||||
.grid{
|
||||
width:100%;
|
||||
display:grid;
|
||||
grid-template-columns:repeat(auto-fill,minmax(220px,1fr));
|
||||
gap:14px;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Card base (shared)
|
||||
========================= */
|
||||
.card{
|
||||
background:var(--card); border:1px solid var(--line);
|
||||
border-radius:8px; padding:10px; min-height:120px;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Users grid (users.html)
|
||||
Two-column card with tall thumbnail
|
||||
========================= */
|
||||
.user-card{
|
||||
display:grid;
|
||||
grid-template-columns:180px 1fr;
|
||||
gap:12px; align-items:stretch;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Video grids (favorites, user_page)
|
||||
Stacked card (thumb on top, meta below)
|
||||
========================= */
|
||||
.video-card{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:10px;
|
||||
}
|
||||
|
||||
/* Thumbnails (shared) */
|
||||
.thumb{
|
||||
position:relative;
|
||||
height:100%; min-height:120px;
|
||||
background:#222; border:1px solid var(--line);
|
||||
border-radius:6px; overflow:hidden;
|
||||
}
|
||||
.thumb img,.thumb .fallback{
|
||||
position:absolute; inset:0;
|
||||
width:100%; height:100%; object-fit:cover; display:block;
|
||||
}
|
||||
.thumb .fallback{
|
||||
display:none; align-items:center; justify-content:center; font-size:28px;
|
||||
}
|
||||
|
||||
/* Taller thumbs on video cards */
|
||||
.grid.videos .thumb{ min-height:140px; }
|
||||
|
||||
/* Meta panel */
|
||||
.meta{
|
||||
display:flex; flex-direction:column; gap:6px; min-width:0;
|
||||
}
|
||||
.meta h3{margin:0; font-size:16px; display:flex; align-items:center; gap:6px}
|
||||
.row{font-size:14px}
|
||||
.muted{color:var(--muted)}
|
||||
|
||||
/* Favorite star overlay (works for both grids) */
|
||||
.fav-btn{
|
||||
position:absolute; top:8px; right:8px; z-index:2;
|
||||
font-size:18px; line-height:1;
|
||||
border:none; border-radius:6px; padding:.25rem .45rem; cursor:pointer;
|
||||
background:rgba(0,0,0,.55); color:#fff; backdrop-filter:blur(2px)
|
||||
}
|
||||
.fav-btn[aria-pressed="true"]{color:gold}
|
||||
.fav-btn:hover{transform:scale(1.05)}
|
||||
|
||||
/* =========================
|
||||
Responsive tweaks
|
||||
========================= */
|
||||
@media (max-width: 900px){
|
||||
.user-card{grid-template-columns:150px 1fr}
|
||||
th{top:56px}
|
||||
}
|
||||
@media (max-width: 640px){
|
||||
.user-card{grid-template-columns:1fr}
|
||||
.thumb{min-height:160px}
|
||||
th{top:60px}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
{% macro pager(endpoint, page, total_pages, q=None, sort=None, dir=None, timeframe=None, start=None, end=None, username=None) -%}
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
{% if username %}
|
||||
<a href="{{ url_for(endpoint, username=username, page=page-1, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">« Prev</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for(endpoint, page=page-1, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">« Prev</a>
|
||||
{% endif %}
|
||||
{% else %}<span>« Prev</span>{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<span class="active">{{ p }}</span>
|
||||
{% else %}
|
||||
{% if username %}
|
||||
<a href="{{ url_for(endpoint, username=username, page=p, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">{{ p }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for(endpoint, page=p, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
{% if username %}
|
||||
<a href="{{ url_for(endpoint, username=username, page=page+1, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">Next »</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for(endpoint, page=page+1, q=q, sort=sort, dir=dir, timeframe=timeframe, start=start, end=end) }}">Next »</a>
|
||||
{% endif %}
|
||||
{% else %}<span>Next »</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>{% block title %}Streamaster Finder{% endblock %}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='styles.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<nav class="nav">
|
||||
<a class="brand" href="{{ url_for('web.dashboard') }}">📺 Streamaster Finder</a>
|
||||
<div class="links">
|
||||
<a href="{{ url_for('web.dashboard') }}">Dashboard</a>
|
||||
<a href="{{ url_for('web.users') }}">Users</a>
|
||||
<a href="{{ url_for('web.recent') }}">Recent</a>
|
||||
<a href="{{ url_for('web.favorites_page') }}">Favorites</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div>© <span id="yr"></span> Streamaster</div>
|
||||
<script>document.getElementById('yr').textContent = new Date().getFullYear();</script>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,173 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>★ Favorites</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin: 10px;
|
||||
color: #0af;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 18px;
|
||||
margin: 20px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
background: #222;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.thumb-wrap {
|
||||
position: relative;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thumb-link {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.thumb-link img {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.video-size {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.fav-btn {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
z-index: 2;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: .25rem .45rem;
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(2px);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fav-btn[aria-pressed="true"] {
|
||||
color: gold;
|
||||
}
|
||||
|
||||
.fav-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.pagination a {
|
||||
color: #0af;
|
||||
margin: 0 8px;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>★ Favorites</h1>
|
||||
<a href="/" class="back-link">⬅ Back to Dashboard</a>
|
||||
|
||||
<div class="grid">
|
||||
{% for video in videos %}
|
||||
<div class="video-card">
|
||||
<div class="thumb-wrap">
|
||||
<a class="thumb-link" href="/video/{{ video['video_id'] }}">
|
||||
<img src="/{{ video.thumbnail }}" alt="Thumbnail">
|
||||
</a>
|
||||
|
||||
<button class="fav-btn" data-video-id="{{ video.video_id }}"
|
||||
aria-pressed="{{ 'true' if video.is_favorite else 'false' }}"
|
||||
title="{{ 'Unfavorite' if video.is_favorite else 'Favorite' }}">
|
||||
{{ '★' if video.is_favorite else '☆' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="video-size">{{ "%.2f"|format(video.size/1024) }} GB</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% extends "base.html" %}
|
||||
{% from "_pagination.html" import pager %}
|
||||
{% block title %}★ Favorites{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>★ Favorites</h1>
|
||||
|
||||
<div class="grid videos">
|
||||
{% for video in videos %}
|
||||
<div class="card video-card" data-video-id="{{ video.video_id }}">
|
||||
<div class="thumb thumb-wrap">
|
||||
{% set thumb = video.thumbnail %}
|
||||
<a class="thumb-link" href="{{ url_for('web.view_video', video_id=video.video_id) }}">
|
||||
<img
|
||||
src="{{ thumb if thumb and thumb.startswith('http') else ('/' ~ (thumb or '')) }}"
|
||||
alt="Thumbnail"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror="this.style.display='none'; this.parentElement.parentElement.querySelector('.fallback').style.display='flex';"
|
||||
>
|
||||
</a>
|
||||
<span class="fallback">🎞️</span>
|
||||
|
||||
<button
|
||||
class="fav-btn"
|
||||
data-video-id="{{ video.video_id }}"
|
||||
aria-pressed="{{ 'true' if video.is_favorite else 'false' }}"
|
||||
title="{{ 'Unfavorite' if video.is_favorite else 'Favorite' }}"
|
||||
>
|
||||
{{ '★' if video.is_favorite else '☆' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page-1 }}">⬅ Prev</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} / {{ total_pages }}</span>
|
||||
{% if page < total_pages %} <a href="?page={{ page+1 }}">Next ➡</a>
|
||||
{% endif %}
|
||||
<div class="meta">
|
||||
<div class="row muted">{{ "%.2f"|format((video.size or 0)/1024) }} GB</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Toggle favorite on this page; if unfavorited, you can either leave it or remove the card.
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.fav-btn');
|
||||
if (!btn) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
|
||||
const card = btn.closest('.video-card');
|
||||
const vid = btn.dataset.videoId;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/fav/toggle/${encodeURIComponent(vid)}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || 'Failed');
|
||||
|
||||
const isFav = data.is_favorite;
|
||||
btn.textContent = isFav ? '★' : '☆';
|
||||
btn.setAttribute('aria-pressed', isFav ? 'true' : 'false');
|
||||
btn.title = isFav ? 'Unfavorite' : 'Favorite';
|
||||
|
||||
// Optional: remove card if unfavorited so it disappears from Favorites immediately
|
||||
if (!isFav) {
|
||||
card.remove();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Could not toggle favorite. ' + (err?.message || ''));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{{ pager('web.favorites_page', page, total_pages) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.fav-btn');
|
||||
if (!btn) return;
|
||||
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
|
||||
const card = btn.closest('.video-card');
|
||||
const vid = btn.dataset.videoId;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/fav/toggle/${encodeURIComponent(vid)}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || 'Failed');
|
||||
|
||||
const isFav = data.is_favorite;
|
||||
btn.textContent = isFav ? '★' : '☆';
|
||||
btn.setAttribute('aria-pressed', isFav ? 'true' : 'false');
|
||||
btn.title = isFav ? 'Unfavorite' : 'Favorite';
|
||||
|
||||
// Remove immediately if unfavorited (since this is the Favorites page)
|
||||
if (!isFav) card.remove();
|
||||
} catch (err) {
|
||||
alert('Could not toggle favorite. ' + (err?.message || ''));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -1,291 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>📊 Video Storage Analytics</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
table {
|
||||
margin: auto;
|
||||
border-collapse: collapse;
|
||||
width: 90%;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid #444;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
tr:nth-child(even) {
|
||||
background: #222;
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.pagination a,
|
||||
.pagination span {
|
||||
display: inline-block;
|
||||
background: #222;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
margin: 0 5px;
|
||||
padding: 6px 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pagination .active {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
padding: 8px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.dot-online {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
/* green */
|
||||
.dot-record {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
/* red */
|
||||
.dot-offline {
|
||||
background: #666;
|
||||
}
|
||||
|
||||
/* grey */
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script>
|
||||
const table = document.getElementById('analytics-table');
|
||||
const headers = table.querySelectorAll('th');
|
||||
const searchInput = document.getElementById('search');
|
||||
const rows = Array.from(table.querySelector('tbody').rows);
|
||||
const pagination = document.getElementById('pagination');
|
||||
let sortDirection = {};
|
||||
let currentPage = 1;
|
||||
const rowsPerPage = 100;
|
||||
|
||||
// Sorting
|
||||
headers.forEach((header, index) => {
|
||||
header.addEventListener('click', () => {
|
||||
const isNumeric = index >= 2;
|
||||
const dir = sortDirection[index] === 'asc' ? 'desc' : 'asc';
|
||||
sortDirection = { [index]: dir };
|
||||
headers.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||
header.classList.add(dir === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const aVal = isNumeric ? parseFloat(a.cells[index].innerText) : a.cells[index].innerText.toLowerCase();
|
||||
const bVal = isNumeric ? parseFloat(b.cells[index].innerText) : b.cells[index].innerText.toLowerCase();
|
||||
return dir === 'asc' ? (aVal > bVal ? 1 : -1) : (aVal < bVal ? 1 : -1);
|
||||
});
|
||||
updateTable();
|
||||
});
|
||||
});
|
||||
|
||||
// Search
|
||||
searchInput.addEventListener('keyup', () => {
|
||||
currentPage = 1;
|
||||
updateTable();
|
||||
});
|
||||
|
||||
function updateTable() {
|
||||
const term = searchInput.value.toLowerCase();
|
||||
const filtered = rows.filter(row => row.cells[0].innerText.toLowerCase().includes(term));
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / rowsPerPage);
|
||||
updatePagination(totalPages);
|
||||
|
||||
table.querySelector('tbody').innerHTML = '';
|
||||
const start = (currentPage - 1) * rowsPerPage;
|
||||
const paginated = filtered.slice(start, start + rowsPerPage);
|
||||
|
||||
paginated.forEach(row => table.querySelector('tbody').appendChild(row));
|
||||
}
|
||||
|
||||
updateTable(); // Initial render
|
||||
</script>
|
||||
|
||||
<h1>📊 Video Storage Analytics</h1>
|
||||
|
||||
<div class="controls">
|
||||
|
||||
<form method="get" action="{{ url_for('web.dashboard') }}"
|
||||
style="display:inline-flex; gap:8px; align-items:center;">
|
||||
<input type="hidden" name="q" value="{{ query or '' }}">
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<input type="hidden" name="dir" value="{{ dir }}">
|
||||
<input type="hidden" name="timeframe" value="{{ timeframe }}">
|
||||
<input type="hidden" name="start" value="{{ start_date or '' }}">
|
||||
<input type="hidden" name="end" value="{{ end_date or '' }}">
|
||||
|
||||
<input type="hidden" name="online" value="{{ '0' if online=='1' else '1' }}">
|
||||
<button type="submit">
|
||||
{{ '📶 Show online (ON)' if online=='1' else '📶 Show online (OFF)' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
<form method="get" action="{{ url_for('web.dashboard') }}"
|
||||
style="display:inline-flex; gap:8px; align-items:center; margin-left:10px;">
|
||||
<label>Timeframe:</label>
|
||||
<select name="timeframe" onchange="this.form.submit()">
|
||||
<option value="all" {{ 'selected' if timeframe=='all' else '' }}>All time</option>
|
||||
<option value="week" {{ 'selected' if timeframe=='week' else '' }}>This week</option>
|
||||
<option value="month" {{ 'selected' if timeframe=='month' else '' }}>Last 30 days</option>
|
||||
<option value="year" {{ 'selected' if timeframe=='year' else '' }}>Last 365 days</option>
|
||||
<option value="custom" {{ 'selected' if timeframe=='custom' else '' }}>Custom…</option>
|
||||
</select>
|
||||
|
||||
<input type="date" name="start" value="{{ start_date or '' }}" placeholder="Start" />
|
||||
<input type="date" name="end" value="{{ end_date or '' }}" placeholder="End" />
|
||||
|
||||
<input type="hidden" name="q" value="{{ query or '' }}">
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<input type="hidden" name="dir" value="{{ dir }}">
|
||||
|
||||
<button type="submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<button onclick="window.location.href='/refresh'">🔄 Refresh Data</button>
|
||||
|
||||
<!-- Server-side search -->
|
||||
<form method="get" action="{{ url_for('web.dashboard') }}" style="display:inline-block; margin-left:10px;">
|
||||
<input type="text" name="q" placeholder="Search users..." value="{{ query or '' }}">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<table id="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% set next_user_dir = 'asc' if sort != 'user' or dir == 'desc' else 'desc' %}
|
||||
{% set next_platform_dir = 'asc' if sort != 'platform' or dir == 'desc' else 'desc' %}
|
||||
{% set next_total_dir = 'asc' if sort != 'total_size' or dir == 'desc' else 'desc' %}
|
||||
{% set next_count_dir = 'asc' if sort != 'video_count' or dir == 'desc' else 'desc' %}
|
||||
{% set next_avg_dir = 'asc' if sort != 'avg_size' or dir == 'desc' else 'desc' %}
|
||||
|
||||
<th>
|
||||
<a href="{{ url_for('web.dashboard', q=query, page=1, sort='user', dir=next_user_dir, online=online) }}">
|
||||
User{% if sort=='user' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a
|
||||
href="{{ url_for('web.dashboard', q=query, page=1, sort='platform', dir=next_platform_dir, online=online) }}">
|
||||
Platform{% if sort=='platform' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a
|
||||
href="{{ url_for('web.dashboard', q=query, page=1, sort='total_size', dir=next_total_dir, online=online) }}">
|
||||
Total Storage (GB){% if sort=='total_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a
|
||||
href="{{ url_for('web.dashboard', q=query, page=1, sort='video_count', dir=next_count_dir, online=online) }}">
|
||||
Video Count{% if sort=='video_count' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a href="{{ url_for('web.dashboard', q=query, page=1, sort='avg_size', dir=next_avg_dir, online=online) }}">
|
||||
Avg Size per Video (GB){% if sort=='avg_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, stats in storage_usage %}
|
||||
{% set user, platform = key.split("::") %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/user/{{ user }}" style="color:#4af;">{{ user }}</a>
|
||||
{% set uname = user.lower() %}
|
||||
{% if uname in online_set %}
|
||||
{% extends "base.html" %}
|
||||
{% from "_pagination.html" import pager %}
|
||||
{% block title %}📊 Video Storage Analytics{% endblock %}
|
||||
{% block content %}
|
||||
<h1>📊 Video Storage Analytics</h1>
|
||||
|
||||
<div class="controls">
|
||||
<form method="get" action="{{ url_for('web.dashboard') }}" style="display:inline-flex;gap:8px;align-items:center;">
|
||||
<label>Timeframe:</label>
|
||||
<select name="timeframe" onchange="this.form.submit()">
|
||||
<option value="all" {{ 'selected' if timeframe=='all' else '' }}>All time</option>
|
||||
<option value="week" {{ 'selected' if timeframe=='week' else '' }}>This week</option>
|
||||
<option value="month" {{ 'selected' if timeframe=='month' else '' }}>Last 30 days</option>
|
||||
<option value="year" {{ 'selected' if timeframe=='year' else '' }}>Last 365 days</option>
|
||||
<option value="custom"{{ 'selected' if timeframe=='custom' else '' }}>Custom…</option>
|
||||
</select>
|
||||
<input type="date" name="start" value="{{ start_date or '' }}" placeholder="Start" />
|
||||
<input type="date" name="end" value="{{ end_date or '' }}" placeholder="End" />
|
||||
<input type="hidden" name="q" value="{{ query or '' }}">
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<input type="hidden" name="dir" value="{{ dir }}">
|
||||
<button type="submit">Apply</button>
|
||||
</form>
|
||||
|
||||
<form method="get" action="{{ url_for('web.dashboard') }}" style="display:inline-block;">
|
||||
<input type="text" name="q" placeholder="Search users..." value="{{ query or '' }}">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<table id="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{% set next_user_dir = 'asc' if sort != 'user' or dir == 'desc' else 'desc' %}
|
||||
{% set next_platform_dir = 'asc' if sort != 'platform' or dir == 'desc' else 'desc' %}
|
||||
{% set next_total_dir = 'asc' if sort != 'total_size' or dir == 'desc' else 'desc' %}
|
||||
{% set next_count_dir = 'asc' if sort != 'video_count' or dir == 'desc' else 'desc' %}
|
||||
{% set next_avg_dir = 'asc' if sort != 'avg_size' or dir == 'desc' else 'desc' %}
|
||||
|
||||
<th><a href="{{ url_for('web.dashboard', q=query, page=1, sort='user', dir=next_user_dir) }}">
|
||||
User{% if sort=='user' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a></th>
|
||||
<th><a href="{{ url_for('web.dashboard', q=query, page=1, sort='platform', dir=next_platform_dir) }}">
|
||||
Platform{% if sort=='platform' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a></th>
|
||||
<th><a href="{{ url_for('web.dashboard', q=query, page=1, sort='total_size', dir=next_total_dir) }}">
|
||||
Total Storage (GB){% if sort=='total_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a></th>
|
||||
<th><a href="{{ url_for('web.dashboard', q=query, page=1, sort='video_count', dir=next_count_dir) }}">
|
||||
Video Count{% if sort=='video_count' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a></th>
|
||||
<th><a href="{{ url_for('web.dashboard', q=query, page=1, sort='avg_size', dir=next_avg_dir) }}">
|
||||
Avg Size per Video (GB){% if sort=='avg_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a></th>
|
||||
<th><a href="{{ url_for('web.dashboard', q=query, page=1, sort='last_online', dir='desc' if dir=='asc' else 'asc') }}">
|
||||
Last Online{% if sort=='last_online' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, stats in storage_usage %}
|
||||
{% set user, platform = key.split("::") %}
|
||||
<tr data-username="{{ user|lower }}">
|
||||
<td>
|
||||
<a href="/user/{{ user }}">{{ user }}</a>
|
||||
{% set uname = user.lower() %}
|
||||
{% if uname in online_set %}
|
||||
<span class="status-dot dot-online" title="Online"></span>
|
||||
{% elif uname in recording_offline_set %}
|
||||
{% elif uname in recording_offline_set %}
|
||||
<span class="status-dot dot-record" title="Recording (offline)"></span>
|
||||
{% else %}
|
||||
{% else %}
|
||||
<span class="status-dot dot-offline" title="Offline"></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="https://{{ platform }}.com/{{ user }}" style="color:#4af;">{{ platform }}</a>
|
||||
</td>
|
||||
<td>{{ "%.2f"|format(stats.total_size) }}</td>
|
||||
<td>{{ stats.video_count }}</td>
|
||||
<td>{{ "%.2f"|format(avg_sizes[key]) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Server-side pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="{{ url_for('web.dashboard', page=page-1, q=query) }}">« Prev</a>
|
||||
{% else %}
|
||||
<span>« Prev</span>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<span class="active">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="{{ url_for('web.dashboard', page=p, q=query) }}">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="https://{{ platform }}.com/{{ user }}">{{ platform }}</a></td>
|
||||
<td>{{ "%.2f"|format(stats.total_size) }}</td>
|
||||
<td>{{ stats.video_count }}</td>
|
||||
<td>{{ "%.2f"|format(avg_sizes[key]) }}</td>
|
||||
<td>
|
||||
{% if stats.last_online %}
|
||||
{{ stats.last_online.strftime('%Y-%m-%d') }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %} <a href="{{ url_for('web.dashboard', page=page+1, q=query) }}">Next »</a>
|
||||
{% else %}
|
||||
<span>Next »</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{ pager('web.dashboard', page, total_pages, q=query, sort=sort, dir=dir, timeframe=timeframe, start=start_date, end=end_str) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function refreshStatusDots() {
|
||||
try {
|
||||
const resp = await fetch("/api/get_recording/");
|
||||
if (!resp.ok) return;
|
||||
const streamers = await resp.json();
|
||||
const online = new Set(streamers.filter(s => s.is_online).map(s => (s.username||"").toLowerCase()));
|
||||
const recOff = new Set(streamers.filter(s => !s.is_online).map(s => (s.username||"").toLowerCase()));
|
||||
|
||||
document.querySelectorAll('#analytics-table tbody tr').forEach(tr => {
|
||||
const uname = (tr.dataset.username || "").toLowerCase();
|
||||
const dot = tr.querySelector('.status-dot'); if (!dot) return;
|
||||
dot.classList.remove('dot-online','dot-record','dot-offline');
|
||||
if (online.has(uname)) { dot.classList.add('dot-online'); dot.title='Online'; }
|
||||
else if (recOff.has(uname)) { dot.classList.add('dot-record'); dot.title='Recording (offline)'; }
|
||||
else { dot.classList.add('dot-offline'); dot.title='Offline'; }
|
||||
});
|
||||
} catch(e) { console.error('status refresh failed:', e); }
|
||||
}
|
||||
refreshStatusDots(); setInterval(refreshStatusDots, 20000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,168 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{{ username }}'s Videos</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #111;
|
||||
color: #eee;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin: 10px;
|
||||
color: #0af;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 18px;
|
||||
margin: 20px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.video-card {
|
||||
background: #222;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease;
|
||||
position: relative;
|
||||
/* <-- allow overlay star positioning */
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.thumb-link {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-card img {
|
||||
width: 100%;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.video-size {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* Favorite star */
|
||||
.fav-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: .25rem .45rem;
|
||||
border-radius: 6px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.fav-btn[aria-pressed="true"] {
|
||||
color: gold;
|
||||
}
|
||||
|
||||
.fav-btn:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.pagination a {
|
||||
color: #0af;
|
||||
margin: 0 8px;
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>🎥 Videos for {{ username }}</h1>
|
||||
<a href="/" class="back-link">⬅ Back to Dashboard</a>
|
||||
|
||||
<div class="grid">
|
||||
{% for video in videos %}
|
||||
<div class="video-card">
|
||||
<div class="thumb-wrap">
|
||||
<a class="thumb-link" href="/video/{{ video['video_id'] }}">
|
||||
<img src="/{{ video.thumbnail }}" alt="Thumbnail">
|
||||
</a>
|
||||
|
||||
<!-- Star overlay INSIDE the thumb-wrap so it sits on top of the image -->
|
||||
<button class="fav-btn" data-video-id="{{ video.video_id }}"
|
||||
aria-pressed="{{ 'true' if video.is_favorite else 'false' }}"
|
||||
title="{{ 'Unfavorite' if video.is_favorite else 'Favorite' }}">
|
||||
{{ '★' if video.is_favorite else '☆' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="video-size">{{ "%.2f"|format(video.size/1024) }} GB</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% extends "base.html" %}
|
||||
{% from "_pagination.html" import pager %}
|
||||
{% block title %}{{ username }} — Videos{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>📼 {{ username }}</h1>
|
||||
|
||||
<div class="grid">
|
||||
{% for v in videos %}
|
||||
<div class="card video-card" data-video-id="{{ v.video_id }}">
|
||||
<div class="thumb thumb-wrap">
|
||||
{% set thumb = v.thumbnail %}
|
||||
<a class="thumb-link" href="{{ url_for('web.view_video', video_id=v.video_id) }}">
|
||||
<img
|
||||
src="{{ thumb if thumb and thumb.startswith('http') else ('/' ~ (thumb or '')) }}"
|
||||
alt="Thumbnail"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onerror="this.style.display='none'; this.parentElement.parentElement.querySelector('.fallback').style.display='flex';"
|
||||
>
|
||||
</a>
|
||||
<span class="fallback">🎞️</span>
|
||||
|
||||
<button
|
||||
class="fav-btn"
|
||||
data-video-id="{{ v.video_id }}"
|
||||
aria-pressed="{{ 'true' if v.is_favorite else 'false' }}"
|
||||
title="{{ 'Unfavorite' if v.is_favorite else 'Favorite' }}"
|
||||
>
|
||||
{{ '★' if v.is_favorite else '☆' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="?page={{ page-1 }}">⬅ Prev</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page }} / {{ total_pages }}</span>
|
||||
{% if page < total_pages %} <a href="?page={{ page+1 }}">Next ➡</a>
|
||||
{% endif %}
|
||||
<div class="meta">
|
||||
<div class="row">
|
||||
<span class="muted">Size:</span>
|
||||
{{ "%.2f"|format((v.size or 0)/1024) }} GB
|
||||
</div>
|
||||
{% if v.site %}
|
||||
<div class="row">
|
||||
<span class="muted">Site:</span>
|
||||
<a href="https://{{ v.site }}.com/{{ username }}" target="_blank" rel="noopener">{{ v.site }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if v.created_at %}
|
||||
<div class="row muted">
|
||||
{{ v.created_at }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Handle favorite toggles (and prevent navigating when clicking the star)
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.fav-btn');
|
||||
if (!btn) return;
|
||||
|
||||
// prevent the underlying link click if someone clicks near the corner
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const vid = btn.dataset.videoId;
|
||||
try {
|
||||
const res = await fetch(`/api/fav/toggle/${encodeURIComponent(vid)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || 'Failed');
|
||||
|
||||
const isFav = data.is_favorite;
|
||||
btn.textContent = isFav ? '★' : '☆';
|
||||
btn.setAttribute('aria-pressed', isFav ? 'true' : 'false');
|
||||
btn.title = isFav ? 'Unfavorite' : 'Favorite';
|
||||
} catch (err) {
|
||||
alert('Could not toggle favorite. ' + (err?.message || ''));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{{ pager('web.user_page', page, total_pages, username=username) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.fav-btn');
|
||||
if (!btn) return;
|
||||
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
|
||||
const card = btn.closest('.video-card');
|
||||
const vid = btn.dataset.videoId;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/fav/toggle/${encodeURIComponent(vid)}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.ok) throw new Error(data.error || 'Failed');
|
||||
|
||||
const isFav = data.is_favorite;
|
||||
btn.textContent = isFav ? '★' : '☆';
|
||||
btn.setAttribute('aria-pressed', isFav ? 'true' : 'false');
|
||||
btn.title = isFav ? 'Unfavorite' : 'Favorite';
|
||||
} catch (err) {
|
||||
alert('Could not toggle favorite. ' + (err?.message || ''));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,181 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>👥 Users</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; background:#111; color:#eee; }
|
||||
a { color:#4af; text-decoration:none; }
|
||||
.header { display:flex; gap:12px; align-items:center; justify-content:center; margin:18px 0; flex-wrap:wrap; }
|
||||
input[type="text"] { padding:8px; width:280px; }
|
||||
select, button { padding:8px 12px; background:#222; color:#eee; border:1px solid #444; }
|
||||
|
||||
.grid { width:92%; margin:0 auto; display:grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap:14px; }
|
||||
|
||||
/* Make thumb take full card height */
|
||||
.card {
|
||||
background:#1a1a1a; border:1px solid #333; border-radius:8px; padding:10px;
|
||||
display:grid; grid-template-columns: 180px 1fr; gap:12px; align-items:stretch;
|
||||
min-height: 120px;
|
||||
}
|
||||
.thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 120px;
|
||||
background:#222; border:1px solid #333; border-radius:6px; overflow:hidden;
|
||||
}
|
||||
.thumb img, .thumb .fallback {
|
||||
position:absolute; inset:0;
|
||||
width:100%; height:100%;
|
||||
object-fit:cover; object-position:center center; display:block;
|
||||
}
|
||||
.thumb .fallback { display:none; align-items:center; justify-content:center; font-size:28px; }
|
||||
|
||||
.meta { display:flex; flex-direction:column; gap:6px; }
|
||||
.meta h3 { margin:0; font-size:16px; display:flex; align-items:center; gap:6px; }
|
||||
.muted { color:#bbb; }
|
||||
.row { font-size:14px; }
|
||||
|
||||
.status-dot { display:inline-block; width:10px; height:10px; border-radius:50%; vertical-align:middle; }
|
||||
.dot-online { background:#22c55e; }
|
||||
.dot-record { background:#ef4444; }
|
||||
.dot-offline { background:#666; }
|
||||
|
||||
.pagination { margin:16px 0; text-align:center; }
|
||||
.pagination a, .pagination span { display:inline-block; background:#222; color:#eee; border:1px solid #444; margin:0 5px; padding:6px 12px; text-decoration:none; }
|
||||
.pagination .active { background:#555; }
|
||||
.toolbar { display:flex; gap:10px; align-items:center; justify-content:center; flex-wrap:wrap; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// If an <img> fails, hide it and show the next candidate; if none left, show the fallback emoji.
|
||||
function tryNext(el) {
|
||||
el.style.display = 'none';
|
||||
let sib = el.nextElementSibling;
|
||||
while (sib && sib.tagName !== 'IMG' && !(sib.classList && sib.classList.contains('fallback'))) {
|
||||
sib = sib.nextElementSibling;
|
||||
}
|
||||
if (!sib) return;
|
||||
if (sib.tagName === 'IMG') {
|
||||
sib.style.display = 'block';
|
||||
} else {
|
||||
sib.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>👥 Users</h1>
|
||||
|
||||
<div class="header">
|
||||
<!-- Search -->
|
||||
<form method="get" action="{{ url_for('web.users') }}" class="toolbar">
|
||||
<input type="text" name="q" placeholder="Search users..." value="{{ query or '' }}">
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<input type="hidden" name="dir" value="{{ dir }}">
|
||||
<input type="hidden" name="timeframe" value="{{ timeframe }}">
|
||||
<input type="hidden" name="start" value="{{ start_date or '' }}">
|
||||
<input type="hidden" name="end" value="{{ end_date or '' }}">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
|
||||
<!-- Sort -->
|
||||
{% set next_user_dir = 'asc' if sort != 'user' or dir == 'desc' else 'desc' %}
|
||||
{% set next_site_dir = 'asc' if sort != 'site' or dir == 'desc' else 'desc' %}
|
||||
{% set next_total_dir = 'asc' if sort != 'total_size' or dir == 'desc' else 'desc' %}
|
||||
{% set next_count_dir = 'asc' if sort != 'video_count' or dir == 'desc' else 'desc' %}
|
||||
|
||||
<div class="toolbar">
|
||||
<a href="{{ url_for('web.users', q=query, page=1, sort='user', dir=next_user_dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: User{% if sort=='user' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
|
||||
<a href="{{ url_for('web.users', q=query, page=1, sort='site', dir=next_site_dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Site{% if sort=='site' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
|
||||
<a href="{{ url_for('web.users', q=query, page=1, sort='total_size', dir=next_total_dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Total Size{% if sort=='total_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
|
||||
<a href="{{ url_for('web.users', q=query, page=1, sort='video_count', dir=next_count_dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Videos{% if sort=='video_count' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
|
||||
|
||||
<!-- Online-first toggle -->
|
||||
<a href="{{ url_for('web.users', q=query, page=1, sort=sort, dir=dir, online=('0' if online=='1' else '1'), timeframe=timeframe, start=start_date, end=end_date) }}">
|
||||
{{ '📶 Show online (ON)' if online=='1' else '📶 Show online (OFF)' }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Timeframe -->
|
||||
<form method="get" action="{{ url_for('web.users') }}" class="toolbar">
|
||||
<label>Timeframe:</label>
|
||||
<select name="timeframe" onchange="this.form.submit()">
|
||||
<option value="all" {{ 'selected' if timeframe=='all' else '' }}>All time</option>
|
||||
<option value="week" {{ 'selected' if timeframe=='week' else '' }}>This week</option>
|
||||
<option value="month" {{ 'selected' if timeframe=='month' else '' }}>Last 30 days</option>
|
||||
<option value="year" {{ 'selected' if timeframe=='year' else '' }}>Last 365 days</option>
|
||||
<option value="custom"{{ 'selected' if timeframe=='custom' else '' }}>Custom…</option>
|
||||
</select>
|
||||
<input type="date" name="start" value="{{ start_date or '' }}" placeholder="Start" />
|
||||
<input type="date" name="end" value="{{ end_date or '' }}" placeholder="End" />
|
||||
<input type="hidden" name="q" value="{{ query or '' }}">
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<input type="hidden" name="dir" value="{{ dir }}">
|
||||
<input type="hidden" name="online" value="{{ online }}">
|
||||
<button type="submit">Apply</button>
|
||||
</form>
|
||||
{% extends "base.html" %}
|
||||
{% from "_pagination.html" import pager %}
|
||||
{% block title %}👥 Users{% endblock %}
|
||||
{% block content %}
|
||||
<h1>👥 Users</h1>
|
||||
|
||||
<div class="controls">
|
||||
<form method="get" action="{{ url_for('web.users') }}">
|
||||
<input type="text" name="q" placeholder="Search users..." value="{{ query or '' }}">
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<input type="hidden" name="dir" value="{{ dir }}">
|
||||
<input type="hidden" name="timeframe" value="{{ timeframe }}">
|
||||
<input type="hidden" name="start" value="{{ start_date or '' }}">
|
||||
<input type="hidden" name="end" value="{{ end_date or '' }}">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<!-- sort links -->
|
||||
{% set next_user_dir = 'asc' if sort != 'user' or dir == 'desc' else 'desc' %}
|
||||
{% set next_site_dir = 'asc' if sort != 'site' or dir == 'desc' else 'desc' %}
|
||||
{% set next_total_dir = 'asc' if sort != 'total_size' or dir == 'desc' else 'desc' %}
|
||||
{% set next_count_dir = 'asc' if sort != 'video_count' or dir == 'desc' else 'desc' %}
|
||||
<div class="controls">
|
||||
<a href="{{ url_for('web.users', q=query, page=1, sort='user', dir=next_user_dir, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: User{% if sort=='user' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
|
||||
<a href="{{ url_for('web.users', q=query, page=1, sort='site', dir=next_site_dir, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Site{% if sort=='site' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
|
||||
<a href="{{ url_for('web.users', q=query, page=1, sort='total_size', dir=next_total_dir, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Total Size{% if sort=='total_size' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
|
||||
<a href="{{ url_for('web.users', q=query, page=1, sort='video_count', dir=next_count_dir, timeframe=timeframe, start=start_date, end=end_date) }}">Sort: Videos{% if sort=='video_count' %} {{ '▲' if dir=='asc' else '▼' }}{% endif %}</a>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
{% for c in cards %}
|
||||
<div class="card">
|
||||
<div class="thumb">
|
||||
{% if c.thumb_urls and c.thumb_urls|length %}
|
||||
{# render all candidates; show first, hide the rest; each tries the next on error #}
|
||||
{% for url in c.thumb_urls %}
|
||||
<img src="{{ url }}" loading="lazy" decoding="async" alt="{{ c.user }}" {% if not loop.first %}style="display:none"{% endif %} onerror="tryNext(this)">
|
||||
{% endfor %}
|
||||
<span class="fallback">🎞️</span>
|
||||
<form method="get" action="{{ url_for('web.users') }}">
|
||||
<label>Timeframe:</label>
|
||||
<select name="timeframe" onchange="this.form.submit()">
|
||||
<option value="all" {{ 'selected' if timeframe=='all' else '' }}>All time</option>
|
||||
<option value="week" {{ 'selected' if timeframe=='week' else '' }}>This week</option>
|
||||
<option value="month" {{ 'selected' if timeframe=='month' else '' }}>Last 30 days</option>
|
||||
<option value="year" {{ 'selected' if timeframe=='year' else '' }}>Last 365 days</option>
|
||||
<option value="custom"{{ 'selected' if timeframe=='custom' else '' }}>Custom…</option>
|
||||
</select>
|
||||
<input type="date" name="start" value="{{ start_date or '' }}" />
|
||||
<input type="date" name="end" value="{{ end_date or '' }}" />
|
||||
<input type="hidden" name="q" value="{{ query or '' }}">
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<input type="hidden" name="dir" value="{{ dir }}">
|
||||
<button type="submit">Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
{% for c in cards %}
|
||||
<div class="card user-card" data-username="{{ c.user|lower }}">
|
||||
<div class="thumb">
|
||||
{% if c.thumb_urls and c.thumb_urls|length %}
|
||||
{% for url in c.thumb_urls %}
|
||||
<img src="{{ url }}" loading="lazy" decoding="async" alt="{{ c.user }}" {% if not loop.first %}style="display:none"{% endif %} onerror="this.style.display='none'; const n=this.nextElementSibling; if(n){ if(n.tagName==='IMG'){ n.style.display='block'; } else { n.style.display='flex'; } }">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<span class="fallback">🎞️</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<h3>
|
||||
<a href="{{ url_for('web.user_page', username=c.user) }}">{{ c.user }}</a>
|
||||
{% if c.is_online %}
|
||||
<span class="status-dot dot-online" title="Online"></span>
|
||||
{% elif c.is_recording_offline %}
|
||||
<span class="status-dot dot-record" title="Recording (offline)"></span>
|
||||
{% else %}
|
||||
{# no thumbnails at all → show fallback by default #}
|
||||
<span class="fallback" style="display:flex">🎞️</span>
|
||||
<span class="status-dot dot-offline" title="Offline"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<h3>
|
||||
<a href="{{ url_for('web.user_page', username=c.user) }}">{{ c.user }}</a>
|
||||
{% if c.is_online %}
|
||||
<span class="status-dot dot-online" title="Online"></span>
|
||||
{% elif c.is_recording_offline %}
|
||||
<span class="status-dot dot-record" title="Recording (offline)"></span>
|
||||
{% else %}
|
||||
<span class="status-dot dot-offline" title="Offline"></span>
|
||||
{% endif %}
|
||||
</h3>
|
||||
<div class="row">
|
||||
<span class="muted">Site:</span>
|
||||
<a href="https://{{ c.site }}.com/{{ c.user }}" target="_blank" rel="noopener">{{ c.site }}</a>
|
||||
</div>
|
||||
<div class="row"><span class="muted">Total size:</span> {{ c.total_size_display }} GB</div>
|
||||
<div class="row"><span class="muted">Videos:</span> {{ c.video_count }}</div>
|
||||
</div>
|
||||
</h3>
|
||||
<div class="row"><span class="muted">Site:</span> <a href="https://{{ c.site }}.com/{{ c.user }}" target="_blank" rel="noopener">{{ c.site }}</a></div>
|
||||
<div class="row"><span class="muted">Total size:</span> {{ c.total_size_display }} GB</div>
|
||||
<div class="row"><span class="muted">Videos:</span> {{ c.video_count }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if page > 1 %}
|
||||
<a href="{{ url_for('web.users', page=page-1, q=query, sort=sort, dir=dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">« Prev</a>
|
||||
{% else %}<span>« Prev</span>{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<span class="active">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="{{ url_for('web.users', page=p, q=query, sort=sort, dir=dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<a href="{{ url_for('web.users', page=page+1, q=query, sort=sort, dir=dir, online=online, timeframe=timeframe, start=start_date, end=end_date) }}">Next »</a>
|
||||
{% else %}<span>Next »</span>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{{ pager('web.users', page, total_pages, q=query, sort=sort, dir=dir, timeframe=timeframe, start=start_date, end=end_date) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const resp = await fetch("/api/get_recording/"); if (!resp.ok) return;
|
||||
const s = await resp.json();
|
||||
const online = new Set(s.filter(x => x.is_online).map(x => (x.username||"").toLowerCase()));
|
||||
const recOff = new Set(s.filter(x => !x.is_online).map(x => (x.username||"").toLowerCase()));
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
const u = (card.dataset.username||"").toLowerCase();
|
||||
const dot = card.querySelector('.status-dot'); if (!dot) return;
|
||||
dot.classList.remove('dot-online','dot-record','dot-offline');
|
||||
if (online.has(u)) dot.classList.add('dot-online');
|
||||
else if (recOff.has(u)) dot.classList.add('dot-record');
|
||||
else dot.classList.add('dot-offline');
|
||||
});
|
||||
} catch(e) {}
|
||||
}
|
||||
refreshStatus(); setInterval(refreshStatus, 20000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Loading…
Reference in New Issue