Files

251 lines
14 KiB
Markdown

# 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](../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](../../.opencode/agent/webapp-builder.md)).
- Todo el estado vive en **un solo** componente Alpine, `window.platform()` en `src/yt_scraper/webapp/static/app.js`; no crear componentes nuevos.
- El orden de `<script defer>` en `index.html` (app.js antes de alpinejs) es carga funcional: no reordenar scripts.
- No tocar backend, endpoints ni `store.py`: `/api/videos` ya 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 con `start-server.bat`.
- Estilo visual existente: dark "command center", `glass`, `accent-grad btn-primary` como tratamiento activo (mismo patrón que la paginación).
- Sin acciones por tarjeta (`.md`/`Clip` quedan en tabla y detalle).
---
### Task 1: Estado y persistencia en app.js
**Files:**
- Modify: `src/yt_scraper/webapp/static/app.js` (línea 39 store `videos`; `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'`) y `setVideoView(v)` — el HTML del Task 2/3 los usa vía `videos.view` / `@click="setVideoView('grid')"`.
- [ ] **Step 1: Añadir `view` al store**
En `app.js:39`, cambiar:
```js
videos: { items: [], total: 0, page: 1, size: 25, selected: [] },
```
por:
```js
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:
```js
const size = Number(localStorage.getItem("videos-size"));
if ([10, 25, 50, 100].includes(size)) this.videos.size = size;
```
añadir:
```js
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:
```js
// 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**
```bash
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.view` y `setVideoView(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:
```html
<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:
```html
<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:
```html
<!-- 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**
```bash
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 de `app.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:
```html
<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-selected` existe en styles.css:179 con `!important` → aplica igual sobre la tarjeta div que sobre el `<tr>`.
- `line-clamp-2` ya 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 `.stop` para 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`
1. Alternar tabla ⇄ grid: ambas muestran los mismos vídeos con el filtro activo.
2. En grid: pasar el ratón sobre una tarjeta → checkbox visible; marcar 3 → barra bulk aparece con "3 selected" → "Download .md" encola el job.
3. Click en tarjeta (fuera del checkbox) → abre el detalle; Back → vuelve a grid conservando página.
4. Paginar en grid: Prev/Next/números funcionan, scroll arriba, sin repeticiones.
5. Recargar página: la vista elegida se conserva; borrar `localStorage["videos-view"]` → cae a tabla.
6. Tarjeta de vídeo blocked: pill candado con label; badge de duración visible en tarjetas con duración.
7. Miniatura rota: `onerror` la oculta sin romper el layout (contenedor aspect-video mantiene proporción).
8. 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**
```bash
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).