@danhachuel/thunderbolt 0.4.98 → 0.5.0

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
@@ -555,9 +555,9 @@ def _render_library_name_editor(kind: str, path: Path, current_name: str) -> str
555
555
  edited_name = st.text_input("Nome de apresentação", value=current_name, max_chars=120, key=f"{edit_key}_input")
556
556
  save_col, cancel_col = st.columns(2)
557
557
  with save_col:
558
- save_name = st.form_submit_button("Guardar nome", type="primary", use_container_width=True)
558
+ save_name = st.form_submit_button("Guardar nome", type="primary", width='stretch')
559
559
  with cancel_col:
560
- cancel_name = st.form_submit_button("Cancelar", use_container_width=True)
560
+ cancel_name = st.form_submit_button("Cancelar", width='stretch')
561
561
  if save_name:
562
562
  try:
563
563
  set_display_name(kind, path, edited_name)
@@ -573,7 +573,7 @@ def _render_library_name_editor(kind: str, path: Path, current_name: str) -> str
573
573
 
574
574
 
575
575
  def _render_card_pencil(edit_key: str) -> None:
576
- if st.button("✏️", help="Editar nome de apresentação", key=f"pencil_{edit_key}", type="tertiary", use_container_width=True):
576
+ if st.button("✏️", help="Editar nome de apresentação", key=f"pencil_{edit_key}", type="tertiary", width='stretch'):
577
577
  st.session_state[edit_key] = True
578
578
  st.rerun()
579
579
 
@@ -1040,7 +1040,7 @@ def render_video_generation_settings(
1040
1040
  st.button(
1041
1041
  "Gerar tópico, roteiro e palavras-chave com IA",
1042
1042
  key=f"{prefix}_generate_video_content",
1043
- use_container_width=True,
1043
+ width='stretch',
1044
1044
  type="secondary",
1045
1045
  icon=":material/auto_awesome:",
1046
1046
  on_click=generate_content_callback,
@@ -1053,7 +1053,7 @@ def render_video_generation_settings(
1053
1053
  st.button(
1054
1054
  "Salvar rascunho",
1055
1055
  key=f"{prefix}_save_draft",
1056
- use_container_width=True,
1056
+ width='stretch',
1057
1057
  type="secondary",
1058
1058
  icon=":material/save:",
1059
1059
  on_click=save_draft_callback,
@@ -1112,7 +1112,7 @@ def render_video_generation_settings(
1112
1112
  type=["mp3", "wav", "m4a", "aac", "flac", "ogg"],
1113
1113
  key=f"{prefix}_voiceover_upload",
1114
1114
  )
1115
- if uploaded_voiceover is not None and st.button("Guardar áudio de narração", key=f"{prefix}_voiceover_store", use_container_width=True):
1115
+ if uploaded_voiceover is not None and st.button("Guardar áudio de narração", key=f"{prefix}_voiceover_store", width='stretch'):
1116
1116
  try:
1117
1117
  stored_voiceover = store_voiceover_file(uploaded_voiceover.name, uploaded_voiceover.getvalue())
1118
1118
  st.session_state[f"{prefix}_voiceover_file"] = str(stored_voiceover)
@@ -1244,7 +1244,7 @@ def channel_videos_for(channel: dict, limit: int = 10) -> list[dict[str, Any]]:
1244
1244
  def render_channel_video_editor(video: dict, channel_id: str) -> None:
1245
1245
  video_id = str(video.get("id") or "")
1246
1246
  edit_key = f"channel_video_edit_{video_id}"
1247
- if st.button("Editar vídeo", key=f"edit_channel_video_{video_id}", use_container_width=True):
1247
+ if st.button("Editar vídeo", key=f"edit_channel_video_{video_id}", width='stretch'):
1248
1248
  st.session_state[edit_key] = True
1249
1249
  st.rerun()
1250
1250
  if st.session_state.get(edit_key):
@@ -1268,7 +1268,7 @@ def render_channel_videos(channel: dict) -> None:
1268
1268
  st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
1269
1269
  refresh_col = st.columns(1)[0]
1270
1270
  with refresh_col:
1271
- if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
1271
+ if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", width='stretch'):
1272
1272
  result = fetch_channel_videos_public(channel, limit=10)
1273
1273
  if result.ok:
1274
1274
  videos = _merge_channel_videos(channel_id, result.data.get("videos", []))
@@ -1325,7 +1325,7 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
1325
1325
  edited_description = st.text_area("Descrição", value=str(channel.get("description") or ""), height=100)
1326
1326
  edited_automation = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"edit_automation_{channel_id}")
1327
1327
  edited_time = st.text_input("Horário diário (HH:MM)", value=str(channel.get("automation_time") or "00:00"))
1328
- save_channel = st.form_submit_button("Guardar alterações", type="primary", use_container_width=True)
1328
+ save_channel = st.form_submit_button("Guardar alterações", type="primary", width='stretch')
1329
1329
  cancel_edit = st.form_submit_button("Cancelar edição")
1330
1330
  if cancel_edit:
1331
1331
  st.session_state.pop(f"edit_channel_{channel_id}", None)
@@ -1654,7 +1654,7 @@ def render_thumbnail_blueprints():
1654
1654
  with cols[1]:
1655
1655
  script = st.selectbox("Blueprint de roteiro associado", blueprint_ids, index=blueprint_ids.index(current_script) if current_script in blueprint_ids else 0, format_func=lambda item: blueprint_labels.get(item, item or "Sem Blueprint padrão"), key=f"thumbnail_script_blueprint_channel_{channel_id}")
1656
1656
  with cols[2]:
1657
- if st.button("Guardar par", key=f"thumbnail_blueprint_save_{channel_id}", use_container_width=True):
1657
+ if st.button("Guardar par", key=f"thumbnail_blueprint_save_{channel_id}", width='stretch'):
1658
1658
  if thumb == "Generic_Thumbnail_Blueprint" and script:
1659
1659
  st.error("Not Allowed to Associate, System Use Only")
1660
1660
  else:
@@ -1727,7 +1727,7 @@ def render_tiktok_accounts():
1727
1727
  st.info("A pesquisa consulta apenas a página pública do perfil e pode devolver dados incompletos. Se o TikTok bloquear a consulta, utilize o cadastro manual.")
1728
1728
  with st.form("tiktok_public_lookup_form"):
1729
1729
  lookup_source = st.text_input("URL pública ou @handle", placeholder="https://www.tiktok.com/@conta ou @conta", key="tiktok_public_lookup_source")
1730
- lookup_submitted = st.form_submit_button("Pesquisar perfil público", type="primary", use_container_width=True)
1730
+ lookup_submitted = st.form_submit_button("Pesquisar perfil público", type="primary", width='stretch')
1731
1731
  if lookup_submitted:
1732
1732
  result = fetch_public_tiktok_profile(lookup_source)
1733
1733
  st.session_state["tiktok_public_lookup"] = {"ok": result.ok, "message": result.message, "data": result.data}
@@ -1747,7 +1747,7 @@ def render_tiktok_accounts():
1747
1747
  with metric_cols[3]: st.metric("Vídeos", lookup_data.get("video_count") if lookup_data.get("video_count") is not None else "—")
1748
1748
  display_name = st.text_input("Nome da conta", value=str(lookup_data.get("name") or lookup_data.get("username") or ""), key="tiktok_lookup_display_name")
1749
1749
  notes = st.text_area("Observações internas", value=str(lookup_data.get("notes") or ""), key="tiktok_lookup_notes", height=80)
1750
- if st.button("Cadastrar conta TikTok", type="primary", use_container_width=True, key="tiktok_register_public_account"):
1750
+ if st.button("Cadastrar conta TikTok", type="primary", width='stretch', key="tiktok_register_public_account"):
1751
1751
  try:
1752
1752
  stored = {**lookup_data, "name": display_name.strip() or lookup_data.get("username", ""), "notes": notes.strip(), "source": "public_lookup"}
1753
1753
  _upsert_tiktok_account(stored)
@@ -1763,7 +1763,7 @@ def render_tiktok_accounts():
1763
1763
  manual_source = st.text_input("@handle ou URL pública", placeholder="@minhaconta", key="tiktok_manual_source")
1764
1764
  manual_name = st.text_input("Nome da conta", placeholder="Nome de apresentação", key="tiktok_manual_name")
1765
1765
  manual_notes = st.text_area("Observações internas", height=90, key="tiktok_manual_notes")
1766
- manual_submitted = st.form_submit_button("Guardar cadastro manual", type="primary", use_container_width=True)
1766
+ manual_submitted = st.form_submit_button("Guardar cadastro manual", type="primary", width='stretch')
1767
1767
  if manual_submitted:
1768
1768
  try:
1769
1769
  reference = normalize_tiktok_reference(manual_source)
@@ -1802,7 +1802,7 @@ def render_tiktok_accounts():
1802
1802
  edited_name = st.text_input("Nome da conta", value=str(account.get("name") or account.get("username") or ""), key=f"edit_tiktok_name_{account_id}")
1803
1803
  edited_source = st.text_input("@handle ou URL pública", value=str(account.get("public_url") or account.get("url") or account.get("handle") or ""), key=f"edit_tiktok_source_{account_id}")
1804
1804
  edited_notes = st.text_area("Observações internas", value=str(account.get("notes") or ""), key=f"edit_tiktok_notes_{account_id}", height=80)
1805
- edit_submitted = st.form_submit_button("Guardar conta", type="primary", use_container_width=True)
1805
+ edit_submitted = st.form_submit_button("Guardar conta", type="primary", width='stretch')
1806
1806
  if edit_submitted:
1807
1807
  try:
1808
1808
  reference = normalize_tiktok_reference(edited_source)
@@ -1827,7 +1827,7 @@ def render_tiktok_prompt_masters():
1827
1827
  uploaded_name = Path(uploaded_prompt.name).stem
1828
1828
  prompt_name = st.text_input("Nome do Prompt Master", value=uploaded_name, key="tiktok_prompt_master_name")
1829
1829
  replace_prompt = st.checkbox("Permitir substituir um ficheiro existente", key="tiktok_prompt_master_replace")
1830
- if st.button("Guardar Prompt Master", type="primary", use_container_width=True, key="save_tiktok_prompt_master"):
1830
+ if st.button("Guardar Prompt Master", type="primary", width='stretch', key="save_tiktok_prompt_master"):
1831
1831
  safe_stem = re.sub(r"[^A-Za-z0-9À-ÿ._-]+", "-", prompt_name.strip() or uploaded_name).strip(".-") or "prompt-master"
1832
1832
  destination = TIKTOK_PROMPT_MASTERS / f"{safe_stem}.md"
1833
1833
  if destination.exists() and not replace_prompt:
@@ -1873,14 +1873,14 @@ def render_tiktok_prompt_masters():
1873
1873
  edited_content = st.text_area("Conteúdo Markdown", value=content, height=360, key=f"tiktok_prompt_master_editor_{path.stem}")
1874
1874
  prompt_cols = st.columns(3)
1875
1875
  with prompt_cols[0]:
1876
- if st.button("Guardar alterações", type="primary", use_container_width=True, key=f"save_prompt_master_{path.stem}"):
1876
+ if st.button("Guardar alterações", type="primary", width='stretch', key=f"save_prompt_master_{path.stem}"):
1877
1877
  path.write_text(edited_content.rstrip() + "\n", encoding="utf-8")
1878
1878
  st.success("Prompt Master actualizado.")
1879
1879
  st.rerun()
1880
1880
  with prompt_cols[1]:
1881
- st.download_button("Descarregar", data=content.encode("utf-8"), file_name=path.name, mime="text/markdown", use_container_width=True, key=f"download_prompt_master_{path.stem}")
1881
+ st.download_button("Descarregar", data=content.encode("utf-8"), file_name=path.name, mime="text/markdown", width='stretch', key=f"download_prompt_master_{path.stem}")
1882
1882
  with prompt_cols[2]:
1883
- if st.button("Apagar", use_container_width=True, key=f"delete_prompt_master_{path.stem}"):
1883
+ if st.button("Apagar", width='stretch', key=f"delete_prompt_master_{path.stem}"):
1884
1884
  path.unlink(missing_ok=True)
1885
1885
  st.success("Prompt Master removido da biblioteca TikTok.")
1886
1886
  st.rerun()
@@ -1956,11 +1956,11 @@ def render_tiktok_channels():
1956
1956
  source = st.text_input("URL pública ou @handle", placeholder="https://www.tiktok.com/@conta", key="tiktok_channel_source")
1957
1957
  lookup_cols = st.columns([1, 1])
1958
1958
  with lookup_cols[0]:
1959
- if st.button("Buscar no Tiktok", type="primary", use_container_width=True, key="tiktok_channel_lookup"):
1959
+ if st.button("Buscar no Tiktok", type="primary", width='stretch', key="tiktok_channel_lookup"):
1960
1960
  result = fetch_public_tiktok_profile(source)
1961
1961
  st.session_state["tiktok_channel_import"] = {"ok": result.ok, "message": result.message, "data": result.data}
1962
1962
  with lookup_cols[1]:
1963
- if st.button("Limpar importação", use_container_width=True, key="tiktok_channel_clear"):
1963
+ if st.button("Limpar importação", width='stretch', key="tiktok_channel_clear"):
1964
1964
  for key in ("tiktok_channel_import", "tiktok_import_language", "tiktok_import_niche", "tiktok_import_prompt", "tiktok_import_description"):
1965
1965
  st.session_state.pop(key, None)
1966
1966
  st.rerun()
@@ -2145,7 +2145,7 @@ def render_tiktok_channels():
2145
2145
  if channels:
2146
2146
  st.dataframe(
2147
2147
  [{k: c.get(v, "") for k, v in {"URL canal": "url", "Nome canal": "name", "Handle canal": "handle", "Idioma": "language", "Nicho": "niche", "Prompt Master": "default_prompt_master", "Narrador/Voz Padrão": "default_voice", "Estilo wide": "style_wide", "Automação ligada": "automation_on", "Horário diário (HH:MM)": "automation_time", "Proporção": "video_aspect_ratio", "Activo": "active", "Descrição": "description"}.items()} for c in channels],
2148
- use_container_width=True,
2148
+ width='stretch',
2149
2149
  hide_index=True,
2150
2150
  height=360,
2151
2151
  )
@@ -2202,7 +2202,7 @@ def render_channels():
2202
2202
  st.caption("Os canais cadastrados são apresentados apenas em lista.")
2203
2203
  col1, col2 = st.columns([1, 1])
2204
2204
  with col1:
2205
- if st.button("Buscar no YouTube", type="primary", use_container_width=True, key="youtube_channel_lookup"):
2205
+ if st.button("Buscar no YouTube", type="primary", width='stretch', key="youtube_channel_lookup"):
2206
2206
  search_source = str(source or "").strip()
2207
2207
  st.session_state.pop("yt_import", None)
2208
2208
  st.session_state.pop("yt_message", None)
@@ -2227,7 +2227,7 @@ def render_channels():
2227
2227
  st.session_state["yt_message"] = f"A pesquisa do YouTube falhou ({type(exc).__name__}). Confirme o URL/handle e tente novamente. Detalhe: {str(exc)[:240]}"
2228
2228
  st.session_state["yt_ok"] = False
2229
2229
  with col2:
2230
- if st.button("Limpar importação", use_container_width=True, key="youtube_channel_clear"):
2230
+ if st.button("Limpar importação", width='stretch', key="youtube_channel_clear"):
2231
2231
  for key in ("yt_import", "yt_message", "yt_ok"):
2232
2232
  st.session_state.pop(key, None)
2233
2233
  st.rerun()
@@ -2309,7 +2309,7 @@ def render_channels():
2309
2309
  data=build_channel_template_xlsx(),
2310
2310
  file_name="modelo_canais_youtube.xlsx",
2311
2311
  mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
2312
- use_container_width=True,
2312
+ width='stretch',
2313
2313
  key="download_channel_spreadsheet_template",
2314
2314
  )
2315
2315
  with help_col:
@@ -2321,7 +2321,7 @@ def render_channels():
2321
2321
  key="channel_spreadsheet_upload",
2322
2322
  help="Apenas a primeira aba do arquivo será importada. Linhas sem URL, nome e handle são ignoradas.",
2323
2323
  )
2324
- if st.button("Ler e preparar planilha", type="primary", use_container_width=True, key="read_channel_spreadsheet"):
2324
+ if st.button("Ler e preparar planilha", type="primary", width='stretch', key="read_channel_spreadsheet"):
2325
2325
  if uploaded_sheet is None:
2326
2326
  st.error("Selecione um arquivo Excel antes de continuar.")
2327
2327
  else:
@@ -2371,9 +2371,9 @@ def render_channels():
2371
2371
  "Descrição": "Preencher via YouTube" if not row.get("description") else "Da planilha",
2372
2372
  "Estado": "Já cadastrado" if find_duplicate_channel({"name": row.get("name"), "handle": row.get("handle"), "url": row.get("url")}, existing_spreadsheet_channels) else "Novo",
2373
2373
  })
