@danhachuel/thunderbolt 0.3.26 → 0.3.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/main.py CHANGED
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import hashlib
4
+ import html
4
5
  import json
5
6
  import re
6
7
  from contextlib import nullcontext
@@ -201,6 +202,30 @@ st.markdown("""
201
202
  [data-testid="stSidebar"] [data-testid="stExpander"] summary p { margin:0; line-height:1; }
202
203
  [data-testid="stSidebar"] [data-testid="stExpander"] > div { padding:0 0 0 0.42rem !important; }
203
204
  [data-testid="stSidebar"] [data-testid="stExpander"] > div [data-testid="stButton"] button { padding-left:0.92rem; font-size:0.83rem; min-height:1.62rem; height:1.62rem; }
205
+ .tb-channel-kanban-card { box-sizing:border-box; min-height:330px; height:330px; padding:0.85rem 0.9rem; border:1px solid color-mix(in srgb, currentColor 18%, transparent); border-radius:14px; background:color-mix(in srgb, currentColor 5%, transparent); color:inherit; box-shadow:0 4px 14px color-mix(in srgb, currentColor 12%, transparent); overflow:hidden; }
206
+ .tb-channel-kanban-card__header { display:flex; align-items:center; gap:0.58rem; min-width:0; }
207
+ .tb-channel-kanban-card__avatar { width:46px; height:46px; flex:0 0 46px; border-radius:10px; object-fit:cover; background:color-mix(in srgb, var(--tb-accent) 14%, transparent); }
208
+ .tb-channel-kanban-card__avatar--fallback { display:flex; align-items:center; justify-content:center; font-weight:800; color:var(--tb-accent); }
209
+ .tb-channel-kanban-card__identity { min-width:0; flex:1 1 auto; }
210
+ .tb-channel-kanban-card__name { font-size:1rem; font-weight:750; line-height:1.18; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
211
+ .tb-channel-kanban-card__niche { margin-top:0.22rem; font-size:0.76rem; opacity:.66; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
212
+ .tb-channel-kanban-card__badges { display:flex; gap:0.28rem; flex:0 0 auto; align-items:center; }
213
+ .tb-channel-kanban-card__badge { padding:0.18rem 0.42rem; border-radius:6px; font-size:0.67rem; font-weight:750; line-height:1.1; white-space:nowrap; }
214
+ .tb-channel-kanban-card__badge--youtube { background:#dc2626; color:#fff; }
215
+ .tb-channel-kanban-card__badge--active { background:color-mix(in srgb, #22c55e 25%, transparent); color:#16a34a; border:1px solid color-mix(in srgb, #22c55e 50%, transparent); }
216
+ .tb-channel-kanban-card__badge--inactive { background:color-mix(in srgb, #ef4444 18%, transparent); color:#dc2626; border:1px solid color-mix(in srgb, #ef4444 45%, transparent); }
217
+ .tb-channel-kanban-card__rule { height:1px; margin:0.72rem 0 0.62rem; background:color-mix(in srgb, currentColor 13%, transparent); }
218
+ .tb-channel-kanban-card__metrics { display:grid; grid-template-columns:repeat(3, minmax(0, 1fr)); gap:0.34rem; }
219
+ .tb-channel-kanban-card__metric { min-width:0; min-height:67px; padding:0.48rem 0.28rem 0.36rem; border-radius:8px; text-align:center; background:color-mix(in srgb, currentColor 4%, transparent); }
220
+ .tb-channel-kanban-card__metric-value { font-size:1.12rem; font-weight:800; line-height:1.15; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
221
+ .tb-channel-kanban-card__metric-label { margin-top:0.3rem; font-size:0.66rem; opacity:.64; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
222
+ .tb-channel-kanban-card__status-row { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:0.34rem; margin-top:0.6rem; }
223
+ .tb-channel-kanban-card__status { min-width:0; padding:0.42rem 0.28rem; border-radius:8px; text-align:center; font-size:0.71rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
224
+ .tb-channel-kanban-card__status strong { font-size:0.92rem; margin-right:0.18rem; }
225
+ .tb-channel-kanban-card__status--doing { color:#d97706; background:color-mix(in srgb, #f59e0b 14%, transparent); border:1px solid color-mix(in srgb, #f59e0b 35%, transparent); }
226
+ .tb-channel-kanban-card__status--done { color:#16a34a; background:color-mix(in srgb, #22c55e 11%, transparent); border:1px solid color-mix(in srgb, #22c55e 30%, transparent); }
227
+ .tb-channel-kanban-card__titles { margin-top:0.58rem; font-size:0.73rem; opacity:.7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
228
+ @media (max-width: 900px) { .tb-channel-kanban-card { min-height:310px; height:310px; } .tb-channel-kanban-card__name { font-size:.9rem; } }
204
229
  .content-card { box-sizing:border-box; padding:1rem 1.1rem; border:1px solid rgba(128,128,128,.28); border-radius:14px; background:rgba(128,128,128,.10); color:inherit; min-height:110px; box-shadow:0 4px 14px rgba(0,0,0,.12); }
205
230
  .content-card { border:1px solid color-mix(in srgb, currentColor 18%, transparent); background:color-mix(in srgb, currentColor 5%, transparent); box-shadow:0 4px 14px color-mix(in srgb, currentColor 12%, transparent); }
206
231
  .content-label { color:inherit; opacity:.72; font-size:.8rem; text-transform:uppercase; letter-spacing:.07em; }
@@ -939,7 +964,7 @@ def render_channel_videos(channel: dict) -> None:
939
964
  channel_id = str(channel.get("id") or "")
940
965
  st.markdown("### Últimos 10 vídeos publicados")
941
966
  st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
942
- refresh_col, view_col = st.columns([1.4, 1])
967
+ refresh_col = st.columns(1)[0]
943
968
  with refresh_col:
944
969
  if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
945
970
  result = fetch_channel_videos_public(channel, limit=10)
@@ -950,42 +975,25 @@ def render_channel_videos(channel: dict) -> None:
950
975
  st.rerun()
951
976
  else:
952
977
  st.warning(result.message)
953
- with view_col:
954
- view_mode = st.radio("Vista", ["Lista", "Kanban"], horizontal=True, key=f"channel_videos_view_{channel_id}", label_visibility="collapsed")
955
978
  videos = st.session_state.get(f"channel_videos_{channel_id}") or channel_videos_for(channel, limit=10)
956
979
  if not videos:
957
980
  st.info("Ainda não existem vídeos sincronizados. Clique em **Actualizar últimos 10 vídeos**.")
958
981
  return
959
- if view_mode == "Lista":
960
- for video in videos[:10]:
961
- with st.container(border=True):
962
- cols = st.columns([0.7, 3.4, 1.4, 1.1])
963
- with cols[0]:
964
- if video.get("thumbnail_url"):
965
- st.image(video["thumbnail_url"], width=64)
966
- else:
967
- st.markdown("### YT")
968
- with cols[1]:
969
- st.write(f"**{video.get('title', 'Vídeo sem título')}**")
970
- st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
971
- with cols[2]:
972
- st.caption(str(video.get("status") or "publicado").title())
973
- with cols[3]:
974
- render_channel_video_editor(video, channel_id)
975
- else:
976
- columns = st.columns(4)
977
- groups = [("planejamento", "Planejamento"), ("produção", "Produção"), ("finalizado", "Finalizado"), ("publicado", "Agendado/Publicado")]
978
- for column, (status_key, label) in zip(columns, groups):
979
- with column:
980
- st.markdown(f"**{label}**")
981
- group = [video for video in videos[:10] if str(video.get("status") or "publicado").lower() in ({status_key} if status_key != "publicado" else {"publicado", "agendado"})]
982
- if not group:
983
- st.caption("Sem vídeos")
984
- for video in group:
985
- with st.container(border=True):
986
- st.write(f"**{video.get('title', 'Vídeo sem título')}**")
987
- st.caption(video.get("published_at") or "Sem data")
988
- render_channel_video_editor(video, channel_id)
982
+ for video in videos[:10]:
983
+ with st.container(border=True):
984
+ cols = st.columns([0.7, 3.4, 1.4, 1.1])
985
+ with cols[0]:
986
+ if video.get("thumbnail_url"):
987
+ st.image(video["thumbnail_url"], width=64)
988
+ else:
989
+ st.markdown("### YT")
990
+ with cols[1]:
991
+ st.write(f"**{video.get('title', 'Vídeo sem título')}**")
992
+ st.caption(f"{video.get('published_at') or 'Sem data'} · {video.get('url') or 'Sem URL'}")
993
+ with cols[2]:
994
+ st.caption(str(video.get("status") or "publicado").title())
995
+ with cols[3]:
996
+ render_channel_video_editor(video, channel_id)
989
997
 
990
998
 
991
999
  def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], youtube_account_labels: dict[str, str], youtube_accounts_by_id: dict[str, dict[str, Any]]) -> None:
@@ -1424,6 +1432,101 @@ def render_tiktok_prompt_masters():
1424
1432
  st.error(str(exc))
1425
1433
 
1426
1434
 
1435
+ def _format_channel_metric(value: Any) -> str:
1436
+ """Format a channel metric compactly for the Kanban card."""
1437
+ if value is None or value == "":
1438
+ return "—"
1439
+ try:
1440
+ number = int(value)
1441
+ except (TypeError, ValueError):
1442
+ return html.escape(str(value))
1443
+ if abs(number) >= 1_000_000:
1444
+ return f"{number / 1_000_000:.1f}M".replace(".0M", "M")
1445
+ if abs(number) >= 1_000:
1446
+ return f"{number / 1_000:.1f}K".replace(".0K", "K")
1447
+ return f"{number:,}".replace(",", ".")
1448
+
1449
+
1450
+ def _channel_pipeline_counts(channel: dict[str, Any]) -> tuple[int, int, int]:
1451
+ """Return in-production, completed and titled task counts for one channel."""
1452
+ channel_id = str(channel.get("id") or "")
1453
+ tasks = read_json("tasks.json", [])
1454
+ if not channel_id or not isinstance(tasks, list):
1455
+ return 0, 0, 0
1456
+ related = [
1457
+ task for task in tasks
1458
+ if isinstance(task, dict) and str(task.get("channel_id") or "") == channel_id
1459
+ ]
1460
+ in_production = sum(1 for task in related if str(task.get("state") or "").lower() == "doing")
1461
+ completed = sum(1 for task in related if str(task.get("state") or "").lower() == "done")
1462
+ titled = sum(1 for task in related if str(task.get("title") or "").strip() or task.get("title_candidates"))
1463
+ return in_production, completed, titled
1464
+
1465
+
1466
+ def _render_channel_kanban_card(channel: dict[str, Any]) -> None:
1467
+ """Render one compact, square-style channel card for the global Kanban view."""
1468
+ ui_language = current_ui_language()
1469
+ name = html.escape(str(channel.get("name") or "Sem nome"))
1470
+ niche = html.escape(channel_niche_label(channel))
1471
+ thumbnail_url = str(channel.get("thumbnail_url") or "").strip()
1472
+ if thumbnail_url:
1473
+ avatar = f'<img class="tb-channel-kanban-card__avatar" src="{html.escape(thumbnail_url, quote=True)}" alt="">'
1474
+ else:
1475
+ avatar = '<div class="tb-channel-kanban-card__avatar tb-channel-kanban-card__avatar--fallback">YT</div>'
1476
+ active = bool(channel.get("active", True))
1477
+ active_label = ui_text("Activo" if active else "Inactivo", ui_language)
1478
+ active_class = "active" if active else "inactive"
1479
+ subscribers = _format_channel_metric(channel.get("subscriber_count"))
1480
+ views = _format_channel_metric(channel.get("view_count"))
1481
+ videos = _format_channel_metric(channel.get("video_count"))
1482
+ in_production, completed, titled = _channel_pipeline_counts(channel)
1483
+ youtube_label = ui_text("YouTube", ui_language)
1484
+ subscribers_label = ui_text("Inscritos", ui_language)
1485
+ views_label = ui_text("Visualizações", ui_language)
1486
+ videos_label = ui_text("Vídeos", ui_language)
1487
+ production_label = ui_text("Em produção", ui_language)
1488
+ completed_label = ui_text("Finalizados", ui_language)
1489
+ titles_label = ui_text("Títulos", ui_language)
1490
+ st.markdown(
1491
+ f"""
1492
+ <div class="tb-channel-kanban-card">
1493
+ <div class="tb-channel-kanban-card__header">
1494
+ {avatar}
1495
+ <div class="tb-channel-kanban-card__identity">
1496
+ <div class="tb-channel-kanban-card__name">{name}</div>
1497
+ <div class="tb-channel-kanban-card__niche">{niche}</div>
1498
+ </div>
1499
+ <div class="tb-channel-kanban-card__badges">
1500
+ <span class="tb-channel-kanban-card__badge tb-channel-kanban-card__badge--youtube">{youtube_label}</span>
1501
+ <span class="tb-channel-kanban-card__badge tb-channel-kanban-card__badge--{active_class}">{active_label}</span>
1502
+ </div>
1503
+ </div>
1504
+ <div class="tb-channel-kanban-card__rule"></div>
1505
+ <div class="tb-channel-kanban-card__metrics">
1506
+ <div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{subscribers}</div><div class="tb-channel-kanban-card__metric-label">{subscribers_label}</div></div>
1507
+ <div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{views}</div><div class="tb-channel-kanban-card__metric-label">{views_label}</div></div>
1508
+ <div class="tb-channel-kanban-card__metric"><div class="tb-channel-kanban-card__metric-value">{videos}</div><div class="tb-channel-kanban-card__metric-label">{videos_label}</div></div>
1509
+ </div>
1510
+ <div class="tb-channel-kanban-card__status-row">
1511
+ <div class="tb-channel-kanban-card__status tb-channel-kanban-card__status--doing"><strong>{in_production}</strong>{production_label}</div>
1512
+ <div class="tb-channel-kanban-card__status tb-channel-kanban-card__status--done"><strong>{completed}</strong>{completed_label}</div>
1513
+ </div>
1514
+ <div class="tb-channel-kanban-card__titles">▣ <strong>{titled}</strong> {titles_label}</div>
1515
+ </div>
1516
+ """,
1517
+ unsafe_allow_html=True,
1518
+ )
1519
+
1520
+
1521
+ def render_registered_channels_kanban(channels: list[dict[str, Any]]) -> None:
1522
+ """Render all registered channels in a responsive three-column Kanban grid."""
1523
+ for row_start in range(0, len(channels), 3):
1524
+ channel_columns = st.columns(3)
1525
+ for column, channel in zip(channel_columns, channels[row_start:row_start + 3]):
1526
+ with column:
1527
+ _render_channel_kanban_card(channel)
1528
+
1529
+
1427
1530
  def render_channels():
1428
1531
  st.title("Canais Youtube")
1429
1532
  st.caption("Escolha entre importar dados públicos do YouTube ou preencher o canal manualmente.")
@@ -1439,7 +1542,11 @@ def render_channels():
1439
1542
  with import_tab:
1440
1543
  st.caption("A pesquisa pública funciona sem API Key. A Data API é opcional e fica disponível numa opção separada para métricas oficiais.")
1441
1544
  source = st.text_input("URL, handle ou ID do canal", placeholder="https://youtube.com/@seucanal", key="youtube_channel_source")
1442
- lookup_mode = st.radio("Método de consulta", ["Página pública — sem API Key", "YouTube Data API — API Key opcional"], horizontal=True, key="youtube_channel_lookup_mode")
1545
+ lookup_cols = st.columns(2)
1546
+ with lookup_cols[0]:
1547
+ lookup_mode = st.radio("Método de consulta", ["Página pública — sem API Key", "YouTube Data API — API Key opcional"], horizontal=True, key="youtube_channel_lookup_mode")
1548
+ with lookup_cols[1]:
1549
+ st.radio("Apresentação dos canais", ["Lista", "Kanban"], horizontal=True, key="youtube_channels_view_mode")
1443
1550
  col1, col2 = st.columns([1, 1])
1444
1551
  with col1:
1445
1552
  if st.button("Buscar no YouTube", type="primary", use_container_width=True, key="youtube_channel_lookup"):
@@ -1675,6 +1782,9 @@ def render_channels():
1675
1782
  if not channels:
1676
1783
  st.info("Nenhum canal cadastrado.")
1677
1784
  return
1785
+ if st.session_state.get("youtube_channels_view_mode", "Lista") == "Kanban":
1786
+ render_registered_channels_kanban(channels)
1787
+ return
1678
1788
  for channel in channels:
1679
1789
  channel_id = str(channel["id"])
1680
1790
  edit_key = f"edit_channel_{channel_id}"
@@ -756,6 +756,18 @@ _CONTENT_TRANSLATION_ROWS = (
756
756
  ("Conteúdo Markdown", "Conteúdo Markdown", "Markdown content", "Markdown 内容", "Markdown-Inhalt", "Nội dung Markdown", "Markdown içeriği", "Содержимое Markdown", "Contenido Markdown", "Konten Markdown", "Contenuto Markdown"),
757
757
  ("Canais Youtube", "Canais Youtube", "YouTube Channels", "YouTube 频道", "YouTube-Kanäle", "Kênh YouTube", "YouTube Kanalları", "Каналы YouTube", "Canales de YouTube", "Kanal YouTube", "Canali YouTube"),
758
758
  ("Método de consulta", "Método de consulta", "Lookup method", "查询方式", "Abfragemethode", "Phương thức tra cứu", "Arama yöntemi", "Метод запроса", "Método de consulta", "Metode pencarian", "Metodo di ricerca"),
759
+ ("Apresentação dos canais", "Apresentação dos canais", "Channel presentation", "频道展示", "Kanaldarstellung", "Hiển thị kênh", "Kanal görünümü", "Представление каналов", "Presentación de canales", "Tampilan kanal", "Presentazione dei canali"),
760
+ ("Lista", "Lista", "List", "列表", "Liste", "Danh sách", "Liste", "Список", "Lista", "Daftar", "Elenco"),
761
+ ("Kanban", "Kanban", "Kanban", "看板", "Kanban", "Kanban", "Kanban", "Канбан", "Kanban", "Kanban", "Kanban"),
762
+ ("YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube", "YouTube"),
763
+ ("Inscritos", "Inscritos", "Subscribers", "订阅者", "Abonnenten", "Người đăng ký", "Aboneler", "Подписчики", "Suscriptores", "Subscriber", "Iscritti"),
764
+ ("Visualizações", "Visualizações", "Views", "观看次数", "Aufrufe", "Lượt xem", "Görüntülemeler", "Просмотры", "Visualizaciones", "Tayangan", "Visualizzazioni"),
765
+ ("Vídeos", "Vídeos", "Videos", "视频", "Videos", "Video", "Videolar", "Видео", "Vídeos", "Video", "Video"),
766
+ ("Em produção", "Em produção", "In production", "制作中", "In Produktion", "Đang sản xuất", "Üretimde", "В производстве", "En producción", "Sedang diproduksi", "In produzione"),
767
+ ("Finalizados", "Finalizados", "Completed", "已完成", "Abgeschlossen", "Đã hoàn tất", "Tamamlandı", "Завершено", "Finalizados", "Selesai", "Completati"),
768
+ ("Títulos", "Títulos", "Titles", "标题", "Titel", "Tiêu đề", "Başlık", "Заголовки", "Títulos", "Judul", "Titoli"),
769
+ ("Activo", "Activo", "Active", "活跃", "Aktiv", "Đang hoạt động", "Aktif", "Активен", "Activo", "Aktif", "Attivo"),
770
+ ("Inactivo", "Inactivo", "Inactive", "不活跃", "Inaktiv", "Không hoạt động", "Pasif", "Неактивен", "Inactivo", "Tidak aktif", "Inattivo"),
759
771
  ("Página pública — sem API Key", "Página pública — sem API Key", "Public page — no API Key", "公开页面 — 无 API 密钥", "Öffentliche Seite — kein API-Schlüssel", "Trang công khai — không cần API Key", "Herkese açık sayfa — API anahtarı yok", "Публичная страница — без API-ключа", "Página pública — sin clave API", "Halaman publik — tanpa Kunci API", "Pagina pubblica — senza API Key"),
760
772
  ("YouTube Data API — API Key opcional", "YouTube Data API — API Key opcional", "YouTube Data API — optional API Key", "YouTube Data API — 可选 API 密钥", "YouTube Data API — optionaler API-Schlüssel", "YouTube Data API — API Key tùy chọn", "YouTube Data API — isteğe bağlı API anahtarı", "YouTube Data API — необязательный API-ключ", "YouTube Data API — clave API opcional", "YouTube Data API — Kunci API opsional", "YouTube Data API — API Key opzionale"),
761
773
  ("Buscar no YouTube", "Buscar no YouTube", "Search YouTube", "搜索 YouTube", "YouTube durchsuchen", "Tìm trên YouTube", "YouTube'da ara", "Поиск на YouTube", "Buscar en YouTube", "Cari di YouTube", "Cerca su YouTube"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",