Files

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() 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:

      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.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:

          <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 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:

          <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
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).