2374
- st.dataframe(preview_rows, use_container_width=True, hide_index=True, height=min(420, 86 + 38 * len(preview_rows)))
2374
+ st.dataframe(preview_rows, width='stretch', hide_index=True, height=min(420, 86 + 38 * len(preview_rows)))
2375
2375
  st.caption("Blueprints e vozes são resolvidos pelos catálogos atuais. Por exemplo, `finanças`, `blueprint_finanças` e `Blueprint Canal Finanças` apontam para o mesmo Blueprint quando ele existe.")
2376
- if st.button("Cadastrar canais da planilha", type="primary", use_container_width=True, key="import_channel_spreadsheet"):
2376
+ if st.button("Cadastrar canais da planilha", type="primary", width='stretch', key="import_channel_spreadsheet"):
2377
2377
  created_names: list[str] = []
2378
2378
  skipped_names: list[str] = []
2379
2379
  spreadsheet_errors: list[str] = []
@@ -2471,7 +2471,7 @@ def render_channels():
2471
2471
  ]
2472
2472
  st.dataframe(
2473
2473
  registered_rows,
2474
- use_container_width=True,
2474
+ width='stretch',
2475
2475
  hide_index=True,
2476
2476
  height=420,
2477
2477
  column_config={
@@ -2512,7 +2512,7 @@ def render_channels():
2512
2512
  (st.success if account_status.ok else st.warning)(account_status.message)
2513
2513
  auth_cols = st.columns([1, 2])
2514
2514
  with auth_cols[0]:
2515
- if st.button("Autorizar conta Google", type="secondary", use_container_width=True, key="batch_authorize_account"):
2515
+ if st.button("Autorizar conta Google", type="secondary", width='stretch', key="batch_authorize_account"):
2516
2516
  result = authorize_youtube_batch_account(selected_account, STORAGE)
2517
2517
  (st.success if result.ok else st.error)(result.message)
2518
2518
  if result.ok:
@@ -2520,7 +2520,7 @@ def render_channels():
2520
2520
  with auth_cols[1]:
2521
2521
  st.caption("A autorização é feita no browser do sistema e fica guardada separadamente para esta conta.")
2522
2522
  batch_key = f"youtube_batch_channels_{selected_account_id}"
2523
- if st.button("Listar canais desta conta", type="primary", use_container_width=True, key="batch_list_channels"):
2523
+ if st.button("Listar canais desta conta", type="primary", width='stretch', key="batch_list_channels"):
2524
2524
  result = list_youtube_batch_channels(selected_account, STORAGE)
2525
2525
  st.session_state[batch_key] = result.data.get("channels", []) if result.ok else []
2526
2526
  st.session_state[f"{batch_key}_message"] = result.message
@@ -2550,7 +2550,7 @@ def render_channels():
2550
2550
  batch_language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index("pt"), format_func=language_label, key="batch_channel_language")
2551
2551
  with defaults_cols[3]:
2552
2552
  batch_style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], key="batch_channel_style")
2553
- import_selected = st.form_submit_button("Cadastrar canais seleccionados", type="primary", use_container_width=True)
2553
+ import_selected = st.form_submit_button("Cadastrar canais seleccionados", type="primary", width='stretch')
2554
2554
  if import_selected:
2555
2555
  created_names = []
2556
2556
  skipped_names = []
@@ -2671,12 +2671,12 @@ def render_channels():
2671
2671
  st.rerun()
2672
2672
  action_cols = st.columns(2)
2673
2673
  with action_cols[0]:
2674
- if st.button("Editar", key=f"edit_channel_button_{channel_id}", use_container_width=True):
2674
+ if st.button("Editar", key=f"edit_channel_button_{channel_id}", width='stretch'):
2675
2675
  st.session_state[edit_key] = True
2676
2676
  st.rerun()
2677
2677
  with action_cols[1]:
2678
2678
  delete_key = f"delete_pending_{channel_id}"
2679
- if st.button("Apagar", key=f"delete_{channel_id}", use_container_width=True):
2679
+ if st.button("Apagar", key=f"delete_{channel_id}", width='stretch'):
2680
2680
  st.session_state[delete_key] = True
2681
2681
  st.rerun()
2682
2682
  if st.session_state.get(delete_key):
@@ -2704,17 +2704,17 @@ def render_channels():
2704
2704
  block_cols = st.columns(4, gap="small")
2705
2705
  with block_cols[0]:
2706
2706
  st.markdown(f"**Blueprint Padrão**\n\n{summary['name']}")
2707
- if st.button("Editar Blueprint", key=f"edit_prompts_{channel_id}", use_container_width=True):
2707
+ if st.button("Editar Blueprint", key=f"edit_prompts_{channel_id}", width='stretch'):
2708
2708
  st.session_state[edit_key] = True
2709
2709
  st.rerun()
2710
2710
  with block_cols[1]:
2711
2711
  st.markdown(f"**Nicho**\n\n{channel_niche_label(channel)}")
2712
- if st.button("Editar Nicho", key=f"edit_niche_{channel_id}", use_container_width=True):
2712
+ if st.button("Editar Nicho", key=f"edit_niche_{channel_id}", width='stretch'):
2713
2713
  st.session_state[edit_key] = True
2714
2714
  st.rerun()
2715
2715
  with block_cols[2]:
2716
2716
  st.markdown(f"**Narrador/Voz Padrão**\n\n{summary['voice'] or 'Sem voz padrão'}")
2717
- if st.button("Configurar Narrador/Voz", key=f"edit_voice_{channel_id}", use_container_width=True):
2717
+ if st.button("Configurar Narrador/Voz", key=f"edit_voice_{channel_id}", width='stretch'):
2718
2718
  st.session_state[edit_key] = True
2719
2719
  st.rerun()
2720
2720
  with block_cols[3]:
@@ -2736,7 +2736,7 @@ def render_channels():
2736
2736
  key=f"channel_delegated_session_id_{channel_id}",
2737
2737
  help="Identificador individual usado pelo Upload directo deste canal. Não é partilhado com outros canais nem mostrado nos diagnósticos.",
2738
2738
  )
2739
- save_channel_direct_credentials = st.form_submit_button("Guardar conta Google e DELEGATED_SESSION_ID", type="primary", use_container_width=True)
2739
+ save_channel_direct_credentials = st.form_submit_button("Guardar conta Google e DELEGATED_SESSION_ID", type="primary", width='stretch')
2740
2740
  selected_channel_account = youtube_accounts_by_id.get(channel_account_id)
2741
2741
  if selected_channel_account:
2742
2742
  selected_account_status = document_status(STORAGE, selected_channel_account, channel, settings, channels)
@@ -2940,7 +2940,7 @@ def render_video_from_draft() -> None:
2940
2940
  st.caption(f"Faltam: {', '.join(still_missing)}")
2941
2941
  elif not missing_sections:
2942
2942
  st.caption("Este roteiro será usado directamente, sem regenerar o conteúdo editorial guardado.")
2943
- if st.button(action_label, type="primary", use_container_width=True, key="new_video_resume_submit", icon=":material/movie:"):
2943
+ if st.button(action_label, type="primary", width='stretch', key="new_video_resume_submit", icon=":material/movie:"):
2944
2944
  if still_missing:
2945
2945
  st.error(f"Complete as configurações seleccionadas: {', '.join(still_missing)}.")
2946
2946
  elif not selected_channel.get("id"):
@@ -2995,7 +2995,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
2995
2995
  key=f"{prefix}_general_context",
2996
2996
  placeholder="Opcional: campanha, época, evento ou restrição editorial comum. O tema final será individual por canal.",
2997
2997
  )
2998
- if st.button("Gerar tópicos individuais para todos os canais", key=f"{prefix}_generate_general_topics", use_container_width=True):
2998
+ if st.button("Gerar tópicos individuais para todos os canais", key=f"{prefix}_generate_general_topics", width='stretch'):
2999
2999
  settings = read_json("settings.json", {})
3000
3000
  generated_topics: dict[str, dict[str, Any]] = {}
3001
3001
  errors: list[str] = []
@@ -3096,7 +3096,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3096
3096
  st.warning("Ainda não existem músicas em storage/music. Escolha Carregar ficheiro ou Criar via Suno API.")
3097
3097
  elif music_source == "Carregar ficheiro":
3098
3098
  uploaded_music = st.file_uploader("Carregar música", type=["mp3", "wav", "m4a", "aac", "flac", "ogg"], key=f"{prefix}_music_upload")
3099
- if uploaded_music and st.button("Guardar música local", key=f"{prefix}_music_store", use_container_width=True):
3099
+ if uploaded_music and st.button("Guardar música local", key=f"{prefix}_music_store", width='stretch'):
3100
3100
  try:
3101
3101
  stored_music = store_music_file(uploaded_music.name, uploaded_music.getvalue())
3102
3102
  st.session_state[f"{prefix}_music_path"] = str(stored_music)
@@ -3107,7 +3107,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3107
3107
  else:
3108
3108
  suno_prompt = st.text_area("Prompt musical Suno", placeholder="Instrumental cinematográfico, calmo, sem voz...", key=f"{prefix}_suno_prompt")
3109
3109
  suno_title = st.text_input("Título da música", value=st.session_state.get(f"{prefix}_topic") or "Thunderbolt music", key=f"{prefix}_suno_title")
3110
- if st.button("Solicitar música no Suno", key=f"{prefix}_suno_request", use_container_width=True):
3110
+ if st.button("Solicitar música no Suno", key=f"{prefix}_suno_request", width='stretch'):
3111
3111
  suno_result = request_suno_generation(read_json("settings.json", {}), suno_prompt, suno_title)
3112
3112
  (st.success if suno_result["ok"] else st.error)(suno_result["message"])
3113
3113
  if suno_result["ok"]:
@@ -3128,7 +3128,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3128
3128
  existing_topics = st.session_state.get(f"{prefix}_general_topics", {})
3129
3129
  payloads = dict(st.session_state.get(f"{prefix}_general_payloads", {}))
3130
3130
  with st.expander("Gerar Thumbnail com IA", expanded=False):
3131
- if st.button("Gerar Thumbnail com IA para todos os vídeos", key=f"{prefix}_generate_general_creative", use_container_width=True):
3131
+ if st.button("Gerar Thumbnail com IA para todos os vídeos", key=f"{prefix}_generate_general_creative", width='stretch'):
3132
3132
  settings = read_json("settings.json", {})
3133
3133
  new_payloads: dict[str, dict[str, Any]] = {}
3134
3134
  errors: list[str] = []
@@ -3169,7 +3169,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3169
3169
  payload["thumbnail_text"] = variant.get("overlay_text", "")
3170
3170
  st.caption(f"{variant.get('composition', '')} · {variant.get('color_palette', '')}")
3171
3171
  thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
3172
- if st.button("Gerar imagem com Nano Banana", key=f"{prefix}_general_generate_thumbnail_{channel['id']}", use_container_width=True):
3172
+ if st.button("Gerar imagem com Nano Banana", key=f"{prefix}_general_generate_thumbnail_{channel['id']}", width='stretch'):
3173
3173
  try:
