14 KiB
Vista Grid estilo YouTube (sección Vídeos) — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Añadir a la sección Vídeos un conmutador tabla ⇄ grid donde el grid replica el look de YouTube (tarjetas 16:9 con miniatura, duración, título y metadatos) manteniendo filtros, orden, selección masiva y paginación compartidos.
Architecture: Solo frontend. El estado videos.view ('table'|'grid') vive en el componente Alpine único window.platform(); la elección se persiste en localStorage["videos-view"]. En index.html los dos layouts son bloques hermanos alternados con <template x-if>; el toggle es una fila fina sobre ellos.
Tech Stack: Alpine 3 + Tailwind CDN (sin build step), estáticos servidos por FastAPI.
Spec: docs/superpowers/specs/2026-08-22-videos-grid-view-design.md
Global Constraints
- Sin build step: todo cambio es HTML/JS/CSS estático editado a mano (.opencode/agent/webapp-builder.md).
- Todo el estado vive en un solo componente Alpine,
window.platform()ensrc/yt_scraper/webapp/static/app.js; no crear componentes nuevos. - El orden de
<script defer>enindex.html(app.js antes de alpinejs) es carga funcional: no reordenar scripts. - No tocar backend, endpoints ni
store.py:/api/videosya devuelve todos los campos que necesita la tarjeta. - Sin tests automatizados de frontend (el repo no tiene infra JS): verificación =
node --check+ smoke manual constart-server.bat. - Estilo visual existente: dark "command center",
glass,accent-grad btn-primarycomo tratamiento activo (mismo patrón que la paginación). - Sin acciones por tarjeta (
.md/Clipquedan en tabla y detalle).
Task 1: Estado y persistencia en app.js
Files:
- Modify:
src/yt_scraper/webapp/static/app.js(línea 39 storevideos;init()líneas 92-93; método nuevo junto a los helpers de selección ~línea 463)
Interfaces:
-
Consumes: nada nuevo.
-
Produces:
this.videos.view('table' | 'grid', default'table') ysetVideoView(v)— el HTML del Task 2/3 los usa víavideos.view/@click="setVideoView('grid')". -
Step 1: Añadir
viewal store
En app.js:39, cambiar:
videos: { items: [], total: 0, page: 1, size: 25, selected: [] },
por:
videos: { items: [], total: 0, page: 1, size: 25, selected: [], view: "table" },
- Step 2: Restaurar la elección en init()
En app.js dentro de init(), justo después del bloque:
const size = Number(localStorage.getItem("videos-size"));
if ([10, 25, 50, 100].includes(size)) this.videos.size = size;
añadir:
const savedView = localStorage.getItem("videos-view");
if (savedView === "table" || savedView === "grid") this.videos.view = savedView;
- Step 3: Añadir setVideoView()
En app.js, inmediatamente antes de toggleSelect(id) { (la sección de helpers de selección, ~línea 463), añadir:
// View mode of the Videos section ('table' | 'grid'). UI preference like
// videos-size: persisted in localStorage, never in the URL.
setVideoView(v) {
if (v !== "table" && v !== "grid") return;
this.videos.view = v;
try { localStorage.setItem("videos-view", v); } catch (_) {}
},
- Step 4: Verificar sintaxis JS
Run: node --check src/yt_scraper/webapp/static/app.js
Expected: sin salida (exit 0). Si node no está disponible, verificar abriendo la webapp en el Task 4.
- Step 5: Commit
git add src/yt_scraper/webapp/static/app.js
git commit -m "feat(webapp): estado y persistencia de vista tabla/grid en Videos"
Task 2: Conmutador de vista + alternancia x-if en index.html
Files:
- Modify:
src/yt_scraper/webapp/static/index.html(contenedor de resultados, líneas 301-352)
Interfaces:
-
Consumes:
videos.viewysetVideoView(v)del Task 1. -
Produces: la estructura de dos bloques
<template x-if>que el Task 3 completa con el grid (este task deja la tabla funcional tal cual). -
Step 1: Envolver la tabla en template x-if
En index.html, la tabla vive en:
<div class="glass overflow-hidden">
<table class="tbl">
(que cierra con </table> + </div> en las líneas ~351-352, justo antes del comentario <!-- pagination -->). Envolver ese <div class="glass overflow-hidden">…</div> completo en:
<template x-if="videos.view==='table'">
<div class="glass overflow-hidden">
… contenido actual de la tabla SIN CAMBIOS …
</div>
</template>
Reindentar una nivel el interior. Ningún atributo ni clase de la tabla cambia.
- Step 2: Insertar la fila del toggle
Justo encima del <template x-if="videos.view==='table'"> recién creado, añadir:
<!-- table ⇄ grid switch -->
<div class="flex justify-end">
<div class="inline-flex rounded-lg border border-zinc-800 bg-zinc-900/60 p-1 gap-1" role="group" aria-label="View mode">
<button type="button" class="btn !py-1 !px-2" :class="videos.view==='table' ? 'accent-grad btn-primary' : 'btn-ghost'" :aria-pressed="(videos.view==='table').toString()" @click="setVideoView('table')" title="Table view">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M3 5h18v2H3zm0 6h18v2H3zm0 6h18v2H3z"/></svg>
</button>
<button type="button" class="btn !py-1 !px-2" :class="videos.view==='grid' ? 'accent-grad btn-primary' : 'btn-ghost'" :aria-pressed="(videos.view==='grid').toString()" @click="setVideoView('grid')" title="Grid view">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"><path d="M3 3h8v8H3zm10 0h8v8h-8zM3 13h8v8H3zm10 0h8v8h-8z"/></svg>
</button>
</div>
</div>
- Step 3: Smoke manual
Run: start-server.bat, abrir Vídeos.
Expected: la tabla se ve idéntica a antes; el toggle aparece arriba a la derecha; pulsar el icono de grid NO cambia nada visible todavía pero el botón grid queda activo (accent), se refresca la página y sigue activo (localStorage); volver a tabla también persiste.
- Step 4: Commit
git add src/yt_scraper/webapp/static/index.html
git commit -m "feat(webapp): conmutador de vista tabla/grid en Videos"
Task 3: Bloque grid de tarjetas estilo YouTube
Files:
- Modify:
src/yt_scraper/webapp/static/index.html(insertar tras el cierre</template>del bloque tabla, antes de<!-- pagination -->)
Interfaces:
-
Consumes:
videos.view,setVideoView(Task 1); helpers existentes deapp.js:ts(sec),fmtNum(n),chanName(id),videoDate(v),videoDateTitle(v),statusClass(s),isBlocked(v),blockLabel(v),blockTitle(v),isSelected(id),toggleSelect(id),openVideo(id),loading.videos,videos.items. -
Produces: la vista grid completa (fin de feature).
-
Step 1: Insertar el bloque grid
Entre el </template> que cierra el bloque tabla y <!-- pagination -->, añadir:
<template x-if="videos.view==='grid'">
<div>
<template x-if="loading.videos"><div class="text-center text-zinc-500 py-10">loading…</div></template>
<template x-if="!loading.videos && videos.items.length===0"><div class="text-center text-zinc-500 py-10"><div>No videos match these filters.</div><button class="btn btn-ghost mt-3" @click.stop="filters={ channel:'', status:'', from:'', to:'', min_dur:'', q:'', sort:'upload_date' }; loadVideos(1)">Clear filters</button></div></template>
<template x-if="!loading.videos && videos.items.length>0">
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 2xl:grid-cols-5 gap-4">
<template x-for="v in videos.items" :key="v.video_id">
<div class="glass group relative cursor-pointer hover:border-rose-500/40 transition-colors overflow-hidden"
:class="isSelected(v.video_id) ? 'row-selected' : ''"
role="button" tabindex="0"
@click="openVideo(v.video_id)"
@keydown.enter.prevent="openVideo(v.video_id)">
<div class="relative aspect-video bg-zinc-900">
<img class="absolute inset-0 w-full h-full object-cover" :src="'/api/thumbnails/'+v.video_id" :alt="v.title" onerror="this.style.visibility='hidden'" />
<span x-show="v.duration" class="absolute bottom-1.5 right-1.5 font-mono text-[0.7rem] leading-none px-1.5 py-1 rounded bg-black/80 text-white" x-text="ts(v.duration)"></span>
<button type="button" @click.stop="toggleSelect(v.video_id)"
class="absolute top-1.5 left-1.5 w-6 h-6 rounded-md border flex items-center justify-center transition-opacity"
:class="isSelected(v.video_id) ? 'bg-rose-500 border-rose-400 opacity-100' : 'bg-black/70 border-zinc-300/70 opacity-0 group-hover:opacity-100'"
:aria-pressed="isSelected(v.video_id).toString()" title="Select video">
<svg x-show="isSelected(v.video_id)" class="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>
</button>
</div>
<div class="p-3 space-y-1.5">
<div class="text-sm font-medium text-zinc-100 line-clamp-2 leading-snug" x-text="v.title" :title="v.title"></div>
<div class="text-xs text-zinc-400 truncate" x-text="chanName(v.channel_id)" :title="chanName(v.channel_id)"></div>
<div class="flex items-center gap-2 font-mono text-xs text-zinc-400 flex-wrap">
<span x-text="fmtNum(v.view_count)"></span>
<span class="text-zinc-600">·</span>
<span :class="v.upload_date ? '' : 'italic text-zinc-600'" x-text="videoDate(v)" :title="videoDateTitle(v)"></span>
</div>
<div class="flex items-center gap-1 flex-wrap pt-0.5">
<span class="pill" :class="statusClass(v.status)" x-text="v.status" :title="v.error_msg || v.status"></span>
<span x-show="isBlocked(v)" class="pill st-locked" :title="blockTitle(v)">
<svg class="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 1 1 8 0v3"/></svg>
<span x-text="blockLabel(v)"></span>
</span>
</div>
</div>
</div>
</template>
</div>
</template>
</div>
</template>
Notas de diseño ya decididas:
-
.row-selectedexiste en styles.css:179 con!important→ aplica igual sobre la tarjeta div que sobre el<tr>. -
line-clamp-2ya se usa en index.html:553 → Tailwind CDN lo resuelve. -
Checkbox estilo YouTube: aparece al hover (
group-hover) o permanece si está seleccionada; click con.stoppara no abrir el detalle. -
La selección masiva, barra bulk, paginación y filtros NO se tocan: viven fuera de estos bloques.
-
Step 2: Verificación manual completa (checklist del spec)
Run: start-server.bat
- Alternar tabla ⇄ grid: ambas muestran los mismos vídeos con el filtro activo.
- En grid: pasar el ratón sobre una tarjeta → checkbox visible; marcar 3 → barra bulk aparece con "3 selected" → "Download .md" encola el job.
- Click en tarjeta (fuera del checkbox) → abre el detalle; Back → vuelve a grid conservando página.
- Paginar en grid: Prev/Next/números funcionan, scroll arriba, sin repeticiones.
- Recargar página: la vista elegida se conserva; borrar
localStorage["videos-view"]→ cae a tabla. - Tarjeta de vídeo blocked: pill candado con label; badge de duración visible en tarjetas con duración.
- Miniatura rota:
onerrorla oculta sin romper el layout (contenedor aspect-video mantiene proporción). - La tabla sigue funcionando exactamente igual que antes.
Run: python -m pytest tests/ -q
Expected: suite verde (backend intacto; sanity check barato).
- Step 3: Commit
git add src/yt_scraper/webapp/static/index.html
git commit -m "feat(webapp): grid de tarjetas estilo YouTube en Videos"
Self-review hecho
- Cobertura del spec: estado+persistencia (Task 1), toggle (Task 2), tarjetas/clamps/badges/pills/loading/empty (Task 3), verificación manual ítem a ítem (Task 3 Step 2). Fuera de alcance respetado: cero cambios backend, sin acciones por tarjeta.
- Placeholders: ninguno; todo step lleva código literal o comando exacto.
- Consistencia de nombres:
videos.view,setVideoView,"videos-view"usados igual en Tasks 1-3; helpers referenciados existen en app.js (verificado: ts:1265, fmtNum:1293, videoDate:1317, videoDateTitle:1324, chanName:1345, blockLabel:1355, blockTitle:1365, isBlocked:1374, statusClass:1376, toggleSelect:464, isSelected:469).