3174
3174
  thumbnail_path = str(
3175
3175
  generate_thumbnail_image(
@@ -3191,7 +3191,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3191
3191
  except ThumbnailGenerationError as exc:
3192
3192
  st.error(str(exc))
3193
3193
  if thumbnail_path and Path(thumbnail_path).is_file():
3194
- st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", use_container_width=True)
3194
+ st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", width='stretch')
3195
3195
  payload["thumbnail_path"] = thumbnail_path
3196
3196
  payload["thumbnail_status"] = "generated"
3197
3197
  else:
@@ -3200,7 +3200,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3200
3200
  else:
3201
3201
  with st.expander("Gerar Thumbnail com IA", expanded=False):
3202
3202
  topic_for_thumbnail = str(generation_settings.get("video_subject") or "").strip()
3203
- if st.button("Gerar Thumbnail com IA", key=f"{prefix}_generate_creative", use_container_width=True):
3203
+ if st.button("Gerar Thumbnail com IA", key=f"{prefix}_generate_creative", width='stretch'):
3204
3204
  if selected_one is None:
3205
3205
  st.error("Seleccione primeiro um canal.")
3206
3206
  elif not topic_for_thumbnail:
@@ -3235,7 +3235,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3235
3235
  selected_title = st.selectbox("Título escolhido", title_options, index=max(0, title_options.index(payload.get("title")) if payload.get("title") in title_options else 0), key=f"{prefix}_title_choice")
3236
3236
  payload["title"] = selected_title
3237
3237
  with st.expander(f"Ver {len(title_options)} candidatos de título"):
3238
- st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
3238
+ st.dataframe(payload.get("title_candidates", []), width='stretch', hide_index=True)
3239
3239
  variants = payload.get("thumbnail_variants", [])
3240
3240
  if variants:
3241
3241
  labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
@@ -3248,7 +3248,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3248
3248
  st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
3249
3249
  st.code(variant.get("image_prompt", ""), language="text")
3250
3250
  thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
3251
- if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", use_container_width=True):
3251
+ if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", width='stretch'):
3252
3252
  try:
3253
3253
  thumbnail_path = str(
3254
3254
  generate_thumbnail_image(
@@ -3270,7 +3270,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3270
3270
  except ThumbnailGenerationError as exc:
3271
3271
  st.error(str(exc))
3272
3272
  if thumbnail_path and Path(thumbnail_path).is_file():
3273
- st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", use_container_width=True)
3273
+ st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", width='stretch')
3274
3274
  payload["thumbnail_path"] = thumbnail_path
3275
3275
  payload["thumbnail_status"] = "generated"
3276
3276
  else:
@@ -3427,7 +3427,7 @@ def render_music_creation():
3427
3427
  key="music_task_prompt",
3428
3428
  height=300,
3429
3429
  )
3430
- if st.button("Gerar campos musicais com IA", key="music_task_generate_fields", use_container_width=True, icon=":material/auto_awesome:"):
3430
+ if st.button("Gerar campos musicais com IA", key="music_task_generate_fields", width='stretch', icon=":material/auto_awesome:"):
3431
3431
  try:
3432
3432
  with st.spinner("A criar título, letra e prompt musical originais…"):
3433
3433
  generated = generate_music_fields(
@@ -3447,7 +3447,7 @@ def render_music_creation():
3447
3447
  models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
3448
3448
  configured = str(settings.get("lyria_model") or models[0])
3449
3449
  lyria_model = st.selectbox("Modelo Lyria", models, index=models.index(configured) if configured in models else 0, key="music_task_lyria_model")
3450
- if st.button("Gerar Música", key="music_task_submit", type="primary", use_container_width=True, icon=":material/music_note:"):
3450
+ if st.button("Gerar Música", key="music_task_submit", type="primary", width='stretch', icon=":material/music_note:"):
3451
3451
  try:
3452
3452
  task = create_music_task(
3453
3453
  provider_label,
@@ -3475,7 +3475,7 @@ def render_custom_music_voices() -> None:
3475
3475
  settings = read_json("settings.json", {})
3476
3476
  refresh_col, cache_col = st.columns([1, 2])
3477
3477
  with refresh_col:
3478
- refresh = st.button("Actualizar vozes", type="primary", use_container_width=True, key="elevenlabs_refresh_voices")
3478
+ refresh = st.button("Actualizar vozes", type="primary", width='stretch', key="elevenlabs_refresh_voices")
3479
3479
  try:
3480
3480
  voices, metadata = fetch_personal_voices(settings, force=refresh)
3481
3481
  if metadata.get("source") == "cache":
@@ -3506,7 +3506,7 @@ def render_custom_music_voices() -> None:
3506
3506
  st.caption("Categoria: personal")
3507
3507
  st.caption("Labels: " + (", ".join(f"{key}: {value}" for key, value in labels.items()) if labels else "não disponíveis"))
3508
3508
  with voice_cols[2]:
3509
- if st.button("Testar voz", key=f"elevenlabs_test_voice_{voice['voice_id']}", use_container_width=True):
3509
+ if st.button("Testar voz", key=f"elevenlabs_test_voice_{voice['voice_id']}", width='stretch'):
3510
3510
  try:
3511
3511
  preview = synthesize_preview(test_text, "elevenlabs", voice["voice_id"], settings)
3512
3512
  st.audio(str(preview), format="audio/mpeg")
@@ -3587,9 +3587,9 @@ def render_scripts():
3587
3587
  brief = str(script_settings.get("video_script") or "").strip() or title
3588
3588
  generate_col, clear_col = st.columns([1.4, 1])
3589
3589
  with generate_col:
3590
- generate_clicked = st.button("Gerar com IA a partir do Blueprint", type="primary", use_container_width=True, key="generate_script_document")
3590
+ generate_clicked = st.button("Gerar com IA a partir do Blueprint", type="primary", width='stretch', key="generate_script_document")
3591
3591
  with clear_col:
3592
- clear_clicked = st.button("Limpar rascunho", use_container_width=True, key="clear_script_document")
3592
+ clear_clicked = st.button("Limpar rascunho", width='stretch', key="clear_script_document")
3593
3593
  if clear_clicked:
3594
3594
  for key in ("script_draft", "script_draft_title", "script_draft_content", "script_draft_summary", "script_draft_keywords"):
3595
3595
  st.session_state.pop(key, None)
@@ -3642,7 +3642,7 @@ def render_scripts():
3642
3642
  draft_summary = st.text_input("Resumo", key="script_draft_summary")
3643
3643
  draft_keywords = st.text_area("Palavras-chave", height=90, key="script_draft_keywords")
3644
3644
  draft_content = st.text_area("Conteúdo guardado", height=460, key="script_draft_content")
3645
- if st.button("Guardar documento no storage", type="primary", use_container_width=True, key="save_script_document"):
3645
+ if st.button("Guardar documento no storage", type="primary", width='stretch', key="save_script_document"):
3646
3646
  try:
3647
3647
  record = save_script_document(
3648
3648
  {
@@ -3716,7 +3716,7 @@ def render_niche_finder():
3716
3716
  start_date = st.date_input("Data inicial", value=default_start, min_value=date(2020, 1, 1), max_value=default_end, key="niche_start_date")
3717
3717
  end_date = st.date_input("Data final", value=default_end, min_value=date(2020, 1, 1), max_value=default_end, key="niche_end_date")
3718
3718
  tags_text = st.text_input("Tags opcionais", key="niche_tags_text", placeholder="Ex.: history, facts, documentary")
3719
- analyse = st.form_submit_button("Analisar Nichos", type="primary", use_container_width=True)
3719
+ analyse = st.form_submit_button("Analisar Nichos", type="primary", width='stretch')
3720
3720
 
3721
3721
  current_parameters = {
3722
3722
  "n_clusters": n_clusters,
@@ -3778,7 +3778,7 @@ def render_niche_finder():
3778
3778
  cluster_table = cluster_table[cluster_table["palavras"].str.contains(keyword.strip(), case=False, na=False)]
3779
3779
  tab_clusters, tab_rules, tab_data = render_localized_tabs(["Clusters encontrados", "Regras de associação", "Dados analisados"])
3780
3780
  with tab_clusters:
3781
- st.dataframe(cluster_table, use_container_width=True, hide_index=True)
3781
+ st.dataframe(cluster_table, width='stretch', hide_index=True)
3782
3782
  if not points.empty:
3783
3783
  try:
3784
3784
  import plotly.express as px
@@ -3786,19 +3786,19 @@ def render_niche_finder():
3786
3786
  hover = [column for column in ["title", "channel_name", "country", "view_count", "engagement_rate"] if column in points.columns]
3787
3787
  figure = px.scatter(points, x="x", y="y", color="cluster", hover_data=hover, title="Distribuição dos clusters")
3788
3788
  figure.update_layout(legend_title_text="Cluster")
3789
- st.plotly_chart(figure, use_container_width=True)
3789
+ st.plotly_chart(figure, width='stretch')
3790
3790
  except ImportError:
3791
3791
  st.error("A visualização da análise não está disponível nesta instalação.")
3792
3792
  with tab_rules:
3793
3793
  if rules_table.empty:
3794
3794
  st.info("Não foram encontradas regras com os filtros actuais. Reduza o suporte mínimo ou escolha outro filtro.")
3795
3795
  else:
3796
- st.dataframe(rules_table, use_container_width=True, hide_index=True)
3796
+ st.dataframe(rules_table, width='stretch', hide_index=True)
3797
3797
  if not items_table.empty:
3798
3798
  st.subheader("Itemsets frequentes")
3799
- st.dataframe(items_table, use_container_width=True, hide_index=True)
3799
+ st.dataframe(items_table, width='stretch', hide_index=True)
3800
3800
  with tab_data:
3801
- st.dataframe(results["raw_data"], use_container_width=True, hide_index=True)
3801
+ st.dataframe(results["raw_data"], width='stretch', hide_index=True)
3802
3802
 
3803
3803
 
3804
3804
  def render_niche_finder_apify():
@@ -3847,7 +3847,7 @@ def render_niche_finder_apify():
3847
3847
  download_subtitles = st.checkbox("Descarregar legendas", value=True, key="niche_apify_download_subtitles")
3848
3848
  with filter_cols[3]:
3849
3849
  has_cc = st.checkbox("Apenas vídeos com CC", value=False, key="niche_apify_has_cc")
3850
- analyse_apify = st.form_submit_button("Pesquisar no Apify", type="primary", use_container_width=True)
3850
+ analyse_apify = st.form_submit_button("Pesquisar no Apify", type="primary", width='stretch')
3851
3851
 
3852
3852
  if analyse_apify:
3853
3853
  try:
@@ -3921,17 +3921,17 @@ def render_niche_finder_apify():
3921
3921
  result_frame = pd.DataFrame(visible_results)
3922
3922
  display_columns = ["title", "channel_name", "duration", "view_count", "subscriber_count", "comments_count", "vsc_ratio", "url", "transcript_status", "summary_status", "summary"]
3923
3923
  display_columns = [column for column in display_columns if column in result_frame.columns]
3924
- st.dataframe(result_frame[display_columns], use_container_width=True, hide_index=True)
3924
+ st.dataframe(result_frame[display_columns], width='stretch', hide_index=True)
3925
3925
  export_frame = result_frame.drop(columns=["transcript"], errors="ignore")
3926
3926
  export_json = export_frame.to_json(orient="records", force_ascii=False, indent=2)
3927
3927
  export_csv = export_frame.to_csv(index=False).encode("utf-8")
3928
3928
  export_cols = st.columns(2)
3929
3929
  with export_cols[0]:
3930
- st.download_button("Exportar JSON", data=export_json, file_name="niche-finder-apify-results.json", mime="application/json", use_container_width=True, key="niche_apify_export_json")
3930
+ st.download_button("Exportar JSON", data=export_json, file_name="niche-finder-apify-results.json", mime="application/json", width='stretch', key="niche_apify_export_json")
3931
3931
  with export_cols[1]:
3932
- st.download_button("Exportar CSV", data=export_csv, file_name="niche-finder-apify-results.csv", mime="text/csv", use_container_width=True, key="niche_apify_export_csv")
3932
+ st.download_button("Exportar CSV", data=export_csv, file_name="niche-finder-apify-results.csv", mime="text/csv", width='stretch', key="niche_apify_export_csv")
3933
3933
  except ImportError:
3934
- st.dataframe(visible_results, use_container_width=True, hide_index=True)
3934
+ st.dataframe(visible_results, width='stretch', hide_index=True)
3935
3935
 
3936
3936
  last_run = st.session_state.get("niche_apify_last_run", {})
3937
3937
  if last_run:
@@ -3973,7 +3973,7 @@ def render_media_download():
3973
3973
  with option_cols[2]:
3974
3974
  embed_metadata = st.checkbox("Incorporar metadados", value=False, key="media_download_embed_metadata")
3975
3975
  st.caption("Playlist desactivada por padrão para evitar downloads acidentais em massa.")
3976
- start_download = st.form_submit_button("Iniciar download", type="primary", use_container_width=True)
3976
+ start_download = st.form_submit_button("Iniciar download", type="primary", width='stretch')
3977
3977
 
3978
3978
  if start_download:
3979
3979
  progress = st.progress(0, text="A preparar o download…")
@@ -4087,7 +4087,7 @@ def render_cuts():
4087
4087
  with url_tab:
4088
4088
  url_value = st.text_input("URL directa do vídeo", placeholder="https://exemplo.com/video.mp4", key="cuts_video_url")
4089
4089
  st.caption("A URL deve apontar directamente para um ficheiro de vídeo HTTP/HTTPS. O download só ocorre depois de clicar no botão.")
4090
- if st.button("Descarregar vídeo", key="cuts_download_url", use_container_width=True):
4090
+ if st.button("Descarregar vídeo", key="cuts_download_url", width='stretch'):
4091
4091
  try:
4092
4092
  source_path = download_direct_video_url(url_value)
4093
4093
  st.session_state["cuts_source_path"] = str(source_path)
@@ -4102,7 +4102,7 @@ def render_cuts():
4102
4102
  else:
4103
4103
  generated_labels = [f"{path.name} — {path}" for path in generated_paths]
4104
4104
  selected_generated = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: generated_labels[index], key="cuts_generated_index")
4105
- if st.button("Usar vídeo seleccionado", key="cuts_use_generated", use_container_width=True):
4105
+ if st.button("Usar vídeo seleccionado", key="cuts_use_generated", width='stretch'):
4106
4106
  source_path = generated_paths[selected_generated]
4107
4107
  st.session_state["cuts_source_path"] = str(source_path)
4108
4108
  st.session_state["cuts_source_label"] = f"Pipeline · {source_path.name}"
@@ -4114,7 +4114,7 @@ def render_cuts():
4114
4114
  else:
4115
4115
  folder_labels = [f"{path.name} — {path}" for path in folder_paths]
4116
4116
  selected_folder = st.selectbox("Vídeo da pasta", range(len(folder_paths)), format_func=lambda index: folder_labels[index], key="cuts_folder_index")
4117
- if st.button("Usar vídeo da pasta", key="cuts_use_folder", use_container_width=True):
4117
+ if st.button("Usar vídeo da pasta", key="cuts_use_folder", width='stretch'):
4118
4118
  source_path = folder_paths[selected_folder]
4119
4119
  st.session_state["cuts_source_path"] = str(source_path)
4120
4120
  st.session_state["cuts_source_label"] = f"Pasta local · {source_path.name}"
@@ -4173,7 +4173,7 @@ def render_cuts():
4173
4173
  generate_button = st.button(
4174
4174
  "Gerar Clips",
4175
4175
  type="primary",
4176
- use_container_width=True,
4176
+ width='stretch',
4177
4177
  disabled=not (source_path and source_path.is_file() and rights_confirmed),
4178
4178
  key="cuts_generate_button",
4179
4179
  )
@@ -4222,13 +4222,13 @@ def render_cuts():
4222
4222
  with st.container(border=True):
4223
4223
  st.caption(f"Clip {clip.get('index', '—')} · {float(clip.get('duration', 0)):.1f}s")
4224
4224
  st.video(str(clip["path"]))
4225
- st.download_button("Descarregar clip", data=Path(clip["path"]).read_bytes(), file_name=clip["name"], mime="video/mp4", key=f"cuts_download_{last_run['id']}_{clip['index']}", use_container_width=True)
4225
+ st.download_button("Descarregar clip", data=Path(clip["path"]).read_bytes(), file_name=clip["name"], mime="video/mp4", key=f"cuts_download_{last_run['id']}_{clip['index']}", width='stretch')
4226
4226
  try:
4227
4227
  _, archive_bytes = zip_cut_run(last_run)
4228
- st.download_button("Descarregar todos os clips (ZIP)", data=archive_bytes, file_name=f"{last_run['id']}.zip", mime="application/zip", key=f"cuts_download_zip_{last_run['id']}", use_container_width=True)
4228
+ st.download_button("Descarregar todos os clips (ZIP)", data=archive_bytes, file_name=f"{last_run['id']}.zip", mime="application/zip", key=f"cuts_download_zip_{last_run['id']}", width='stretch')
4229
4229
  except CutsError as exc:
4230
4230
  st.warning(str(exc))
4231
- st.download_button("Descarregar manifesto JSON", data=cut_manifest_bytes(last_run), file_name=f"{last_run['id']}.json", mime="application/json", key=f"cuts_download_manifest_{last_run['id']}", use_container_width=True)
4231
+ st.download_button("Descarregar manifesto JSON", data=cut_manifest_bytes(last_run), file_name=f"{last_run['id']}.json", mime="application/json", key=f"cuts_download_manifest_{last_run['id']}", width='stretch')
4232
4232
  elif last_run.get("error"):
4233
4233
  st.error(last_run["error"])
4234
4234
 
@@ -4237,7 +4237,7 @@ def render_cuts():
4237
4237
  with st.expander("Histórico do Clip Generator"):
4238
4238
  st.dataframe(
4239
4239
  [{"Data": run.get("created_at", "—"), "Fonte": run.get("source_name", "—"), "Formato": run.get("output_format", "—"), "Clips": len(run.get("clips", [])), "Estado": run.get("status", "—")} for run in runs[:20]],
4240
- use_container_width=True,
4240
+ width='stretch',
4241
4241
  hide_index=True,
4242
4242
  )
4243
4243
 
@@ -4308,7 +4308,7 @@ def render_python_editor():
4308
4308
  width = st.number_input("Largura", min_value=16, max_value=7680, value=1280, step=2, key="python_editor_width")
4309
4309
  with resize_cols[1]:
4310
4310
  height = st.number_input("Altura", min_value=16, max_value=7680, value=720, step=2, key="python_editor_height")
4311
- apply_operation = st.form_submit_button(f"Aplicar: {operation}", type="primary", use_container_width=True)
4311
+ apply_operation = st.form_submit_button(f"Aplicar: {operation}", type="primary", width='stretch')
4312
4312
  if apply_operation:
4313
4313
  try:
4314
4314
  ffmpeg_path = read_json("settings.json", {}).get("ffmpeg_path", "")
@@ -4344,7 +4344,7 @@ def render_python_editor():
4344
4344
  records = list_python_editor_records()
4345
4345
  if records:
4346
4346
  with st.expander("Histórico do Editor Python"):
4347
- st.dataframe([{key: record.get(key, "") for key in ("created_at", "operation", "source_name", "output_name")} for record in records[:20]], use_container_width=True, hide_index=True)
4347
+ st.dataframe([{key: record.get(key, "") for key in ("created_at", "operation", "source_name", "output_name")} for record in records[:20]], width='stretch', hide_index=True)
4348
4348
 
4349
4349
  with code_tab:
4350
4350
  st.subheader("Scripts Python locais")
@@ -4642,7 +4642,7 @@ def render_videos():
4642
4642
  file_name=video_file.name,
4643
4643
  mime='video/mp4',
4644
4644
  key=f"pipeline_video_download_{task['id']}",
4645
- use_container_width=True,
4645
+ width='stretch',
4646
4646
  )
4647
4647
  elif video_path:
4648
4648
  st.caption(f'Vídeo registado: {video_path}')
@@ -4658,11 +4658,11 @@ def render_videos():
4658
4658
  state = str(task.get("state") or "")
4659
4659
  start_col, stop_col = st.columns(2)
4660
4660
  with start_col:
4661
- if st.button("Start", key=f"automation_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
4661
+ if st.button("Start", key=f"automation_start_{task['id']}", width='stretch', disabled=state not in {"to_do", "blocked", "failed"}):
4662
4662
  transition_task(task["id"], "doing")
4663
4663
  st.rerun()
4664
4664
  with stop_col:
4665
- if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
4665
+ if st.button("Stop", key=f"automation_stop_{task['id']}", width='stretch', disabled=state != "doing"):
4666
4666
  transition_task(task["id"], "blocked")
4667
4667
  st.rerun()
4668
4668
 
@@ -4701,7 +4701,7 @@ def render_music_backlog() -> None:
4701
4701
  file_name=music_file.name,
4702
4702
  mime="audio/mpeg",
4703
4703
  key=f"music_backlog_download_{task['id']}",
4704
- use_container_width=True,
4704
+ width='stretch',
4705
4705
  )
4706
4706
  elif music_path:
4707
4707
  st.caption(f"Música registada: {music_path}")
@@ -4725,11 +4725,11 @@ def render_music_backlog() -> None:
4725
4725
  state = str(task.get("state") or "")
4726
4726
  start_col, stop_col = st.columns(2)
4727
4727
  with start_col:
4728
- if st.button("Start", key=f"music_backlog_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
4728
+ if st.button("Start", key=f"music_backlog_start_{task['id']}", width='stretch', disabled=state not in {"to_do", "blocked", "failed"}):
4729
4729
  run_music_task(str(task["id"]), read_json("settings.json", {}))
4730
4730
  st.rerun()
4731
4731
  with stop_col:
4732
- if st.button("Stop", key=f"music_backlog_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
4732
+ if st.button("Stop", key=f"music_backlog_stop_{task['id']}", width='stretch', disabled=state != "doing"):
4733
4733
  transition_music_task(str(task["id"]), "blocked")
4734
4734
  st.rerun()
4735
4735
 
@@ -4775,14 +4775,14 @@ def render_thumbnails():
4775
4775
  with image_col:
4776
4776
  image_path = record.get("image_path")
4777
4777
  if image_path and image_path.is_file():
4778
- st.image(str(image_path), use_container_width=True)
4778
+ st.image(str(image_path), width='stretch')
4779
4779
  st.download_button(
4780
4780
  "Descarregar thumbnail",
4781
4781
  data=image_path.read_bytes(),
4782
4782
  file_name=image_path.name,
4783
4783
  mime="image/jpeg" if image_path.suffix.lower() in {".jpg", ".jpeg"} else "image/png",
4784
4784
  key=f"thumbnail_download_{task_id}_{record['variant_index']}",
4785
- use_container_width=True,
4785
+ width='stretch',
4786
4786
  )
4787
4787
  else:
4788
4788
  st.markdown("### Sem imagem")
@@ -4802,7 +4802,7 @@ def render_thumbnails():
4802
4802
  "Refazer Prompt Thumb",
4803
4803
  key=f"regenerate_thumbnail_{task_id}",
4804
4804
  icon=":material/refresh:",
4805
- use_container_width=True,
4805
+ width='stretch',
4806
4806
  disabled=not bool(record["title"] or record["topic"]),
4807
4807
  ):
4808
4808
  try:
@@ -4837,7 +4837,7 @@ def render_thumbnails():
4837
4837
  "Gerar Imagem",
4838
4838
  key=f"generate_thumbnail_image_{task_id}",
4839
4839
  icon=":material/image:",
4840
- use_container_width=True,
4840
+ width='stretch',
4841
4841
  disabled=not bool(record["prompt"]),
4842
4842
  ):
4843
4843
  try:
@@ -4861,7 +4861,7 @@ def render_thumbnails():
4861
4861
  "Refazer Prompt e Gerar Imagem",
4862
4862
  key=f"regenerate_thumbnail_prompt_{task_id}",
4863
4863
  icon=":material/auto_awesome:",
4864
- use_container_width=True,
4864
+ width='stretch',
4865
4865
  disabled=not bool(record["title"] or record["topic"]),
4866
4866
  ):
4867
4867
  try:
@@ -4892,7 +4892,7 @@ def render_thumbnails():
4892
4892
  "Refazer Lettering",
4893
4893
  key=f"regenerate_thumbnail_lettering_{task_id}",
4894
4894
  icon=":material/title:",
4895
- use_container_width=True,
4895
+ width='stretch',
4896
4896
  disabled=not bool(record.get("image_path") and record["image_path"].is_file()),
4897
4897
  ):
4898
4898
  try:
@@ -5002,7 +5002,7 @@ def render_tiktok_automation():
5002
5002
  with default_cols[4]:
5003
5003
  automation_format = st.selectbox("Formato", CHANNEL_FORMAT_OPTIONS, index=CHANNEL_FORMAT_OPTIONS.index(str(channel.get("format") or "Shorts")) if str(channel.get("format") or "Shorts") in CHANNEL_FORMAT_OPTIONS else 1, key=f"tiktok_automation_format_{channel_id}")
5004
5004
  with default_cols[5]:
5005
- if st.button("Guardar", key=f"tiktok_automation_save_{channel_id}", use_container_width=True, type="primary"):
5005
+ if st.button("Guardar", key=f"tiktok_automation_save_{channel_id}", width='stretch', type="primary"):
5006
5006
  if not valid_hhmm(schedule_time):
5007
5007
  st.error("Use o formato HH:MM, por exemplo 08:30.")
5008
5008
  else:
@@ -5034,10 +5034,10 @@ def render_tiktok_automation():
5034
5034
  st.caption("Portrait 9:16")
5035
5035
  with cols[3]:
5036
5036
  state = str(task.get("state") or "")
5037
- if st.button("Start", key=f"tiktok_automation_start_{task_id}", disabled=state not in {"to_do", "blocked", "failed"}, use_container_width=True):
5037
+ if st.button("Start", key=f"tiktok_automation_start_{task_id}", disabled=state not in {"to_do", "blocked", "failed"}, width='stretch'):
5038
5038
  retry_task_with_current_settings(task_id) if state in {"blocked", "failed"} else transition_task(task_id, "doing")
5039
5039
  st.rerun()
5040
- if st.button("Stop", key=f"tiktok_automation_stop_{task_id}", disabled=state != "doing", use_container_width=True):
5040
+ if st.button("Stop", key=f"tiktok_automation_stop_{task_id}", disabled=state != "doing", width='stretch'):
5041
5041
  stop_task_by_user(task_id)
5042
5042
  st.rerun()
5043
5043
 
@@ -5112,7 +5112,7 @@ def render_automation():
5112
5112
  key=f"automation_voice_{channel_id}",
5113
5113
  )
5114
5114
  with default_cols[5]:
5115
- if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True):
5115
+ if st.button("Guardar", key=f"automation_save_{channel_id}", width='stretch'):
5116
5116
  if not valid_hhmm(schedule_time):
5117
5117
  st.error("Use o formato HH:MM, por exemplo 08:30.")
5118
5118
  else:
@@ -5156,7 +5156,7 @@ def render_automation():
5156
5156
  file_name=thumbnail_path.name if thumbnail_path else "thumbnail.png",
5157
5157
  mime="image/png",
5158
5158
  key=f"automation_download_thumbnail_{task['id']}",
5159
- use_container_width=True,
5159
+ width='stretch',
5160
5160
  disabled=thumbnail_path is None,
5161
5161
  )
5162
5162
  with prompt_download_col:
@@ -5167,7 +5167,7 @@ def render_automation():
5167
5167
  file_name=thumbnail_prompt_path.name if thumbnail_prompt_path else "thumbnail-prompt.txt",
5168
5168
  mime="application/json" if thumbnail_prompt_path else "text/plain",
5169
5169
  key=f"automation_download_thumbnail_prompt_{task['id']}",
5170
- use_container_width=True,
5170
+ width='stretch',
5171
5171
  disabled=thumbnail_prompt_path is None and not thumbnail_prompt,
5172
5172
  )
5173
5173
  with task_cols[1]:
@@ -5179,14 +5179,14 @@ def render_automation():
5179
5179
  state = str(task.get("state") or "")
5180
5180
  start_col, stop_col, delete_col = st.columns(3)
5181
5181
  with start_col:
5182
- if st.button("Start", key=f"automation_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
5182
+ if st.button("Start", key=f"automation_start_{task['id']}", width='stretch', disabled=state not in {"to_do", "blocked", "failed"}):
5183
5183
  if state in {"failed", "blocked"}:
5184
5184
  retry_task_with_current_settings(task["id"])
5185
5185
  else:
5186
5186
  transition_task(task["id"], "doing")
5187
5187
  st.rerun()
5188
5188
  with stop_col:
5189
- if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
5189
+ if st.button("Stop", key=f"automation_stop_{task['id']}", width='stretch', disabled=state != "doing"):
5190
5190
  stop_task_by_user(task["id"])
5191
5191
  st.rerun()
5192
5192
  script_download_col, video_download_col = st.columns(2, gap="small")
@@ -5197,7 +5197,7 @@ def render_automation():
5197
5197
  file_name=script_path.name if script_path else "roteiro.md",
5198
5198
  mime="text/markdown",
5199
5199
  key=f"automation_download_script_{task['id']}",
5200
- use_container_width=True,
5200
+ width='stretch',
5201
5201
  disabled=script_path is None,
5202
5202
  )
5203
5203
  with video_download_col:
@@ -5207,19 +5207,19 @@ def render_automation():
5207
5207
  file_name=video_path.name if video_path else "video.mp4",
5208
5208
  mime="video/mp4",
5209
5209
  key=f"automation_download_video_{task['id']}",
5210
- use_container_width=True,
5210
+ width='stretch',
5211
5211
  disabled=video_path is None,
5212
5212
  )
5213
5213
  with delete_col:
5214
5214
  confirm_delete_key = f"automation_confirm_delete_{task['id']}"
5215
- if st.button("Apagar", key=f"automation_delete_{task['id']}", use_container_width=True, disabled=state == "doing"):
5215
+ if st.button("Apagar", key=f"automation_delete_{task['id']}", width='stretch', disabled=state == "doing"):
5216
5216
  st.session_state[confirm_delete_key] = True
5217
5217
  st.rerun()
5218
5218
  if st.session_state.get(confirm_delete_key):
5219
5219
  st.warning("Remover este vídeo da fila? Os ficheiros de artefactos serão preservados.")
5220
5220
  confirm_col, cancel_col = st.columns(2)
5221
5221
  with confirm_col:
5222
- if st.button("Confirmar", key=f"automation_confirm_delete_button_{task['id']}", use_container_width=True, type="primary"):
5222
+ if st.button("Confirmar", key=f"automation_confirm_delete_button_{task['id']}", width='stretch', type="primary"):
5223
5223
  try:
5224
5224
  delete_task(task["id"])
5225
5225
  st.session_state.pop(confirm_delete_key, None)
@@ -5227,7 +5227,7 @@ def render_automation():
5227
5227
  except ValueError as exc:
5228
5228
  st.error(str(exc))
5229
5229
  with cancel_col:
5230
- if st.button("Cancelar", key=f"automation_cancel_delete_{task['id']}", use_container_width=True):
5230
+ if st.button("Cancelar", key=f"automation_cancel_delete_{task['id']}", width='stretch'):
5231
5231
  st.session_state.pop(confirm_delete_key, None)
5232
5232
  st.rerun()
5233
5233
 
@@ -5326,7 +5326,7 @@ def _render_music_source(prefix: str, extensions: set[str]) -> str:
5326
5326
  type=sorted(extension.lstrip(".") for extension in extensions),
5327
5327
  key=f"{prefix}_music_file_upload",
5328
5328
  )
5329
- if uploaded is not None and st.button("Guardar música no storage local", key=f"{prefix}_music_store", use_container_width=True):
5329
+ if uploaded is not None and st.button("Guardar música no storage local", key=f"{prefix}_music_store", width='stretch'):
5330
5330
  try:
5331
5331
  stored = store_music_file(uploaded.name, uploaded.getvalue())
5332
5332
  selected_path = str(stored)
@@ -5399,7 +5399,7 @@ def _render_jewelmusic_upload_tab() -> None:
5399
5399
  base_url = st.text_input("Base URL", value=str(settings.get("jewelmusic_base_url") or "https://api.jewelmusic.com"))
5400
5400
  proxy_url = st.text_input("Proxy opcional", value=str(settings.get("jewelmusic_proxy_url") or ""), placeholder="http://127.0.0.1:8080")
5401
5401
  timeout_seconds = st.number_input("Timeout (segundos)", min_value=5, max_value=900, value=int(settings.get("jewelmusic_timeout_seconds", 120)), step=5)
5402
- save = st.form_submit_button("Guardar configuração JewelMusic", type="primary", use_container_width=True)
5402
+ save = st.form_submit_button("Guardar configuração JewelMusic", type="primary", width='stretch')
5403
5403
  if save:
5404
5404
  settings = _persist_music_upload_settings({
5405
5405
  "jewelmusic_enabled": bool(enabled),
@@ -5410,7 +5410,7 @@ def _render_jewelmusic_upload_tab() -> None:
5410
5410
  })
5411
5411
  st.success("Configuração JewelMusic guardada no storage local.")
5412
5412
  adapter = JewelMusicAdapter(settings)
5413
- if st.button("Testar conexão JewelMusic", key="jewelmusic_test", use_container_width=True):
5413
+ if st.button("Testar conexão JewelMusic", key="jewelmusic_test", width='stretch'):
5414
5414
  test_result = adapter.test_connection()
5415
5415
  (st.success if test_result.ok else st.error)(test_result.message)
5416
5416
  music_path = _render_music_source("jewelmusic", MUSIC_UPLOAD_EXTENSIONS)
@@ -5424,7 +5424,7 @@ def _render_jewelmusic_upload_tab() -> None:
5424
5424
  with metadata_cols[3]:
5425
5425
  year = st.text_input("Ano", key="jewelmusic_year")
5426
5426
  genre = st.text_input("Género", key="jewelmusic_genre")
5427
- if st.button("Enviar música para JewelMusic", type="primary", key="jewelmusic_upload", use_container_width=True, disabled=not bool(music_path)):
5427
+ if st.button("Enviar música para JewelMusic", type="primary", key="jewelmusic_upload", width='stretch', disabled=not bool(music_path)):
5428
5428
  result = JewelMusicAdapter(read_json("settings.json", {})).upload_track(music_path, title=title, artist=artist, album=album, year=year, genre=genre)
5429
5429
  _record_music_upload("JewelMusic", result, music_path=music_path, target={"artist": artist, "title": title})
5430
5430
  (st.success if result.ok else st.error)(result.message)
@@ -5459,7 +5459,7 @@ def _render_pushtunes_upload_tab() -> None:
5459
5459
  spotify_client_secret = st.text_input("Spotify Client Secret", value=str(settings.get("pushtunes_spotify_client_secret") or ""), type="password")
5460
5460
  spotify_redirect_uri = st.text_input("Spotify Redirect URI", value=str(settings.get("pushtunes_spotify_redirect_uri") or ""))
5461
5461
  timeout_seconds = st.number_input("Timeout Pushtunes (segundos)", min_value=30, max_value=3600, value=int(settings.get("pushtunes_timeout_seconds", 1800)), step=30)
5462
- save = st.form_submit_button("Guardar configuração Pushtunes", type="primary", use_container_width=True)
5462
+ save = st.form_submit_button("Guardar configuração Pushtunes", type="primary", width='stretch')
5463
5463
  if save:
5464
5464
  settings = _persist_music_upload_settings({
5465
5465
  "pushtunes_enabled": bool(enabled),
@@ -5483,17 +5483,17 @@ def _render_pushtunes_upload_tab() -> None:
5483
5483
  if source == "csv":
5484
5484
  st.caption("Pode carregar o CSV nesta página e usar o caminho guardado como origem Pushtunes.")
5485
5485
  csv_upload = st.file_uploader("Carregar CSV de origem Pushtunes", type=["csv"], key="pushtunes_csv_upload")
5486
- if csv_upload is not None and st.button("Guardar CSV Pushtunes no storage", key="pushtunes_csv_store", use_container_width=True):
5486
+ if csv_upload is not None and st.button("Guardar CSV Pushtunes no storage", key="pushtunes_csv_store", width='stretch'):
5487
5487
  stored_csv = _store_pushtunes_csv(csv_upload)
5488
5488
  settings = _persist_music_upload_settings({"pushtunes_csv_file": stored_csv})
5489
5489
  st.success(f"CSV guardado em `{stored_csv}`.")
5490
5490
  adapter = PushtunesAdapter(read_json("settings.json", {}))
5491
5491
  status = adapter.status()
5492
5492
  (st.success if status.ok else st.warning)(status.message)
5493
- if st.button("Validar instalação e configuração Pushtunes", key="pushtunes_test", use_container_width=True):
5493
+ if st.button("Validar instalação e configuração Pushtunes", key="pushtunes_test", width='stretch'):
5494
5494
  check = PushtunesAdapter(read_json("settings.json", {})).status()
5495
5495
  (st.success if check.ok else st.error)(check.message)
5496
- if st.button("Executar sincronização Pushtunes", type="primary", key="pushtunes_sync", use_container_width=True, disabled=not status.ok):
5496
+ if st.button("Executar sincronização Pushtunes", type="primary", key="pushtunes_sync", width='stretch', disabled=not status.ok):
5497
5497
  result = PushtunesAdapter(read_json("settings.json", {})).sync()
5498
5498
  _record_music_upload(f"Pushtunes ({adapter.source} → {adapter.target})", result, target={"source": adapter.source, "target": adapter.target, "operation": adapter.operation})
5499
5499
  (st.success if result.ok else st.error)(result.message)
@@ -5521,7 +5521,7 @@ def _render_ytmusicapi_upload_tab() -> None:
5521
5521
  auth_file = st.text_input("Caminho do browser.json", value=str(settings.get("ytmusicapi_auth_file") or ""))
5522
5522
  proxy_url = st.text_input("Proxy opcional", value=str(settings.get("ytmusicapi_proxy_url") or ""), placeholder="http://127.0.0.1:8080")
5523
5523
  timeout_seconds = st.number_input("Timeout (segundos)", min_value=30, max_value=900, value=int(settings.get("ytmusicapi_timeout_seconds", 240)), step=10)
5524
- save = st.form_submit_button("Guardar configuração ytmusicapi", type="primary", use_container_width=True)
5524
+ save = st.form_submit_button("Guardar configuração ytmusicapi", type="primary", width='stretch')
5525
5525
  if save:
5526
5526
  settings = _persist_music_upload_settings({
5527
5527
  "ytmusicapi_enabled": bool(enabled),
@@ -5531,18 +5531,18 @@ def _render_ytmusicapi_upload_tab() -> None:
5531
5531
  })
5532
5532
  st.success("Configuração ytmusicapi guardada no storage local.")
5533
5533
  auth_upload = st.file_uploader("Carregar browser.json", type=["json"], key="ytmusicapi_auth_upload")
5534
- if auth_upload is not None and st.button("Guardar browser.json no storage local", key="ytmusicapi_auth_store", use_container_width=True):
5534
+ if auth_upload is not None and st.button("Guardar browser.json no storage local", key="ytmusicapi_auth_store", width='stretch'):
5535
5535
  stored_auth = _store_ytmusicapi_auth(auth_upload)
5536
5536
  settings = _persist_music_upload_settings({"ytmusicapi_auth_file": stored_auth, "ytmusicapi_enabled": True})
5537
5537
  st.success(f"Ficheiro de autenticação guardado em `{stored_auth}`.")
5538
5538
  adapter = YTMusicApiAdapter(read_json("settings.json", {}))
5539
5539
  status = adapter.status()
5540
5540
  (st.success if status.ok else st.warning)(status.message)
5541
- if st.button("Testar autenticação ytmusicapi", key="ytmusicapi_test", use_container_width=True):
5541
+ if st.button("Testar autenticação ytmusicapi", key="ytmusicapi_test", width='stretch'):
5542
5542
  result = YTMusicApiAdapter(read_json("settings.json", {})).test_connection()
5543
5543
  (st.success if result.ok else st.error)(result.message)
5544
5544
  music_path = _render_music_source("ytmusicapi", YT_MUSIC_UPLOAD_EXTENSIONS)
5545
- if st.button("Enviar música para YouTube Music", type="primary", key="ytmusicapi_upload", use_container_width=True, disabled=not bool(music_path) or not status.ok):
5545
+ if st.button("Enviar música para YouTube Music", type="primary", key="ytmusicapi_upload", width='stretch', disabled=not bool(music_path) or not status.ok):
5546
5546
  result = YTMusicApiAdapter(read_json("settings.json", {})).upload_song(music_path)
5547
5547
  _record_music_upload("ytmusicapi / YouTube Music", result, music_path=music_path, target={"service": "youtube_music"})
5548
5548
  (st.success if result.ok else st.error)(result.message)
@@ -5574,7 +5574,7 @@ def _render_distrokid_upload_tab() -> None:
5574
5574
  last_name = st.text_input("Apelido", value=str(settings.get("distrokid_last_name") or ""))
5575
5575
  record_label = st.text_input("Record label", value=str(settings.get("distrokid_record_label") or ""))
5576
5576
  genre = st.text_input("Género principal", value=str(settings.get("distrokid_genre") or ""), help="O género deve corresponder a uma opção aceite pelo formulário DistroKid.")
5577
- save = st.form_submit_button("Guardar configuração DistroKid", type="primary", use_container_width=True)
5577
+ save = st.form_submit_button("Guardar configuração DistroKid", type="primary", width='stretch')
5578
5578
  if save:
5579
5579
  _persist_music_upload_settings({
5580
5580
  "distrokid_enabled": bool(enabled), "distrokid_cookie": cookie.strip(), "distrokid_account": account.strip(),
@@ -5586,11 +5586,11 @@ def _render_distrokid_upload_tab() -> None:
5586
5586
  adapter = DistroKidAdapter(settings)
5587
5587
  status = adapter.status()
5588
5588
  (st.success if status.ok else st.warning)(status.message)
5589
- if st.button("Testar sessão DistroKid", key="distrokid_test", use_container_width=True):
5589
+ if st.button("Testar sessão DistroKid", key="distrokid_test", width='stretch'):
5590
5590
  result = DistroKidAdapter(read_json("settings.json", {})).test_connection()
5591
5591
  (st.success if result.ok else st.error)(result.message)
5592
5592
  uploaded_tracks = st.file_uploader("Faixas para o lançamento", type=sorted(extension.lstrip(".") for extension in DISTROKID_AUDIO_EXTENSIONS), accept_multiple_files=True, key="distrokid_track_upload")
5593
- if uploaded_tracks and st.button("Guardar faixas no storage local", key="distrokid_store_tracks", use_container_width=True):
5593
+ if uploaded_tracks and st.button("Guardar faixas no storage local", key="distrokid_store_tracks", width='stretch'):
5594
5594
  stored_paths: list[str] = []
5595
5595
  for uploaded in uploaded_tracks:
5596
5596
  try:
@@ -5609,7 +5609,7 @@ def _render_distrokid_upload_tab() -> None:
5609
5609
  instrumental = st.checkbox("Instrumental", value=False, key=f"distrokid_track_instrumental_{index}")
5610
5610
  track_rows.append({"path": path, "title": track_title.strip() or Path(path).stem, "instrumental": instrumental})
5611
5611
  cover_upload = st.file_uploader("Capa do lançamento (opcional)", type=sorted(extension.lstrip(".") for extension in DISTROKID_COVER_EXTENSIONS), key="distrokid_cover_upload")
5612
- if cover_upload and st.button("Guardar capa no storage local", key="distrokid_store_cover", use_container_width=True):
5612
+ if cover_upload and st.button("Guardar capa no storage local", key="distrokid_store_cover", width='stretch'):
5613
5613
  try:
5614
5614
  st.session_state["distrokid_cover_path"] = _store_distrokid_cover(cover_upload)
5615
5615
  st.success("Capa guardada no storage local.")
@@ -5619,11 +5619,11 @@ def _render_distrokid_upload_tab() -> None:
5619
5619
  if cover_path and Path(cover_path).is_file():
5620
5620
  st.caption(f"Capa seleccionada: `{cover_path}`")
5621
5621
  session_id = str(st.session_state.get("distrokid_session_id") or "")
5622
- if session_id and st.button("Fechar browser DistroKid", key="distrokid_close_browser", use_container_width=True):
5622
+ if session_id and st.button("Fechar browser DistroKid", key="distrokid_close_browser", width='stretch'):
5623
5623
  result = close_distrokid_session(session_id)
5624
5624
  (st.success if result.ok else st.warning)(result.message)
5625
5625
  st.session_state.pop("distrokid_session_id", None)
5626
- if st.button("Abrir formulário e carregar para DistroKid", type="primary", key="distrokid_prepare_upload", use_container_width=True, disabled=not status.ok or not track_rows):
5626
+ if st.button("Abrir formulário e carregar para DistroKid", type="primary", key="distrokid_prepare_upload", width='stretch', disabled=not status.ok or not track_rows):
5627
5627
  current = read_json("settings.json", {})
5628
5628
  result = DistroKidAdapter(current).prepare_upload(
5629
5629
  track_rows, artist=str(current.get("distrokid_artist") or ""), release_title=str(current.get("distrokid_release_title") or ""),
@@ -5675,7 +5675,7 @@ def render_upload_composio():
5675
5675
  return
5676
5676
  query = st.text_input("Pesquisar ferramenta Composio", value="upload a video file", key="composio_upload_query")
5677
5677
  toolkit_filter = st.text_input("Toolkit preferido (opcional)", value=str(settings.get("composio_toolkit") or ""), key="composio_upload_toolkit")
5678
- if st.button("Descobrir ferramentas", key="composio_upload_discover", use_container_width=True):
5678
+ if st.button("Descobrir ferramentas", key="composio_upload_discover", width='stretch'):
5679
5679
  try:
5680
5680
  st.session_state["composio_upload_tools"] = discover_tools(api_key, user_id, query, toolkit_filter)
5681
5681
  if not st.session_state["composio_upload_tools"]:
@@ -5698,7 +5698,7 @@ def render_upload_composio():
5698
5698
  st.json(schema)
5699
5699
  file_field = st.text_input("Campo que recebe o vídeo", value=str(st.session_state.get("composio_upload_file_field") or "file"), key="composio_upload_file_field")
5700
5700
  arguments_json = st.text_area("Argumentos JSON adicionais", value=str(st.session_state.get("composio_upload_arguments") or "{}"), height=150, key="composio_upload_arguments")
5701
- if st.button("Autorizar toolkit no Composio", key="composio_upload_authorize", use_container_width=True):
5701
+ if st.button("Autorizar toolkit no Composio", key="composio_upload_authorize", width='stretch'):
5702
5702
  try:
5703
5703
  auth = authorize_toolkit(api_key, user_id, str(selected_tool.get("toolkit") or ""))
5704
5704
  if auth.get("redirect_url"):
@@ -5718,7 +5718,7 @@ def render_upload_composio():
5718
5718
  video_path = str((task.get("artifacts", {}) or {}).get("video") or "")
5719
5719
  st.caption(video_path or "Sem caminho de vídeo registado")
5720
5720
  st.info(f"Resumo: `{selected_slug}` · toolkit `{selected_tool.get('toolkit') or 'não indicado'}` · campo `{file_field or 'não indicado'}`")
5721
- if st.button("Enviar vídeo via Composio", type="primary", key=f"composio_upload_send_{selected_task_id}", use_container_width=True):
5721
+ if st.button("Enviar vídeo via Composio", type="primary", key=f"composio_upload_send_{selected_task_id}", width='stretch'):
5722
5722
  try:
5723
5723
  parse_arguments(arguments_json)
5724
5724
  result = execute_upload(api_key, user_id, selected_slug, video_path, file_field, arguments_json)
@@ -5991,14 +5991,14 @@ def render_upload_conventional():
5991
5991
  status_cols = st.columns(2)
5992
5992
  with status_cols[0]:
5993
5993
  (st.success if status["agent"].ok else st.warning)(f"Agente: {status['agent'].message}")
5994
- if st.button("Autorizar agente YouTube", key="youtube_authorize_agent", use_container_width=True):
5994
+ if st.button("Autorizar agente YouTube", key="youtube_authorize_agent", width='stretch'):
5995
5995
  result = youtube.authorize_agent()
5996
5996
  (st.success if result.ok else st.error)(result.message)
5997
5997
  if result.ok:
5998
5998
  st.rerun()
5999
5999
  with status_cols[1]:
6000
6000
  (st.info if status["fallback"].ok else st.caption)(f"Fallback OAuth: {status['fallback'].message}")
6001
- if st.button("Autorizar fallback OAuth", key="youtube_authorize_fallback", use_container_width=True):
6001
+ if st.button("Autorizar fallback OAuth", key="youtube_authorize_fallback", width='stretch'):
6002
6002
  result = youtube.authorize_fallback()
6003
6003
  (st.success if result.ok else st.error)(result.message)
6004
6004
  if result.ok:
@@ -6051,7 +6051,7 @@ def render_upload_conventional():
6051
6051
  st.session_state.pop(description_error_key, None)
6052
6052
 
6053
6053
  description = st.text_area("Descrição", value=task.get("description", ""), key=description_state_key, height=100)
6054
- st.button("Gerar descrição com IA", key=f"yt_description_ai_{task['id']}", use_container_width=False, on_click=generate_upload_description_callback)
6054
+ st.button("Gerar descrição com IA", key=f"yt_description_ai_{task['id']}", width='content', on_click=generate_upload_description_callback)
6055
6055
  if description_error := str(st.session_state.get(description_error_key) or "").strip():
6056
6056
  st.error(f"Não foi possível gerar a descrição: {description_error}")
6057
6057
  tags_raw = st.text_input("Tags separadas por vírgula", value=task.get("tags", "") if isinstance(task.get("tags", ""), str) else ", ".join(task.get("tags", [])), key=f"yt_tags_{task['id']}")
@@ -6219,11 +6219,11 @@ def render_tiktok_api_cards(settings: dict[str, Any]) -> None:
6219
6219
  client_secret = st.text_input("TikTok Client Secret", value=card["client_secret"], type="password", key=f"tiktok_api_{card_id}_client_secret")
6220
6220
  action_cols = st.columns(3)
6221
6221
  with action_cols[0]:
6222
- test_clicked = st.form_submit_button("Testar chamada API", use_container_width=True)
6222
+ test_clicked = st.form_submit_button("Testar chamada API", width='stretch')
6223
6223
  with action_cols[1]:
6224
- save_clicked = st.form_submit_button("Guardar card", type="primary", use_container_width=True)
6224
+ save_clicked = st.form_submit_button("Guardar card", type="primary", width='stretch')
6225
6225
  with action_cols[2]:
6226
- delete_clicked = st.form_submit_button("Apagar card", use_container_width=True)
6226
+ delete_clicked = st.form_submit_button("Apagar card", width='stretch')
6227
6227
  edited = {"id": card_id, "client_id": client_id.strip(), "client_secret": client_secret.strip()}
6228
6228
  if test_clicked:
6229
6229
  cards[index] = edited
@@ -6245,7 +6245,7 @@ def render_tiktok_api_cards(settings: dict[str, Any]) -> None:
6245
6245
  _persist_tiktok_api_cards(settings, cards)
6246
6246
  st.success("Card TikTok apagado.")
6247
6247
  st.rerun()
6248
- if st.button("Adicionar nova API", type="primary", use_container_width=True, key="add_tiktok_api_card"):
6248
+ if st.button("Adicionar nova API", type="primary", width='stretch', key="add_tiktok_api_card"):
6249
6249
  cards.append({"id": f"tiktok-api-{uuid.uuid4().hex[:10]}", "client_id": "", "client_secret": ""})
6250
6250
  _persist_tiktok_api_cards(settings, cards)
6251
6251
  st.success("Novo card TikTok criado.")
@@ -6297,11 +6297,11 @@ def render_bilibili_api_cards(settings: dict[str, Any]) -> None:
6297
6297
  proxy = st.text_input("Proxy opcional", value=str(card.get("proxy") or ""), key=f"bilibili_api_{card_id}_proxy", placeholder="http://127.0.0.1:8080")
6298
6298
  action_cols = st.columns(3)
6299
6299
  with action_cols[0]:
6300
- test_clicked = st.form_submit_button("Testar chamada API", use_container_width=True)
6300
+ test_clicked = st.form_submit_button("Testar chamada API", width='stretch')
6301
6301
  with action_cols[1]:
6302
- save_clicked = st.form_submit_button("Guardar card", type="primary", use_container_width=True)
6302
+ save_clicked = st.form_submit_button("Guardar card", type="primary", width='stretch')
6303
6303
  with action_cols[2]:
6304
- delete_clicked = st.form_submit_button("Apagar card", use_container_width=True)
6304
+ delete_clicked = st.form_submit_button("Apagar card", width='stretch')
6305
6305
  edited = {"id": card_id, "label": label.strip() or f"Conta Bilibili {index + 1}", "active": bool(active), "sessdata": sessdata.strip(), "bili_jct": bili_jct.strip(), "buvid3": buvid3.strip(), "buvid4": buvid4.strip(), "dedeuserid": dedeuserid.strip(), "ac_time_value": ac_time_value.strip(), "proxy": proxy.strip()}
6306
6306
  if test_clicked:
6307
6307
  cards[index] = edited
@@ -6318,7 +6318,7 @@ def render_bilibili_api_cards(settings: dict[str, Any]) -> None:
6318
6318
  _persist_bilibili_api_cards(settings, cards)
6319
6319
  st.success("Card Bilibili apagado.")
6320
6320
  st.rerun()
6321
- if st.button("Adicionar nova API", type="primary", use_container_width=True, key="add_bilibili_api_card"):
6321
+ if st.button("Adicionar nova API", type="primary", width='stretch', key="add_bilibili_api_card"):
6322
6322
  cards.append({"id": f"bilibili-api-{uuid.uuid4().hex[:10]}", "label": f"Conta Bilibili {len(cards) + 1}", "active": True, "sessdata": "", "bili_jct": "", "buvid3": "", "buvid4": "", "dedeuserid": "", "ac_time_value": "", "proxy": ""})
6323
6323
  _persist_bilibili_api_cards(settings, cards)
6324
6324
  st.success("Novo card Bilibili criado.")
@@ -6402,7 +6402,7 @@ def render_google_accounts(*, include_innertube: bool = True):
6402
6402
  key=f"batch_session_info_captured_at_{account_id}",
6403
6403
  help="Data em que o sessionInfo token foi capturado. É usada para calcular o alerta de expiração e não revela o token.",
6404
6404
  )
6405
- save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
6405
+ save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", width='stretch')
6406
6406
 
6407
6407
  st.markdown("**Documento de cookies/credenciais desta conta Google**")
6408
6408
  st.caption("O documento padrão é criado automaticamente. Suba um JSON completo ou apenas o documento de cookies; os valores preenchidos são incorporados e mantidos em credentials.json.")
@@ -6417,20 +6417,20 @@ def render_google_accounts(*, include_innertube: bool = True):
6417
6417
  else:
6418
6418
  st.success("Documento completo para a conta Google")
6419
6419
  st.caption(f"Documento guardado em: {direct_status['document_file']}")
6420
- document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
6420
+ document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", width='stretch')
6421
6421
 
6422
6422
  account_status = youtube_batch_account_status(batch_account, STORAGE)
6423
6423
  status_cols = st.columns(3)
6424
6424
  with status_cols[0]:
6425
6425
  (st.success if account_status.ok else st.warning)(account_status.message)
6426
6426
  with status_cols[1]:
6427
- if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
6427
+ if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", width='stretch'):
6428
6428
  result = authorize_youtube_batch_account(batch_account, STORAGE)
6429
6429
  (st.success if result.ok else st.error)(result.message)
6430
6430
  if result.ok:
6431
6431
  st.rerun()
6432
6432
  with status_cols[2]:
6433
- if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
6433
+ if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", width='stretch'):
6434
6434
  delete_youtube_batch_token(batch_account, STORAGE)
6435
6435
  delete_credentials_document(STORAGE, batch_account)
6436
6436
  remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
@@ -6531,7 +6531,7 @@ def render_google_accounts(*, include_innertube: bool = True):
6531
6531
  lambda: test_innertube_api_key(innertube_api_key_value),
6532
6532
  widget_key="api_test_innertube",
6533
6533
  )
6534
- save_innertube_api_key = st.form_submit_button("Guardar INNERTUBE_API_KEY global", type="primary", use_container_width=True)
6534
+ save_innertube_api_key = st.form_submit_button("Guardar INNERTUBE_API_KEY global", type="primary", width='stretch')
6535
6535
  if save_innertube_api_key:
6536
6536
  settings["direct_innertube_api_key"] = innertube_api_key_value.strip()
6537
6537
  settings.pop("INNERTUBE_API_KEY", None)
@@ -6556,7 +6556,7 @@ def render_google_accounts(*, include_innertube: bool = True):
6556
6556
  new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
6557
6557
  new_account_session_info = st.text_input("sessionInfo token desta conta Google", type="password", key="new_batch_account_session_info", help="Token sessionInfo desta conta. Os cookies e delegated_session_ids ficam no documento; a INNERTUBE_API_KEY é configurada no bloco próprio acima.")
6558
6558
  new_account_document = st.file_uploader("Documento de cookies/credenciais opcional", type=["json"], key="new_batch_account_credentials_document", help="Pode subir agora um JSON completo ou apenas o documento de cookies. Se não subir, será criado um credentials.json padrão vazio.")
6559
- add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
6559
+ add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", width='stretch')
6560
6560
  if add_account:
6561
6561
  document_error = ""
6562
6562
  if "@" not in new_account_email.strip():
@@ -6616,7 +6616,7 @@ def render_google_accounts(*, include_innertube: bool = True):
6616
6616
  key="google_page_youtube_api_key",
6617
6617
  help="Credencial Google Cloud separada, usada apenas para métricas oficiais da YouTube Data API.",
6618
6618
  )
6619
- save_google_global = st.form_submit_button("Guardar configuração global do YouTube", type="primary", use_container_width=True)
6619
+ save_google_global = st.form_submit_button("Guardar configuração global do YouTube", type="primary", width='stretch')
6620
6620
  if save_google_global:
6621
6621
  settings.update({
6622
6622
  "youtube_client_id": youtube_client_id.strip(),
@@ -6693,7 +6693,7 @@ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str,
6693
6693
  lambda: test_material_source_credentials(provider, api_key),
6694
6694
  widget_key=f"api_test_material_{card_id}",
6695
6695
  )
6696
- save_card = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"material_card_{card_id}_save")
6696
+ save_card = st.form_submit_button("Salvar", type="primary", width='stretch', key=f"material_card_{card_id}_save")
6697
6697
  if save_card:
6698
6698
  cards[index] = {
6699
6699
  **card,
@@ -6726,7 +6726,7 @@ def render_material_source_api_keys(settings: dict[str, Any], *, embedded: bool
6726
6726
  format_func=lambda value: _material_source_card_definition(value)["label"],
6727
6727
  key="material_new_provider_choice",
6728
6728
  )
6729
- add_source_clicked = st.form_submit_button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card") if embedded else st.button("Configurar Nova Fonte de Materiais", type="primary", use_container_width=True, key="add_material_source_card")
6729
+ add_source_clicked = st.form_submit_button("Configurar Nova Fonte de Materiais", type="primary", width='stretch', key="add_material_source_card") if embedded else st.button("Configurar Nova Fonte de Materiais", type="primary", width='stretch', key="add_material_source_card")
6730
6730
  if add_source_clicked:
6731
6731
  new_card = new_material_card(provider_to_add, card_id=f"material-{provider_to_add}-{uuid.uuid4().hex[:8]}")
6732
6732
  new_card["priority"] = max(
@@ -6817,7 +6817,7 @@ def _render_api_test_control(
6817
6817
  consistent: testing it must not appear successful while its values remain
6818
6818
  only in the browser session.
6819
6819
  """
6820
- if st.form_submit_button("Testar chamada API", use_container_width=True, key=widget_key):
6820
+ if st.form_submit_button("Testar chamada API", width='stretch', key=widget_key):
6821
6821
  with st.spinner(ui_text("A testar chamada API…", current_ui_language())):
6822
6822
  try:
6823
6823
  if persist_callback is not None:
@@ -6917,9 +6917,9 @@ def _render_llm_card(settings: dict[str, Any], cards: list[dict[str, Any]], inde
6917
6917
  with action_col:
6918
6918
  action_buttons = st.columns(2)
6919
6919
  with action_buttons[0]:
6920
- refresh_clicked = st.form_submit_button("Consultar modelos", use_container_width=True, key=f"llm_card_{card_id}_refresh")
6920
+ refresh_clicked = st.form_submit_button("Consultar modelos", width='stretch', key=f"llm_card_{card_id}_refresh")
6921
6921
  with action_buttons[1]:
6922
- test_clicked = st.form_submit_button("Testar chamada API", use_container_width=True, key=f"llm_card_{card_id}_test")
6922
+ test_clicked = st.form_submit_button("Testar chamada API", width='stretch', key=f"llm_card_{card_id}_test")
6923
6923
 
6924
6924
  extra_values: dict[str, str] = {}
6925
6925
  if definition.extra_fields:
@@ -6956,10 +6956,10 @@ def _render_llm_card(settings: dict[str, Any], cards: list[dict[str, Any]], inde
6956
6956
  if telegram_llm:
6957
6957
  st.caption("Exclusivo para Notificações de Telegram — prioridade ignorada no pool LLM.")
6958
6958
 
6959
- save_clicked = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"llm_card_{card_id}_save")
6959
+ save_clicked = st.form_submit_button("Salvar", type="primary", width='stretch', key=f"llm_card_{card_id}_save")
6960
6960
  remove_clicked = False
6961
6961
  if definition.code != "openai":
6962
- remove_clicked = st.form_submit_button("Remover cartão", use_container_width=True, key=f"llm_card_{card_id}_remove")
6962
+ remove_clicked = st.form_submit_button("Remover cartão", width='stretch', key=f"llm_card_{card_id}_remove")
6963
6963
 
6964
6964
  edited = dict(card)
6965
6965
  edited.update({"api_key": str(api_key or "").strip(), "model": str(model or "").strip(), "base_url": str(base_url or "").strip(), "enabled": bool(enabled), "priority": max(1, int(priority)), "telegram_llm": bool(telegram_llm), **extra_values})
@@ -7015,9 +7015,9 @@ def render_llm_provider_cards(settings: dict[str, Any], *, embedded: bool = Fals
7015
7015
  with rpm_cols[2]:
7016
7016
  llm_rpm_window_seconds = st.number_input("Janela (segundos)", min_value=1, max_value=3600, value=int(settings.get("llm_rpm_window_seconds", 60)), step=1, key="settings_llm_rpm_window_seconds")
7017
7017
  save_llm_rpm_clicked = (
7018
- st.form_submit_button("Guardar limite LLM NVIDIA NIM", type="primary", use_container_width=True, key="save_llm_rpm_limit")
7018
+ st.form_submit_button("Guardar limite LLM NVIDIA NIM", type="primary", width='stretch', key="save_llm_rpm_limit")
7019
7019
  if embedded
7020
- else st.button("Guardar limite LLM NVIDIA NIM", type="primary", use_container_width=True, key="save_llm_rpm_limit")
7020
+ else st.button("Guardar limite LLM NVIDIA NIM", type="primary", width='stretch', key="save_llm_rpm_limit")
7021
7021
  )
7022
7022
  if save_llm_rpm_clicked:
7023
7023
  settings.update({
@@ -7039,9 +7039,9 @@ def render_llm_provider_cards(settings: dict[str, Any], *, embedded: bool = Fals
7039
7039
  key="llm_new_provider_choice",
7040
7040
  )
7041
7041
  add_provider_clicked = (
7042
- st.form_submit_button("Configurar Novo Provedor LLM", type="primary", use_container_width=True, key="add_llm_provider_card")
7042
+ st.form_submit_button("Configurar Novo Provedor LLM", type="primary", width='stretch', key="add_llm_provider_card")
7043
7043
  if embedded
7044
- else st.button("Configurar Novo Provedor LLM", type="primary", use_container_width=True, key="add_llm_provider_card")
7044
+ else st.button("Configurar Novo Provedor LLM", type="primary", width='stretch', key="add_llm_provider_card")
7045
7045
  )
7046
7046
  if add_provider_clicked:
7047
7047
  new_card = new_llm_card(provider_to_add, card_id=f"llm-{provider_to_add}-{uuid.uuid4().hex[:8]}")
@@ -7161,7 +7161,7 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
7161
7161
  )
7162
7162
  model = "" if selected_model == "__select_model__" else selected_model
7163
7163
  if definition.code == "canva":
7164
- refresh_clicked = st.form_submit_button("Consultar Modelos", use_container_width=True, key=f"media_card_{card_id}_refresh")
7164
+ refresh_clicked = st.form_submit_button("Consultar Modelos", width='stretch', key=f"media_card_{card_id}_refresh")
7165
7165
  if definition.code == "canva":
7166
7166
  base_url = definition.default_base_url
7167
7167
  st.text_input("Base URL", value=base_url, disabled=True, key=f"media_card_{card_id}_base_url_display")
@@ -7193,7 +7193,7 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
7193
7193
  if not has_credentials:
7194
7194
  st.info("Preencha Client Id, Client Secret e Redirect Uri para iniciar a autorização.")
7195
7195
  else:
7196
- authorize_clicked = st.form_submit_button("Iniciar autorização", use_container_width=True, key=f"media_card_{card_id}_authorize") if embedded else st.button("Iniciar autorização", key=f"media_card_{card_id}_authorize")
7196
+ authorize_clicked = st.form_submit_button("Iniciar autorização", width='stretch', key=f"media_card_{card_id}_authorize") if embedded else st.button("Iniciar autorização", key=f"media_card_{card_id}_authorize")
7197
7197
  if authorize_clicked:
7198
7198
  verifier, challenge = create_pkce_pair()
7199
7199
  state = create_state()
@@ -7221,13 +7221,13 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
7221
7221
  action_cols = st.columns(4)
7222
7222
  with action_cols[0]:
7223
7223
  if definition.code != "canva":
7224
- refresh_clicked = st.form_submit_button("Consultar Modelos", use_container_width=True, key=f"media_card_{card_id}_refresh")
7224
+ refresh_clicked = st.form_submit_button("Consultar Modelos", width='stretch', key=f"media_card_{card_id}_refresh")
7225
7225
  with action_cols[1]:
7226
- test_clicked = st.form_submit_button("Testar Chamada API", use_container_width=True, key=f"media_card_{card_id}_test")
7226
+ test_clicked = st.form_submit_button("Testar Chamada API", width='stretch', key=f"media_card_{card_id}_test")
7227
7227
  with action_cols[2]:
7228
- save_clicked = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"media_card_{card_id}_save")
7228
+ save_clicked = st.form_submit_button("Salvar", type="primary", width='stretch', key=f"media_card_{card_id}_save")
7229
7229
  with action_cols[3]:
7230
- remove_clicked = st.form_submit_button("Remover provider", use_container_width=True, key=f"media_card_{card_id}_remove")
7230
+ remove_clicked = st.form_submit_button("Remover provider", width='stretch', key=f"media_card_{card_id}_remove")
7231
7231
  edited = dict(card)
7232
7232
  edited.update({"api_key": str(api_key or "").strip(), "model": str(model or "").strip(), "base_url": str(base_url or "").strip(), "enabled": bool(enabled), "supports_image": bool(supports_image), "supports_video": bool(supports_video), "priority": int(priority), **extra_values})
7233
7233
  cards[index] = edited
@@ -7279,7 +7279,7 @@ def render_media_provider_cards(settings: dict[str, Any], *, embedded: bool = Fa
7279
7279
  video_active_id = st.selectbox("Provider principal de vídeo", video_options, index=video_options.index(str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or "")) if str(settings.get(MEDIA_VIDEO_ACTIVE_CARD_KEY) or "") in video_options else 0, format_func=lambda value: "Não usar pool externo" if not value else next((media_provider_definition(card.get("provider")).label for card in video_cards if str(card.get("id")) == value), value), key="media_video_active_selector")
7280
7280
  with selector_cols[2]:
7281
7281
  video_pool_enabled = st.checkbox("Usar pool de vídeo externo", value=bool(settings.get("media_video_pool_enabled", False)), key="media_video_pool_enabled_ui")
7282
- if st.button("Salvar selecção dos pools", use_container_width=True, key="save_media_pool_selection") if not embedded else st.form_submit_button("Salvar selecção dos pools", use_container_width=True, key="save_media_pool_selection"):
7282
+ if st.button("Salvar selecção dos pools", width='stretch', key="save_media_pool_selection") if not embedded else st.form_submit_button("Salvar selecção dos pools", width='stretch', key="save_media_pool_selection"):
7283
7283
  settings["media_video_pool_enabled"] = bool(video_pool_enabled)
7284
7284
  _persist_media_cards(settings, cards, image_active_id, video_active_id)
7285
7285
  st.success("Selecção dos pools guardada.")
@@ -7290,7 +7290,7 @@ def render_media_provider_cards(settings: dict[str, Any], *, embedded: bool = Fa
7290
7290
  st.markdown("**Adicionar provider de imagem/vídeo**")
7291
7291
  provider_codes = [item["code"] for item in media_provider_catalog()]
7292
7292
  provider_to_add = st.selectbox("Provider de media", provider_codes, format_func=lambda value: media_provider_definition(value).label, key="media_new_provider_choice")
7293
- add_clicked = st.form_submit_button("Adicionar provider de imagem/vídeo", use_container_width=True, key="add_media_provider_card") if embedded else st.button("Adicionar provider de imagem/vídeo", use_container_width=True, key="add_media_provider_card")
7293
+ add_clicked = st.form_submit_button("Adicionar provider de imagem/vídeo", width='stretch', key="add_media_provider_card") if embedded else st.button("Adicionar provider de imagem/vídeo", width='stretch', key="add_media_provider_card")
7294
7294
  if add_clicked:
7295
7295
  cards.append(new_media_card(provider_to_add, card_id=f"media-{provider_to_add}-{uuid.uuid4().hex[:8]}"))
7296
7296
  _persist_media_cards(settings, cards, image_active_id, video_active_id)
@@ -7328,7 +7328,7 @@ def render_settings():
7328
7328
  for index, (key, label) in enumerate((("json", "JSON/API"), ("log", "Logs"), ("pdf", "PDF"), ("csv", "CSV"), ("diff", "Diffs"), ("code", "Código"))):
7329
7329
  with optimizer_cols[index % 4]:
7330
7330
  optimizer_values[f"token_optimizer_{key}_enabled"] = st.checkbox(label, value=bool(settings.get(f"token_optimizer_{key}_enabled", True)), key=f"token_optimizer_{key}_ui")
7331
- if st.button("Guardar optimizador", key="save_token_optimizer", use_container_width=True):
7331
+ if st.button("Guardar optimizador", key="save_token_optimizer", width='stretch'):
7332
7332
  settings.update({"token_optimizer_enabled": optimizer_enabled, **optimizer_values})
7333
7333
  write_json("settings.json", settings)
7334
7334
  st.success("Configuração do jusTokenMax guardada.")
@@ -7401,7 +7401,7 @@ def render_settings():
7401
7401
  })
7402
7402
  write_json("settings.json", settings)
7403
7403
 
7404
- if st.form_submit_button("Guardar Azure Speech", type="primary", use_container_width=True, key="save_azure_speech"):
7404
+ if st.form_submit_button("Guardar Azure Speech", type="primary", width='stretch', key="save_azure_speech"):
7405
7405
  save_azure_speech()
7406
7406
  st.success("Azure Speech guardado.")
7407
7407
  _render_api_test_control(
@@ -7509,7 +7509,7 @@ def render_settings():
7509
7509
  settings.update({"lyria_api_key": lyria_api_key.strip(), "lyria_model": lyria_model})
7510
7510
  write_json("settings.json", settings)
7511
7511
 
7512
- if st.form_submit_button("Guardar Google Lyria", type="primary", use_container_width=True, key="save_google_lyria"):
7512
+ if st.form_submit_button("Guardar Google Lyria", type="primary", width='stretch', key="save_google_lyria"):
7513
7513
  save_google_lyria()
7514
7514
  st.success("Google Lyria guardado.")
7515
7515
  _render_api_test_control(
@@ -7593,7 +7593,7 @@ def render_settings():
7593
7593
  with st.form("upload_innertube_api_key_form"):
7594
7594
  upload_innertube = st.text_input("INNERTUBE_API_KEY", value=current_upload_innertube, type="password", key="upload_api_innertube_key", help="Chave global usada pelo Upload directo para todas as contas Google/YouTube. Guarde-a na configuração global, separada dos documentos de cookies.")
7595
7595
  _render_api_test_control(settings, "innertube_upload", lambda: test_innertube_api_key(upload_innertube), widget_key="api_test_innertube_upload")
7596
- save_upload_innertube = st.form_submit_button("Guardar INNERTUBE_API_KEY global", type="primary", use_container_width=True)
7596
+ save_upload_innertube = st.form_submit_button("Guardar INNERTUBE_API_KEY global", type="primary", width='stretch')
7597
7597
  if save_upload_innertube:
7598
7598
  settings["direct_innertube_api_key"] = upload_innertube.strip()
7599
7599
  settings.pop("INNERTUBE_API_KEY", None)
@@ -7625,9 +7625,9 @@ def render_settings():
7625
7625
  _render_credential_status(composio_api_key)
7626
7626
  composio_action_cols = st.columns(2)
7627
7627
  with composio_action_cols[0]:
7628
- save_composio = st.button("Guardar Composio", type="primary", use_container_width=True, key="upload_composio_save")
7628
+ save_composio = st.button("Guardar Composio", type="primary", width='stretch', key="upload_composio_save")
7629
7629
  with composio_action_cols[1]:
7630
- test_composio = st.button("Testar configuração", use_container_width=True, key="upload_composio_test")
7630
+ test_composio = st.button("Testar configuração", width='stretch', key="upload_composio_test")
7631
7631
  if save_composio:
7632
7632
  settings.update({"composio_enabled": bool(composio_enabled), "composio_auto_upload": bool(composio_auto_upload), "composio_api_key": composio_api_key.strip(), "composio_user_id": composio_user_id.strip() or "thunderbolt-local", "composio_toolkit": composio_toolkit.strip(), "composio_tool_slug": composio_tool_slug.strip(), "composio_file_field": composio_file_field.strip() or "file", "composio_channel_field": composio_channel_field.strip() or "channel_id", "composio_privacy_field": composio_privacy_field.strip() or "privacy_status", "composio_category_field": composio_category_field.strip() or "category_id", "composio_language_field": composio_language_field.strip() or "language", "composio_arguments_json": composio_arguments_json.strip() or "{}"})
7633
7633
  write_json("settings.json", settings)
@@ -7646,7 +7646,7 @@ def render_settings():
7646
7646
  upload_post_user_upload = st.text_input("Upload-Post username", value=str(settings.get("upload_post_username") or ""), key="upload_tab_upload_post_user")
7647
7647
  upload_post_platforms_upload = st.text_input("Plataformas Upload-Post", value=str(settings.get("upload_post_platforms") or "youtube,tiktok"), key="upload_tab_upload_post_platforms")
7648
7648
  upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)), key="upload_tab_upload_post_auto")
7649
- if st.button("Guardar Upload-Post", type="primary", use_container_width=True, key="upload_tab_save_upload_post"):
7649
+ if st.button("Guardar Upload-Post", type="primary", width='stretch', key="upload_tab_save_upload_post"):
7650
7650
  settings.update({"upload_post_enabled": bool(upload_post_enabled_upload), "upload_post_api_key": upload_post_key_upload.strip(), "upload_post_username": upload_post_user_upload.strip(), "upload_post_platforms": upload_post_platforms_upload.strip(), "upload_post_auto_upload": bool(upload_post_auto_upload)})
7651
7651
  write_json("settings.json", settings)
7652
7652
  st.success("Upload-Post guardado.")
@@ -7658,7 +7658,7 @@ def render_settings():
7658
7658
  postiz_mcp_upload = st.text_input("Postiz MCP URL", value=str(settings.get("postiz_mcp_url") or "https://api.postiz.com/mcp"), key="upload_tab_postiz_mcp")
7659
7659
  postiz_integration_upload = st.text_input("Postiz integração padrão", value=str(settings.get("postiz_integration_id") or ""), key="upload_tab_postiz_integration")
7660
7660
  postiz_auto_upload = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)), key="upload_tab_postiz_auto")
7661
- if st.button("Guardar Postiz", type="primary", use_container_width=True, key="upload_tab_save_postiz"):
7661
+ if st.button("Guardar Postiz", type="primary", width='stretch', key="upload_tab_save_postiz"):
7662
7662
  settings.update({"postiz_enabled": bool(postiz_enabled_upload), "postiz_api_key": postiz_key_upload.strip(), "postiz_base_url": postiz_base_upload.strip(), "postiz_mcp_url": postiz_mcp_upload.strip(), "postiz_integration_id": postiz_integration_upload.strip(), "postiz_auto_publish": bool(postiz_auto_upload)})
7663
7663
  write_json("settings.json", settings)
7664
7664
  st.success("Postiz guardado.")
@@ -7684,8 +7684,8 @@ def render_settings():
7684
7684
  influencer_supabase_url = text_setting("Supabase Project URL", "influencer_supabase_url", help_text="URL do projecto, por exemplo https://project-id.supabase.co")
7685
7685
  with db_cols[1]:
7686
7686
  influencer_supabase_key = text_setting("Supabase API key", "influencer_supabase_key", secret=True, help_text="Use uma chave com as permissões RLS adequadas. Nunca é colocada no GitHub ou nos logs.")
7687
- test_backend_clicked = st.form_submit_button("Testar ligação do backend", use_container_width=True)
7688
- save_backend_clicked = st.form_submit_button("Guardar configuração do backend", type="primary", use_container_width=True)
7687
+ test_backend_clicked = st.form_submit_button("Testar ligação do backend", width='stretch')
7688
+ save_backend_clicked = st.form_submit_button("Guardar configuração do backend", type="primary", width='stretch')
7689
7689
  effective_settings = dict(settings)
7690
7690
  effective_settings.update({
7691
7691
  "influencer_db_backend": influencer_db_backend,
@@ -7790,7 +7790,7 @@ def _render_telegram_notification_settings() -> None:
7790
7790
  lambda: test_telegram_credentials(telegram_bot_token, telegram_chat_id),
7791
7791
  widget_key="api_test_telegram",
7792
7792
  )
7793
- if st.form_submit_button("Guardar configuração Telegram", type="primary", use_container_width=True):
7793
+ if st.form_submit_button("Guardar configuração Telegram", type="primary", width='stretch'):
7794
7794
  settings.update({
7795
7795
  "telegram_enabled": bool(telegram_enabled),
7796
7796
  "telegram_bot_token": telegram_bot_token.strip(),
@@ -7838,7 +7838,7 @@ def render_logs():
7838
7838
 
7839
7839
  action_cols = st.columns([1, 1, 3])
7840
7840
  with action_cols[0]:
7841
- if st.button("Actualizar logs", use_container_width=True):
7841
+ if st.button("Actualizar logs", width='stretch'):
7842
7842
  st.rerun()
7843
7843
  with action_cols[1]:
7844
7844
  st.metric("Registos", len(initial_records))
@@ -7854,7 +7854,7 @@ def render_logs():
7854
7854
  rows = logs_to_rows(records)
7855
7855
  st.dataframe(
7856
7856
  rows,
7857
- use_container_width=True,
7857
+ width='stretch',
7858
7858
  height=520,
7859
7859
  hide_index=True,
7860
7860
  column_config={
@@ -7894,16 +7894,16 @@ def render_notifications():
7894
7894
 
7895
7895
  action_cols = st.columns([1.4, 1.4, 2.2])
7896
7896
  with action_cols[0]:
7897
- if st.button("Marcar todas como lidas", use_container_width=True, disabled=unread_count == 0):
7897
+ if st.button("Marcar todas como lidas", width='stretch', disabled=unread_count == 0):
7898
7898
  mark_all_notifications_read()
7899
7899
  st.rerun()
7900
7900
  with action_cols[1]:
7901
- if st.button("Actualizar notificações", use_container_width=True):
7901
+ if st.button("Actualizar notificações", width='stretch'):
7902
7902
  reconcile_persisted_notifications()
7903
7903
  st.rerun()
7904
7904
  with action_cols[2]:
7905
7905
  confirm_clear = st.checkbox("Confirmar limpeza do histórico", key="confirm_clear_notifications")
7906
- if st.button("Limpar histórico", use_container_width=True, disabled=not confirm_clear):
7906
+ if st.button("Limpar histórico", width='stretch', disabled=not confirm_clear):
7907
7907
  clear_notifications()
7908
7908
  st.session_state.pop("confirm_clear_notifications", None)
7909
7909
  st.rerun()
@@ -7925,7 +7925,7 @@ def render_notifications():
7925
7925
  help=event["description"],
7926
7926
  key=f"notification_preference_{event['code']}",
7927
7927
  )
7928
- if st.form_submit_button("Guardar preferências", type="primary", use_container_width=True):
7928
+ if st.form_submit_button("Guardar preferências", type="primary", width='stretch'):
7929
7929
  save_notification_preferences(pending_preferences)
7930
7930
  st.success("Preferências de notificação guardadas.")
7931
7931
  st.rerun()
@@ -7960,7 +7960,7 @@ def render_notifications():
7960
7960
  st.caption(f"{item.get('category', 'Sistema')} · {item.get('created_at', '—')}")
7961
7961
  st.write("Lida" if item.get("read") else "Não lida")
7962
7962
  with notification_cols[2]:
7963
- if not item.get("read") and st.button("Marcar como lida", key=f"mark_notification_{item.get('id')}", use_container_width=True):
7963
+ if not item.get("read") and st.button("Marcar como lida", key=f"mark_notification_{item.get('id')}", width='stretch'):
7964
7964
  mark_notification_read(str(item.get("id")))
7965
7965
  st.rerun()
7966
7966
 
@@ -8007,6 +8007,22 @@ def render_supabase_tutorial():
8007
8007
  st.markdown(tutorial_content, unsafe_allow_html=True)
8008
8008
 
8009
8009
 
8010
+ def render_canvas_api_tutorial():
8011
+ """Render the Canva Connect API setup guide supplied for Thunderbolt."""
8012
+ tutorial_path = ROOT / "seed" / "references" / "tutorial-api-canvas.md"
8013
+ st.title("Tutorial API Canvas")
8014
+ st.caption("Guia para configurar as credenciais Canva Connect API usadas pelo Thunderbolt.")
8015
+ st.markdown("[Abrir documentação oficial da Canva](https://www.canva.dev/docs/connect/)")
8016
+ try:
8017
+ tutorial_content = tutorial_path.read_text(encoding="utf-8").strip()
8018
+ except OSError:
8019
+ tutorial_content = ""
8020
+ if not tutorial_content:
8021
+ st.error("O conteúdo local do tutorial não está disponível. Consulte a documentação oficial da Canva.")
8022
+ return
8023
+ st.markdown(tutorial_content, unsafe_allow_html=False)
8024
+
8025
+
8010
8026
  def render_google_oauth_tutorial():
8011
8027
  """Render the Google OAuth setup guide supplied for Thunderbolt."""
8012
8028
  tutorial_path = ROOT / "seed" / "references" / "tutorial-oauth-google.md"
@@ -8080,7 +8096,7 @@ def render_mcp():
8080
8096
  st.rerun()
8081
8097
 
8082
8098
  st.caption(integration.get("endpoint_note", "Porta editável para o serviço local."))
8083
- if st.button("Guardar porta", key=f"mcp_save_port_{integration_id}", use_container_width=True):
8099
+ if st.button("Guardar porta", key=f"mcp_save_port_{integration_id}", width='stretch'):
8084
8100
  update_integration(integration_id, port=int(port))
8085
8101
  st.success(f"Porta de {integration['name']} guardada: {int(port)}")
8086
8102
  st.rerun()
@@ -8114,7 +8130,7 @@ def render_mcp():
8114
8130
  st.error("Para expor o servidor fora do computador local, defina um token de acesso MCP.")
8115
8131
 
8116
8132
  action_label = "Guardar e iniciar Servidor MCP" if server_enabled else "Guardar e parar Servidor MCP"
8117
- if st.button(action_label, type="primary", key="mcp_server_save", use_container_width=True):
8133
+ if st.button(action_label, type="primary", key="mcp_server_save", width='stretch'):
8118
8134
  try:
8119
8135
  saved = save_server_config(enabled=server_enabled, host=server_host, port=int(server_port), auth_token=server_token, write_enabled=write_enabled)
8120
8136
  if saved["enabled"]:
@@ -8154,7 +8170,7 @@ def render_mcp():
8154
8170
  st.caption("A skill anexada pode ser guardada na pasta local do Thunderbolt ou descarregada como ficheiro Markdown. Nenhum dos quatro repositórios externos é copiado para o pacote.")
8155
8171
  skill_cols = st.columns(2)
8156
8172
  with skill_cols[0]:
8157
- if st.button("Guardar skill localmente", type="primary", use_container_width=True, key="mcp_install_mpt_skill"):
8173
+ if st.button("Guardar skill localmente", type="primary", width='stretch', key="mcp_install_mpt_skill"):
8158
8174
  try:
8159
8175
  destination = install_skill_locally()
8160
8176
  st.success(f"Skill guardada em `{destination}`")
@@ -8171,7 +8187,7 @@ def render_mcp():
8171
8187
  data=skill_data,
8172
8188
  file_name="moneyprinterturbo-video.md",
8173
8189
  mime="text/markdown",
8174
- use_container_width=True,
8190
+ width='stretch',
8175
8191
  key="mcp_download_mpt_skill",
8176
8192
  )
8177
8193
  else:
@@ -8273,8 +8289,8 @@ def render_metadata_cleaner():
8273
8289
  st.subheader("Resultado")
8274
8290
  st.write(f"**Ficheiro limpo:** `{output.name}`")
8275
8291
  mime = "video/mp4" if output.suffix.lower() == ".mp4" else "video/*"
8276
- st.download_button("Descarregar vídeo limpo", data=output.read_bytes(), file_name=output.name, mime=mime, use_container_width=True, key="metadata_download_video")
8277
- st.download_button("Descarregar manifesto de upload (JSON)", data=metadata_manifest(record), file_name=f"{output.stem}-metadata.json", mime="application/json", use_container_width=True, key="metadata_download_manifest")
8292
+ st.download_button("Descarregar vídeo limpo", data=output.read_bytes(), file_name=output.name, mime=mime, width='stretch', key="metadata_download_video")
8293
+ st.download_button("Descarregar manifesto de upload (JSON)", data=metadata_manifest(record), file_name=f"{output.stem}-metadata.json", mime="application/json", width='stretch', key="metadata_download_manifest")
8278
8294
  with st.expander("Pré-visualizar metadados"):
8279
8295
  st.json(record["metadata"])
8280
8296
 
@@ -8303,7 +8319,7 @@ def render_update_youtube_videos():
8303
8319
  channel = st.selectbox("Canal YouTube", channels, format_func=lambda item: item.get("name") or item.get("url") or "Canal sem nome", key="update_youtube_channel")
8304
8320
  updater = YouTubeVideoUpdater(settings, STORAGE)
8305
8321
  refresh_key = f"update_youtube_loaded_{channel.get('id', '')}"
8306
- if st.button("Carregar vídeos do canal", type="primary", use_container_width=True, key="update_youtube_load") or refresh_key not in st.session_state:
8322
+ if st.button("Carregar vídeos do canal", type="primary", width='stretch', key="update_youtube_load") or refresh_key not in st.session_state:
8307
8323
  with st.spinner("A carregar vídeos publicados…"):
8308
8324
  result = updater.list_videos(channel, max_results=50)
8309
8325
  st.session_state[refresh_key] = result.data if result.ok else {"error": result.message, "videos": []}
@@ -8325,7 +8341,7 @@ def render_update_youtube_videos():
8325
8341
  top = st.columns([1.2, 3.8, 1.2])
8326
8342
  with top[0]:
8327
8343
  if video.get("thumbnail_url"):
8328
- st.image(video["thumbnail_url"], use_container_width=True)
8344
+ st.image(video["thumbnail_url"], width='stretch')
8329
8345
  with top[1]:
8330
8346
  st.markdown(f"**{video.get('title') or 'Sem título'}**")
8331
8347
  st.caption(f"{video.get('published_at') or 'Data indisponível'} · {video.get('privacy_status') or 'estado desconhecido'} · `{video_id}`")
@@ -8340,7 +8356,7 @@ def render_update_youtube_videos():
8340
8356
  ai_cols = st.columns(3)
8341
8357
  script = next((str(task.get("script") or task.get("video_script") or "") for task in tasks if str(task.get("youtube_video_id") or task.get("video_id") or "") == video_id), "")
8342
8358
  with ai_cols[0]:
8343
- if st.button("Gerar título", key=f"{card_key}_ai_title", use_container_width=True):
8359
+ if st.button("Gerar título", key=f"{card_key}_ai_title", width='stretch'):
8344
8360
  try:
8345
8361
  generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="title")
8346
8362
  st.session_state[title_key] = generated["title"]
@@ -8348,7 +8364,7 @@ def render_update_youtube_videos():
8348
8364
  except CreativeGenerationError as exc:
8349
8365
  st.error(str(exc))
8350
8366
  with ai_cols[1]:
8351
- if st.button("Gerar descrição", key=f"{card_key}_ai_description", use_container_width=True):
8367
+ if st.button("Gerar descrição", key=f"{card_key}_ai_description", width='stretch'):
8352
8368
  try:
8353
8369
  generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="description")
8354
8370
  st.session_state[desc_key] = generated["description"]
@@ -8356,7 +8372,7 @@ def render_update_youtube_videos():
8356
8372
  except CreativeGenerationError as exc:
8357
8373
  st.error(str(exc))
8358
8374
  with ai_cols[2]:
8359
- if st.button("Gerar título e descrição", key=f"{card_key}_ai_both", use_container_width=True):
8375
+ if st.button("Gerar título e descrição", key=f"{card_key}_ai_both", width='stretch'):
8360
8376
  try:
8361
8377
  generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="both")
8362
8378
  st.session_state[title_key] = generated.get("title", title)
@@ -8365,7 +8381,7 @@ def render_update_youtube_videos():
8365
8381
  except CreativeGenerationError as exc:
8366
8382
  st.error(str(exc))
8367
8383
  thumbnail = st.file_uploader("Trocar thumbnail (opcional)", type=["jpg", "jpeg", "png"], key=f"{card_key}_thumbnail")
8368
- if st.button("Actualizar no YouTube", type="primary", key=f"{card_key}_save", use_container_width=True):
8384
+ if st.button("Actualizar no YouTube", type="primary", key=f"{card_key}_save", width='stretch'):
8369
8385
  thumbnail_path = None
8370
8386
  if thumbnail is not None:
8371
8387
  thumbnail_path = STORAGE / "youtube_update" / f"{video_id}_{thumbnail.name}"
@@ -8437,6 +8453,7 @@ def main():
8437
8453
  documentation_items = [
8438
8454
  ("Tutorial Meta", ":material/menu_book:", "Tutorial Meta"),
8439
8455
  ("Tutorial Supabase", ":material/storage:", "Tutorial Supabase"),
8456
+ ("Tutorial API Canvas", ":material/design_services:", "Tutorial API Canvas"),
8440
8457
  ("Tutorial Kaggle", ":material/menu_book:", "Tutorial Kaggle"),
8441
8458
  ("Tutorial Apify", ":material/menu_book:", "Tutorial Apify"),
8442
8459
  ("Tutorial YouTube Video-Upload Frontend", ":material/video_library:", "Tutorial YouTube Video-Upload Frontend"),
@@ -8548,7 +8565,7 @@ def main():
8548
8565
 
8549
8566
  def render_nav_button(target: str, icon: str, label: str, scope: str):
8550
8567
  display_label = ui_text(label, ui_language)
8551
- if st.button(display_label, key=f"nav_{scope}_{target}", icon=icon, use_container_width=True, type="primary" if is_nav_item_active(target) else "secondary"):
8568
+ if st.button(display_label, key=f"nav_{scope}_{target}", icon=icon, width='stretch', type="primary" if is_nav_item_active(target) else "secondary"):
8552
8569
  navigate(target)
8553
8570
 
8554
8571
  with st.sidebar:
@@ -8616,6 +8633,7 @@ def main():
8616
8633
  "Documentação": lambda: render_edit_placeholder("Documentação", "Seleccione um tutorial no menu expansível."),
8617
8634
  "Tutorial Meta": render_models_ai_tutorial,
8618
8635
  "Tutorial Supabase": render_supabase_tutorial,
8636
+ "Tutorial API Canvas": render_canvas_api_tutorial,
8619
8637
  "Tutorial YouTube Video-Upload Frontend": render_youtube_frontend_upload_tutorial,
8620
8638
  "Tutorial OAuth do Google": render_google_oauth_tutorial,
8621
8639
  "Configurações": lambda: render_edit_placeholder("Configurações", "Seleccione uma opção no menu expansível."),