@danhachuel/thunderbolt 0.6.97 → 0.6.98

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
@@ -639,9 +639,9 @@ def _render_library_name_editor(kind: str, path: Path, current_name: str) -> str
639
639
  edited_name = st.text_input("Nome de apresentação", value=current_name, max_chars=120, key=f"{edit_key}_input")
640
640
  save_col, cancel_col = st.columns(2)
641
641
  with save_col:
642
- save_name = st.form_submit_button("Guardar nome", type="primary", use_container_width=True)
642
+ save_name = st.form_submit_button("Guardar nome", type="primary", width="stretch")
643
643
  with cancel_col:
644
- cancel_name = st.form_submit_button("Cancelar", use_container_width=True)
644
+ cancel_name = st.form_submit_button("Cancelar", width="stretch")
645
645
  if save_name:
646
646
  try:
647
647
  set_display_name(kind, path, edited_name)
@@ -657,7 +657,7 @@ def _render_library_name_editor(kind: str, path: Path, current_name: str) -> str
657
657
 
658
658
 
659
659
  def _render_card_pencil(edit_key: str) -> None:
660
- if st.button("✏️", help="Editar nome de apresentação", key=f"pencil_{edit_key}", type="tertiary", use_container_width=True):
660
+ if st.button("✏️", help="Editar nome de apresentação", key=f"pencil_{edit_key}", type="tertiary", width="stretch"):
661
661
  st.session_state[edit_key] = True
662
662
  st.rerun()
663
663
 
@@ -1153,7 +1153,7 @@ def render_video_generation_settings(
1153
1153
  st.button(
1154
1154
  "Gerar tópico, roteiro e palavras-chave com IA",
1155
1155
  key=f"{prefix}_generate_video_content",
1156
- use_container_width=True,
1156
+ width="stretch",
1157
1157
  type="secondary",
1158
1158
  icon=":material/auto_awesome:",
1159
1159
  on_click=generate_content_callback,
@@ -1166,7 +1166,7 @@ def render_video_generation_settings(
1166
1166
  st.button(
1167
1167
  "Salvar rascunho",
1168
1168
  key=f"{prefix}_save_draft",
1169
- use_container_width=True,
1169
+ width="stretch",
1170
1170
  type="secondary",
1171
1171
  icon=":material/save:",
1172
1172
  on_click=save_draft_callback,
@@ -1225,7 +1225,7 @@ def render_video_generation_settings(
1225
1225
  type=["mp3", "mpeg", "wav", "m4a", "aac", "flac", "ogg"],
1226
1226
  key=f"{prefix}_voiceover_upload",
1227
1227
  )
1228
- if uploaded_voiceover is not None and st.button("Guardar áudio de narração", key=f"{prefix}_voiceover_store", use_container_width=True):
1228
+ if uploaded_voiceover is not None and st.button("Guardar áudio de narração", key=f"{prefix}_voiceover_store", width="stretch"):
1229
1229
  try:
1230
1230
  stored_voiceover = store_voiceover_file(uploaded_voiceover.name, uploaded_voiceover.getvalue())
1231
1231
  st.session_state[f"{prefix}_voiceover_file"] = str(stored_voiceover)
@@ -1357,7 +1357,7 @@ def channel_videos_for(channel: dict, limit: int = 10) -> list[dict[str, Any]]:
1357
1357
  def render_channel_video_editor(video: dict, channel_id: str) -> None:
1358
1358
  video_id = str(video.get("id") or "")
1359
1359
  edit_key = f"channel_video_edit_{video_id}"
1360
- if st.button("Editar vídeo", key=f"edit_channel_video_{video_id}", use_container_width=True):
1360
+ if st.button("Editar vídeo", key=f"edit_channel_video_{video_id}", width="stretch"):
1361
1361
  st.session_state[edit_key] = True
1362
1362
  st.rerun()
1363
1363
  if st.session_state.get(edit_key):
@@ -1381,7 +1381,7 @@ def render_channel_videos(channel: dict) -> None:
1381
1381
  st.caption("A lista usa o feed público do YouTube, sem Data API Key. Pode actualizar manualmente e editar os metadados locais apresentados.")
1382
1382
  refresh_col = st.columns(1)[0]
1383
1383
  with refresh_col:
1384
- if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", use_container_width=True):
1384
+ if st.button("Actualizar últimos 10 vídeos", key=f"refresh_channel_videos_{channel_id}", width="stretch"):
1385
1385
  result = fetch_channel_videos_public(channel, limit=10)
1386
1386
  if result.ok:
1387
1387
  videos = _merge_channel_videos(channel_id, result.data.get("videos", []))
@@ -1432,7 +1432,7 @@ def render_channel_audio_subtitle_defaults(channel: dict) -> None:
1432
1432
  subtitle_font_size = st.slider("Font Size", min_value=12, max_value=96, value=int(channel.get("default_subtitle_font_size") or 60), key=f"channel_default_subtitle_font_size_{channel_id}")
1433
1433
  subtitle_outline = st.color_picker("Outline", str(channel.get("default_subtitle_outline") or "#000000"), key=f"channel_default_subtitle_outline_{channel_id}")
1434
1434
  subtitle_outline_width = st.slider("Outline Width", min_value=0.0, max_value=5.0, value=float(channel.get("default_subtitle_outline_width") or 1.5), step=0.25, key=f"channel_default_subtitle_outline_width_{channel_id}")
1435
- if st.button("Guardar configurações de legendas", type="primary", key=f"save_channel_subtitle_defaults_{channel_id}", use_container_width=True):
1435
+ if st.button("Guardar configurações de legendas", type="primary", key=f"save_channel_subtitle_defaults_{channel_id}", width="stretch"):
1436
1436
  update_channel(channel_id, {
1437
1437
  "default_enable_subtitles": bool(enable_subtitles), "default_subtitle_font": selected_font, "default_subtitle_position": selected_position,
1438
1438
  "default_subtitle_color": subtitle_color, "default_subtitle_background": bool(subtitle_background), "default_subtitle_background_color": subtitle_background_color,
@@ -1444,7 +1444,7 @@ def render_channel_audio_subtitle_defaults(channel: dict) -> None:
1444
1444
  with st.expander("Configurações de Audio", expanded=False):
1445
1445
  selected_music_source = st.selectbox("Fonte da música de fundo", BACKGROUND_MUSIC_SOURCE_OPTIONS, index=BACKGROUND_MUSIC_SOURCE_OPTIONS.index(music_source) if music_source in BACKGROUND_MUSIC_SOURCE_OPTIONS else BACKGROUND_MUSIC_SOURCE_OPTIONS.index("Sem música"), key=f"channel_default_music_source_{channel_id}")
1446
1446
  selected_music_volume = st.selectbox("Volume da música de fundo", BACKGROUND_MUSIC_VOLUME_OPTIONS, index=BACKGROUND_MUSIC_VOLUME_OPTIONS.index(music_volume) if music_volume in BACKGROUND_MUSIC_VOLUME_OPTIONS else 2, key=f"channel_default_music_volume_{channel_id}")
1447
- if st.button("Guardar configurações de Audio", type="primary", key=f"save_channel_audio_defaults_{channel_id}", use_container_width=True):
1447
+ if st.button("Guardar configurações de Audio", type="primary", key=f"save_channel_audio_defaults_{channel_id}", width="stretch"):
1448
1448
  update_channel(channel_id, {"default_background_music_source": selected_music_source, "default_background_music_volume": selected_music_volume})
1449
1449
  st.success("Default de Audio guardado para este canal.")
1450
1450
  st.rerun()
@@ -1482,7 +1482,7 @@ def render_channel_edit_form(channel: dict, youtube_account_ids: list[str], yout
1482
1482
  edited_description = st.text_area("Descrição", value=str(channel.get("description") or ""), height=100)
1483
1483
  edited_automation = st.toggle("Automação ON", value=bool(channel.get("automation_on", False)), key=f"edit_automation_{channel_id}")
1484
1484
  edited_time = st.text_input("Horário diário (HH:MM)", value=str(channel.get("automation_time") or "00:00"))
1485
- save_channel = st.form_submit_button("Guardar alterações", type="primary", use_container_width=True)
1485
+ save_channel = st.form_submit_button("Guardar alterações", type="primary", width="stretch")
1486
1486
  cancel_edit = st.form_submit_button("Cancelar edição")
1487
1487
  if cancel_edit:
1488
1488
  st.session_state.pop(f"edit_channel_{channel_id}", None)
@@ -1650,7 +1650,7 @@ def render_growth_youtube():
1650
1650
  action_button_col, action_status_col = st.columns([1.15, 0.85], gap="small")
1651
1651
  with action_button_col:
1652
1652
  st.write("")
1653
- analyse_clicked = st.button("ANALISAR CANAL", type="primary", use_container_width=True, key="growth_youtube_analyse")
1653
+ analyse_clicked = st.button("ANALISAR CANAL", type="primary", width="stretch", key="growth_youtube_analyse")
1654
1654
  with action_status_col:
1655
1655
  if st.session_state.get("growth_analysis_status") == "complete":
1656
1656
  st.markdown('<div style="padding-top:9px;color:#22c55e;font-weight:700;white-space:nowrap;">✓ Análise concluída</div>', unsafe_allow_html=True)
@@ -1709,9 +1709,9 @@ def render_growth_youtube():
1709
1709
  if selected_record and selected_record.get("report_path"):
1710
1710
  report_path = Path(str(selected_record["report_path"]))
1711
1711
  if report_path.is_file():
1712
- st.download_button("BAIXAR ANALISE COMPLETA", report_path.read_bytes(), file_name=report_path.name, mime="text/markdown", use_container_width=True, key=f"growth_download_{selected_record['code']}")
1712
+ st.download_button("BAIXAR ANALISE COMPLETA", report_path.read_bytes(), file_name=report_path.name, mime="text/markdown", width="stretch", key=f"growth_download_{selected_record['code']}")
1713
1713
  else:
1714
- st.button("BAIXAR ANALISE COMPLETA", disabled=True, use_container_width=True, key="growth_download_disabled")
1714
+ st.button("BAIXAR ANALISE COMPLETA", disabled=True, width="stretch", key="growth_download_disabled")
1715
1715
  st.subheader("Dashboard de Growth")
1716
1716
  st.caption("Visão híbrida: os 3 pilares críticos em destaque e os 5 pilares operacionais abaixo. Vermelho: 0–30 · Amarelo: 31–69 · Verde: 70–100.")
1717
1717
  source_names = {"public": "PÚBLICO", "paligemma": "PALIGEMMA", "estimated": "DADO ESTIMADO", "unavailable": "INDISPONÍVEL", "youtube_analytics_oauth": "ANALYTICS OAUTH"}
@@ -2029,7 +2029,7 @@ def render_thumbnail_blueprints():
2029
2029
  with cols[1]:
2030
2030
  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}")
2031
2031
  with cols[2]:
2032
- if st.button("Guardar par", key=f"thumbnail_blueprint_save_{channel_id}", use_container_width=True):
2032
+ if st.button("Guardar par", key=f"thumbnail_blueprint_save_{channel_id}", width="stretch"):
2033
2033
  if thumb == "Generic_Thumbnail_Blueprint" and script:
2034
2034
  st.error("Not Allowed to Associate, System Use Only")
2035
2035
  else:
@@ -2102,7 +2102,7 @@ def render_tiktok_accounts():
2102
2102
  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.")
2103
2103
  with st.form("tiktok_public_lookup_form"):
2104
2104
  lookup_source = st.text_input("URL pública ou @handle", placeholder="https://www.tiktok.com/@conta ou @conta", key="tiktok_public_lookup_source")
2105
- lookup_submitted = st.form_submit_button("Pesquisar perfil público", type="primary", use_container_width=True)
2105
+ lookup_submitted = st.form_submit_button("Pesquisar perfil público", type="primary", width="stretch")
2106
2106
  if lookup_submitted:
2107
2107
  result = fetch_public_tiktok_profile(lookup_source)
2108
2108
  st.session_state["tiktok_public_lookup"] = {"ok": result.ok, "message": result.message, "data": result.data}
@@ -2122,7 +2122,7 @@ def render_tiktok_accounts():
2122
2122
  with metric_cols[3]: st.metric("Vídeos", lookup_data.get("video_count") if lookup_data.get("video_count") is not None else "—")
2123
2123
  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")
2124
2124
  notes = st.text_area("Observações internas", value=str(lookup_data.get("notes") or ""), key="tiktok_lookup_notes", height=80)
2125
- if st.button("Cadastrar conta TikTok", type="primary", use_container_width=True, key="tiktok_register_public_account"):
2125
+ if st.button("Cadastrar conta TikTok", type="primary", width="stretch", key="tiktok_register_public_account"):
2126
2126
  try:
2127
2127
  stored = {**lookup_data, "name": display_name.strip() or lookup_data.get("username", ""), "notes": notes.strip(), "source": "public_lookup"}
2128
2128
  _upsert_tiktok_account(stored)
@@ -2138,7 +2138,7 @@ def render_tiktok_accounts():
2138
2138
  manual_source = st.text_input("@handle ou URL pública", placeholder="@minhaconta", key="tiktok_manual_source")
2139
2139
  manual_name = st.text_input("Nome da conta", placeholder="Nome de apresentação", key="tiktok_manual_name")
2140
2140
  manual_notes = st.text_area("Observações internas", height=90, key="tiktok_manual_notes")
2141
- manual_submitted = st.form_submit_button("Guardar cadastro manual", type="primary", use_container_width=True)
2141
+ manual_submitted = st.form_submit_button("Guardar cadastro manual", type="primary", width="stretch")
2142
2142
  if manual_submitted:
2143
2143
  try:
2144
2144
  reference = normalize_tiktok_reference(manual_source)
@@ -2177,7 +2177,7 @@ def render_tiktok_accounts():
2177
2177
  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}")
2178
2178
  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}")
2179
2179
  edited_notes = st.text_area("Observações internas", value=str(account.get("notes") or ""), key=f"edit_tiktok_notes_{account_id}", height=80)
2180
- edit_submitted = st.form_submit_button("Guardar conta", type="primary", use_container_width=True)
2180
+ edit_submitted = st.form_submit_button("Guardar conta", type="primary", width="stretch")
2181
2181
  if edit_submitted:
2182
2182
  try:
2183
2183
  reference = normalize_tiktok_reference(edited_source)
@@ -2202,7 +2202,7 @@ def render_tiktok_prompt_masters():
2202
2202
  uploaded_name = Path(uploaded_prompt.name).stem
2203
2203
  prompt_name = st.text_input("Nome do Prompt Master", value=uploaded_name, key="tiktok_prompt_master_name")
2204
2204
  replace_prompt = st.checkbox("Permitir substituir um ficheiro existente", key="tiktok_prompt_master_replace")
2205
- if st.button("Guardar Prompt Master", type="primary", use_container_width=True, key="save_tiktok_prompt_master"):
2205
+ if st.button("Guardar Prompt Master", type="primary", width="stretch", key="save_tiktok_prompt_master"):
2206
2206
  safe_stem = re.sub(r"[^A-Za-z0-9À-ÿ._-]+", "-", prompt_name.strip() or uploaded_name).strip(".-") or "prompt-master"
2207
2207
  destination = TIKTOK_PROMPT_MASTERS / f"{safe_stem}.md"
2208
2208
  if destination.exists() and not replace_prompt:
@@ -2248,14 +2248,14 @@ def render_tiktok_prompt_masters():
2248
2248
  edited_content = st.text_area("Conteúdo Markdown", value=content, height=360, key=f"tiktok_prompt_master_editor_{path.stem}")
2249
2249
  prompt_cols = st.columns(3)
2250
2250
  with prompt_cols[0]:
2251
- if st.button("Guardar alterações", type="primary", use_container_width=True, key=f"save_prompt_master_{path.stem}"):
2251
+ if st.button("Guardar alterações", type="primary", width="stretch", key=f"save_prompt_master_{path.stem}"):
2252
2252
  path.write_text(edited_content.rstrip() + "\n", encoding="utf-8")
2253
2253
  st.success("Prompt Master actualizado.")
2254
2254
  st.rerun()
2255
2255
  with prompt_cols[1]:
2256
- 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}")
2256
+ 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}")
2257
2257
  with prompt_cols[2]:
2258
- if st.button("Apagar", use_container_width=True, key=f"delete_prompt_master_{path.stem}"):
2258
+ if st.button("Apagar", width="stretch", key=f"delete_prompt_master_{path.stem}"):
2259
2259
  path.unlink(missing_ok=True)
2260
2260
  st.success("Prompt Master removido da biblioteca TikTok.")
2261
2261
  st.rerun()
@@ -2334,11 +2334,11 @@ def render_tiktok_channels():
2334
2334
  source = st.text_input("URL pública ou @handle", placeholder="https://www.tiktok.com/@conta", key="tiktok_channel_source")
2335
2335
  lookup_cols = st.columns([1, 1])
2336
2336
  with lookup_cols[0]:
2337
- if st.button("Buscar no Tiktok", type="primary", use_container_width=True, key="tiktok_channel_lookup"):
2337
+ if st.button("Buscar no Tiktok", type="primary", width="stretch", key="tiktok_channel_lookup"):
2338
2338
  result = fetch_public_tiktok_profile(source)
2339
2339
  st.session_state["tiktok_channel_import"] = {"ok": result.ok, "message": result.message, "data": result.data}
2340
2340
  with lookup_cols[1]:
2341
- if st.button("Limpar importação", use_container_width=True, key="tiktok_channel_clear"):
2341
+ if st.button("Limpar importação", width="stretch", key="tiktok_channel_clear"):
2342
2342
  for key in ("tiktok_channel_import", "tiktok_import_language", "tiktok_import_niche", "tiktok_import_prompt", "tiktok_import_description"):
2343
2343
  st.session_state.pop(key, None)
2344
2344
  st.rerun()
@@ -2432,7 +2432,7 @@ def render_tiktok_channels():
2432
2432
  prompt_content = load_prompt_master_file(prompt_file) if prompt_file.is_file() else ""
2433
2433
  calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel, prompt_master=prompt_content)
2434
2434
  average_video_time = st.text_input("Tempo Medio de Video", value=channel_video_time_value(channel), key=f"tiktok_channel_average_video_time_{channel_id}", help=f"Referência: {calculated_words or 0} palavras · {calculated_source}.")
2435
- if st.button("Guardar tempo", key=f"save_tiktok_channel_average_video_time_{channel_id}", use_container_width=True):
2435
+ if st.button("Guardar tempo", key=f"save_tiktok_channel_average_video_time_{channel_id}", width="stretch"):
2436
2436
  if not valid_hhmm(average_video_time):
2437
2437
  st.error("Use o formato MM:SS, por exemplo 12:00.")
2438
2438
  else:
@@ -2440,7 +2440,7 @@ def render_tiktok_channels():
2440
2440
  st.success("Tempo médio do canal TikTok guardado.")
2441
2441
  st.rerun()
2442
2442
  with card_cols[6]:
2443
- if st.button("↻", key=f"refresh_tiktok_metrics_{channel_id}", help="Actualizar Seguidores, Vídeos e Curtidas", use_container_width=True):
2443
+ if st.button("↻", key=f"refresh_tiktok_metrics_{channel_id}", help="Actualizar Seguidores, Vídeos e Curtidas", width="stretch"):
2444
2444
  with st.spinner("A actualizar métricas TikTok…"):
2445
2445
  refreshed, message = _refresh_tiktok_channel_metrics(channel)
2446
2446
  (st.success if refreshed else st.warning)(message)
@@ -2544,7 +2544,7 @@ def render_tiktok_channels():
2544
2544
  if channels:
2545
2545
  st.dataframe(
2546
2546
  [{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],
2547
- use_container_width=True,
2547
+ width="stretch",
2548
2548
  hide_index=True,
2549
2549
  height=360,
2550
2550
  )
@@ -2664,7 +2664,7 @@ def render_channels():
2664
2664
  st.caption("Os canais cadastrados são apresentados apenas em lista.")
2665
2665
  col1, col2 = st.columns([1, 1])
2666
2666
  with col1:
2667
- if st.button("Buscar no YouTube", type="primary", use_container_width=True, key="youtube_channel_lookup"):
2667
+ if st.button("Buscar no YouTube", type="primary", width="stretch", key="youtube_channel_lookup"):
2668
2668
  search_source = str(source or "").strip()
2669
2669
  st.session_state.pop("yt_import", None)
2670
2670
  st.session_state.pop("yt_message", None)
@@ -2689,7 +2689,7 @@ def render_channels():
2689
2689
  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]}"
2690
2690
  st.session_state["yt_ok"] = False
2691
2691
  with col2:
2692
- if st.button("Limpar importação", use_container_width=True, key="youtube_channel_clear"):
2692
+ if st.button("Limpar importação", width="stretch", key="youtube_channel_clear"):
2693
2693
  for key in ("yt_import", "yt_message", "yt_ok"):
2694
2694
  st.session_state.pop(key, None)
2695
2695
  st.rerun()
@@ -2771,7 +2771,7 @@ def render_channels():
2771
2771
  data=build_channel_template_xlsx(),
2772
2772
  file_name="modelo_canais_youtube.xlsx",
2773
2773
  mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
2774
- use_container_width=True,
2774
+ width="stretch",
2775
2775
  key="download_channel_spreadsheet_template",
2776
2776
  )
2777
2777
  with help_col:
@@ -2783,7 +2783,7 @@ def render_channels():
2783
2783
  key="channel_spreadsheet_upload",
2784
2784
  help="Apenas a primeira aba do arquivo será importada. Linhas sem URL, nome e handle são ignoradas.",
2785
2785
  )
2786
- if st.button("Ler e preparar planilha", type="primary", use_container_width=True, key="read_channel_spreadsheet"):
2786
+ if st.button("Ler e preparar planilha", type="primary", width="stretch", key="read_channel_spreadsheet"):
2787
2787
  if uploaded_sheet is None:
2788
2788
  st.error("Selecione um arquivo Excel antes de continuar.")
2789
2789
  else:
@@ -2833,9 +2833,9 @@ def render_channels():
2833
2833
  "Descrição": "Preencher via YouTube" if not row.get("description") else "Da planilha",
2834
2834
  "Estado": "Já cadastrado" if find_duplicate_channel({"name": row.get("name"), "handle": row.get("handle"), "url": row.get("url")}, existing_spreadsheet_channels) else "Novo",
2835
2835
  })
2836
- st.dataframe(preview_rows, use_container_width=True, hide_index=True, height=min(420, 86 + 38 * len(preview_rows)))
2836
+ st.dataframe(preview_rows, width="stretch", hide_index=True, height=min(420, 86 + 38 * len(preview_rows)))
2837
2837
  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.")
2838
- if st.button("Cadastrar canais da planilha", type="primary", use_container_width=True, key="import_channel_spreadsheet"):
2838
+ if st.button("Cadastrar canais da planilha", type="primary", width="stretch", key="import_channel_spreadsheet"):
2839
2839
  created_names: list[str] = []
2840
2840
  skipped_names: list[str] = []
2841
2841
  spreadsheet_errors: list[str] = []
@@ -2933,7 +2933,7 @@ def render_channels():
2933
2933
  ]
2934
2934
  st.dataframe(
2935
2935
  registered_rows,
2936
- use_container_width=True,
2936
+ width="stretch",
2937
2937
  hide_index=True,
2938
2938
  height=420,
2939
2939
  column_config={
@@ -2974,7 +2974,7 @@ def render_channels():
2974
2974
  (st.success if account_status.ok else st.warning)(account_status.message)
2975
2975
  auth_cols = st.columns([1, 2])
2976
2976
  with auth_cols[0]:
2977
- if st.button("Autorizar conta Google", type="secondary", use_container_width=True, key="batch_authorize_account"):
2977
+ if st.button("Autorizar conta Google", type="secondary", width="stretch", key="batch_authorize_account"):
2978
2978
  result = authorize_youtube_batch_account(selected_account, STORAGE)
2979
2979
  (st.success if result.ok else st.error)(result.message)
2980
2980
  if result.ok:
@@ -2982,7 +2982,7 @@ def render_channels():
2982
2982
  with auth_cols[1]:
2983
2983
  st.caption("A autorização é feita no browser do sistema e fica guardada separadamente para esta conta.")
2984
2984
  batch_key = f"youtube_batch_channels_{selected_account_id}"
2985
- if st.button("Listar canais desta conta", type="primary", use_container_width=True, key="batch_list_channels"):
2985
+ if st.button("Listar canais desta conta", type="primary", width="stretch", key="batch_list_channels"):
2986
2986
  result = list_youtube_batch_channels(selected_account, STORAGE)
2987
2987
  st.session_state[batch_key] = result.data.get("channels", []) if result.ok else []
2988
2988
  st.session_state[f"{batch_key}_message"] = result.message
@@ -3012,7 +3012,7 @@ def render_channels():
3012
3012
  batch_language = st.selectbox("Idioma", list(LANGUAGE_CODES), index=list(LANGUAGE_CODES).index("pt"), format_func=language_label, key="batch_channel_language")
3013
3013
  with defaults_cols[3]:
3014
3014
  batch_style = st.selectbox("Estilo wide", ["Pexels/Pixabay", "full_ia", "Apenas Música"], key="batch_channel_style")
3015
- import_selected = st.form_submit_button("Cadastrar canais seleccionados", type="primary", use_container_width=True)
3015
+ import_selected = st.form_submit_button("Cadastrar canais seleccionados", type="primary", width="stretch")
3016
3016
  if import_selected:
3017
3017
  created_names = []
3018
3018
  skipped_names = []
@@ -3131,7 +3131,7 @@ def render_channels():
3131
3131
  with header_cols[5]:
3132
3132
  calculated_time, calculated_source, calculated_words = _channel_average_video_time(channel)
3133
3133
  average_video_time = st.text_input("Tempo Medio de Video", value=channel_video_time_value(channel), key=f"youtube_channel_average_video_time_{channel_id}", help=f"Referência: {calculated_words or 0} palavras · {calculated_source}.")
3134
- if st.button("Guardar tempo", key=f"save_youtube_channel_average_video_time_{channel_id}", use_container_width=True):
3134
+ if st.button("Guardar tempo", key=f"save_youtube_channel_average_video_time_{channel_id}", width="stretch"):
3135
3135
  if not valid_hhmm(average_video_time):
3136
3136
  st.error("Use o formato MM:SS, por exemplo 12:00.")
3137
3137
  else:
@@ -3139,7 +3139,7 @@ def render_channels():
3139
3139
  st.success("Tempo médio do canal YouTube guardado.")
3140
3140
  st.rerun()
3141
3141
  with header_cols[6]:
3142
- if st.button("↻", key=f"refresh_youtube_metrics_{channel_id}", help="Actualizar Inscritos, Vídeos e Visualizações", use_container_width=True):
3142
+ if st.button("↻", key=f"refresh_youtube_metrics_{channel_id}", help="Actualizar Inscritos, Vídeos e Visualizações", width="stretch"):
3143
3143
  with st.spinner("A actualizar métricas YouTube…"):
3144
3144
  refreshed, message = _refresh_youtube_channel_metrics(channel, youtube)
3145
3145
  (st.success if refreshed else st.warning)(message)
@@ -3151,12 +3151,12 @@ def render_channels():
3151
3151
  st.rerun()
3152
3152
  action_cols = st.columns(2)
3153
3153
  with action_cols[0]:
3154
- if st.button("Editar", key=f"edit_channel_button_{channel_id}", use_container_width=True):
3154
+ if st.button("Editar", key=f"edit_channel_button_{channel_id}", width="stretch"):
3155
3155
  st.session_state[edit_key] = True
3156
3156
  st.rerun()
3157
3157
  with action_cols[1]:
3158
3158
  delete_key = f"delete_pending_{channel_id}"
3159
- if st.button("Apagar", key=f"delete_{channel_id}", use_container_width=True):
3159
+ if st.button("Apagar", key=f"delete_{channel_id}", width="stretch"):
3160
3160
  st.session_state[delete_key] = True
3161
3161
  st.rerun()
3162
3162
  if st.session_state.get(delete_key):
@@ -3184,17 +3184,17 @@ def render_channels():
3184
3184
  block_cols = st.columns(4, gap="small")
3185
3185
  with block_cols[0]:
3186
3186
  st.markdown(f"**Blueprint Padrão**\n\n{summary['name']}")
3187
- if st.button("Editar Blueprint", key=f"edit_prompts_{channel_id}", use_container_width=True):
3187
+ if st.button("Editar Blueprint", key=f"edit_prompts_{channel_id}", width="stretch"):
3188
3188
  st.session_state[edit_key] = True
3189
3189
  st.rerun()
3190
3190
  with block_cols[1]:
3191
3191
  st.markdown(f"**Nicho**\n\n{channel_niche_label(channel)}")
3192
- if st.button("Editar Nicho", key=f"edit_niche_{channel_id}", use_container_width=True):
3192
+ if st.button("Editar Nicho", key=f"edit_niche_{channel_id}", width="stretch"):
3193
3193
  st.session_state[edit_key] = True
3194
3194
  st.rerun()
3195
3195
  with block_cols[2]:
3196
3196
  st.markdown(f"**Narrador/Voz Padrão**\n\n{summary['voice'] or 'Sem voz padrão'}")
3197
- if st.button("Configurar Narrador/Voz", key=f"edit_voice_{channel_id}", use_container_width=True):
3197
+ if st.button("Configurar Narrador/Voz", key=f"edit_voice_{channel_id}", width="stretch"):
3198
3198
  st.session_state[edit_key] = True
3199
3199
  st.rerun()
3200
3200
  with block_cols[3]:
@@ -3216,7 +3216,7 @@ def render_channels():
3216
3216
  key=f"channel_delegated_session_id_{channel_id}",
3217
3217
  help="Identificador individual usado pelo Upload directo deste canal. Não é partilhado com outros canais nem mostrado nos diagnósticos.",
3218
3218
  )
3219
- save_channel_direct_credentials = st.form_submit_button("Guardar conta Google e DELEGATED_SESSION_ID", type="primary", use_container_width=True)
3219
+ save_channel_direct_credentials = st.form_submit_button("Guardar conta Google e DELEGATED_SESSION_ID", type="primary", width="stretch")
3220
3220
  selected_channel_account = youtube_accounts_by_id.get(channel_account_id)
3221
3221
  if selected_channel_account:
3222
3222
  selected_account_status = document_status(STORAGE, selected_channel_account, channel, settings, channels)
@@ -3421,7 +3421,7 @@ def render_video_from_draft() -> None:
3421
3421
  st.caption(f"Faltam: {', '.join(still_missing)}")
3422
3422
  elif not missing_sections:
3423
3423
  st.caption("Este roteiro será usado directamente, sem regenerar o conteúdo editorial guardado.")
3424
- if st.button(action_label, type="primary", use_container_width=True, key="new_video_resume_submit", icon=":material/movie:"):
3424
+ if st.button(action_label, type="primary", width="stretch", key="new_video_resume_submit", icon=":material/movie:"):
3425
3425
  if still_missing:
3426
3426
  st.error(f"Complete as configurações seleccionadas: {', '.join(still_missing)}.")
3427
3427
  elif not selected_channel.get("id"):
@@ -3476,7 +3476,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3476
3476
  key=f"{prefix}_general_context",
3477
3477
  placeholder="Opcional: campanha, época, evento ou restrição editorial comum. O tema final será individual por canal.",
3478
3478
  )
3479
- if st.button("Gerar tópicos individuais para todos os canais", key=f"{prefix}_generate_general_topics", use_container_width=True):
3479
+ if st.button("Gerar tópicos individuais para todos os canais", key=f"{prefix}_generate_general_topics", width="stretch"):
3480
3480
  settings = read_json("settings.json", {})
3481
3481
  generated_topics: dict[str, dict[str, Any]] = {}
3482
3482
  errors: list[str] = []
@@ -3580,7 +3580,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3580
3580
  st.warning("Ainda não existem músicas em storage/music. Escolha Carregar ficheiro ou Criar via Suno API.")
3581
3581
  elif music_source == "Carregar ficheiro":
3582
3582
  uploaded_music = st.file_uploader("Carregar música", type=["mp3", "mpeg", "wav", "m4a", "aac", "flac", "ogg"], key=f"{prefix}_music_upload")
3583
- if uploaded_music and st.button("Guardar música local", key=f"{prefix}_music_store", use_container_width=True):
3583
+ if uploaded_music and st.button("Guardar música local", key=f"{prefix}_music_store", width="stretch"):
3584
3584
  try:
3585
3585
  stored_music = store_music_file(uploaded_music.name, uploaded_music.getvalue())
3586
3586
  st.session_state[f"{prefix}_music_path"] = str(stored_music)
@@ -3591,7 +3591,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3591
3591
  else:
3592
3592
  suno_prompt = st.text_area("Prompt musical Suno", placeholder="Instrumental cinematográfico, calmo, sem voz...", key=f"{prefix}_suno_prompt")
3593
3593
  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")
3594
- if st.button("Solicitar música no Suno", key=f"{prefix}_suno_request", use_container_width=True):
3594
+ if st.button("Solicitar música no Suno", key=f"{prefix}_suno_request", width="stretch"):
3595
3595
  suno_result = request_suno_generation(read_json("settings.json", {}), suno_prompt, suno_title)
3596
3596
  (st.success if suno_result["ok"] else st.error)(suno_result["message"])
3597
3597
  if suno_result["ok"]:
@@ -3612,7 +3612,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3612
3612
  existing_topics = st.session_state.get(f"{prefix}_general_topics", {})
3613
3613
  payloads = dict(st.session_state.get(f"{prefix}_general_payloads", {}))
3614
3614
  with st.expander("Gerar Thumbnail com IA", expanded=False):
3615
- if st.button("Gerar Thumbnail com IA para todos os vídeos", key=f"{prefix}_generate_general_creative", use_container_width=True):
3615
+ if st.button("Gerar Thumbnail com IA para todos os vídeos", key=f"{prefix}_generate_general_creative", width="stretch"):
3616
3616
  settings = read_json("settings.json", {})
3617
3617
  new_payloads: dict[str, dict[str, Any]] = {}
3618
3618
  errors: list[str] = []
@@ -3653,7 +3653,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3653
3653
  payload["thumbnail_text"] = variant.get("overlay_text", "")
3654
3654
  st.caption(f"{variant.get('composition', '')} · {variant.get('color_palette', '')}")
3655
3655
  thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
3656
- if st.button("Gerar imagem com Nano Banana", key=f"{prefix}_general_generate_thumbnail_{channel['id']}", use_container_width=True):
3656
+ if st.button("Gerar imagem com Nano Banana", key=f"{prefix}_general_generate_thumbnail_{channel['id']}", width="stretch"):
3657
3657
  try:
3658
3658
  thumbnail_path = str(
3659
3659
  generate_thumbnail_image(
@@ -3675,7 +3675,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3675
3675
  except ThumbnailGenerationError as exc:
3676
3676
  st.error(str(exc))
3677
3677
  if thumbnail_path and Path(thumbnail_path).is_file():
3678
- st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", use_container_width=True)
3678
+ st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", width="stretch")
3679
3679
  payload["thumbnail_path"] = thumbnail_path
3680
3680
  payload["thumbnail_status"] = "generated"
3681
3681
  else:
@@ -3684,7 +3684,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3684
3684
  else:
3685
3685
  with st.expander("Gerar Thumbnail com IA", expanded=False):
3686
3686
  topic_for_thumbnail = str(generation_settings.get("video_subject") or "").strip()
3687
- if st.button("Gerar Thumbnail com IA", key=f"{prefix}_generate_creative", use_container_width=True):
3687
+ if st.button("Gerar Thumbnail com IA", key=f"{prefix}_generate_creative", width="stretch"):
3688
3688
  if selected_one is None:
3689
3689
  st.error("Seleccione primeiro um canal.")
3690
3690
  elif not topic_for_thumbnail:
@@ -3719,7 +3719,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3719
3719
  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")
3720
3720
  payload["title"] = selected_title
3721
3721
  with st.expander(f"Ver {len(title_options)} candidatos de título"):
3722
- st.dataframe(payload.get("title_candidates", []), use_container_width=True, hide_index=True)
3722
+ st.dataframe(payload.get("title_candidates", []), width="stretch", hide_index=True)
3723
3723
  variants = payload.get("thumbnail_variants", [])
3724
3724
  if variants:
3725
3725
  labels = [f"{idx + 1}. {item.get('concept', 'Variante')}" for idx, item in enumerate(variants)]
@@ -3732,7 +3732,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3732
3732
  st.caption(f"Composição: {variant.get('composition', '')} · Cores: {variant.get('color_palette', '')}")
3733
3733
  st.code(variant.get("image_prompt", ""), language="text")
3734
3734
  thumbnail_path = str(variant.get("image_path") or payload.get("thumbnail_path") or "").strip()
3735
- if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", use_container_width=True):
3735
+ if st.button("Gerar imagem da thumbnail com Nano Banana", key=f"{prefix}_generate_thumbnail_image", width="stretch"):
3736
3736
  try:
3737
3737
  thumbnail_path = str(
3738
3738
  generate_thumbnail_image(
@@ -3754,7 +3754,7 @@ def render_new_video(page_title: str = "Criação de Vídeos", prefix: str = "ne
3754
3754
  except ThumbnailGenerationError as exc:
3755
3755
  st.error(str(exc))
3756
3756
  if thumbnail_path and Path(thumbnail_path).is_file():
3757
- st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", use_container_width=True)
3757
+ st.image(thumbnail_path, caption="Thumbnail gerada pelo Nano Banana", width="stretch")
3758
3758
  payload["thumbnail_path"] = thumbnail_path
3759
3759
  payload["thumbnail_status"] = "generated"
3760
3760
  else:
@@ -3911,7 +3911,7 @@ def render_music_creation():
3911
3911
  key="music_task_prompt",
3912
3912
  height=300,
3913
3913
  )
3914
- if st.button("Gerar campos musicais com IA", key="music_task_generate_fields", use_container_width=True, icon=":material/auto_awesome:"):
3914
+ if st.button("Gerar campos musicais com IA", key="music_task_generate_fields", width="stretch", icon=":material/auto_awesome:"):
3915
3915
  try:
3916
3916
  with st.spinner("A criar título, letra e prompt musical originais…"):
3917
3917
  generated = generate_music_fields(
@@ -3931,7 +3931,7 @@ def render_music_creation():
3931
3931
  models = ["lyria-3-clip-preview", "lyria-3-pro-preview"]
3932
3932
  configured = str(settings.get("lyria_model") or models[0])
3933
3933
  lyria_model = st.selectbox("Modelo Lyria", models, index=models.index(configured) if configured in models else 0, key="music_task_lyria_model")
3934
- if st.button("Gerar Música", key="music_task_submit", type="primary", use_container_width=True, icon=":material/music_note:"):
3934
+ if st.button("Gerar Música", key="music_task_submit", type="primary", width="stretch", icon=":material/music_note:"):
3935
3935
  try:
3936
3936
  task = create_music_task(
3937
3937
  provider_label,
@@ -3959,7 +3959,7 @@ def render_custom_music_voices() -> None:
3959
3959
  settings = read_json("settings.json", {})
3960
3960
  refresh_col, cache_col = st.columns([1, 2])
3961
3961
  with refresh_col:
3962
- refresh = st.button("Actualizar vozes", type="primary", use_container_width=True, key="elevenlabs_refresh_voices")
3962
+ refresh = st.button("Actualizar vozes", type="primary", width="stretch", key="elevenlabs_refresh_voices")
3963
3963
  try:
3964
3964
  voices, metadata = fetch_personal_voices(settings, force=refresh)
3965
3965
  if metadata.get("source") == "cache":
@@ -3990,7 +3990,7 @@ def render_custom_music_voices() -> None:
3990
3990
  st.caption("Categoria: personal")
3991
3991
  st.caption("Labels: " + (", ".join(f"{key}: {value}" for key, value in labels.items()) if labels else "não disponíveis"))
3992
3992
  with voice_cols[2]:
3993
- if st.button("Testar voz", key=f"elevenlabs_test_voice_{voice['voice_id']}", use_container_width=True):
3993
+ if st.button("Testar voz", key=f"elevenlabs_test_voice_{voice['voice_id']}", width="stretch"):
3994
3994
  try:
3995
3995
  preview = synthesize_preview(test_text, "elevenlabs", voice["voice_id"], settings)
3996
3996
  st.audio(str(preview), format="audio/mpeg")
@@ -4071,9 +4071,9 @@ def render_scripts():
4071
4071
  brief = str(script_settings.get("video_script") or "").strip() or title
4072
4072
  generate_col, clear_col = st.columns([1.4, 1])
4073
4073
  with generate_col:
4074
- generate_clicked = st.button("Gerar com IA a partir do Blueprint", type="primary", use_container_width=True, key="generate_script_document")
4074
+ generate_clicked = st.button("Gerar com IA a partir do Blueprint", type="primary", width="stretch", key="generate_script_document")
4075
4075
  with clear_col:
4076
- clear_clicked = st.button("Limpar rascunho", use_container_width=True, key="clear_script_document")
4076
+ clear_clicked = st.button("Limpar rascunho", width="stretch", key="clear_script_document")
4077
4077
  if clear_clicked:
4078
4078
  for key in ("script_draft", "script_draft_title", "script_draft_content", "script_draft_summary", "script_draft_keywords"):
4079
4079
  st.session_state.pop(key, None)
@@ -4126,7 +4126,7 @@ def render_scripts():
4126
4126
  draft_summary = st.text_input("Resumo", key="script_draft_summary")
4127
4127
  draft_keywords = st.text_area("Palavras-chave", height=90, key="script_draft_keywords")
4128
4128
  draft_content = st.text_area("Conteúdo guardado", height=460, key="script_draft_content")
4129
- if st.button("Guardar documento no storage", type="primary", use_container_width=True, key="save_script_document"):
4129
+ if st.button("Guardar documento no storage", type="primary", width="stretch", key="save_script_document"):
4130
4130
  try:
4131
4131
  record = save_script_document(
4132
4132
  {
@@ -4200,7 +4200,7 @@ def render_niche_finder():
4200
4200
  start_date = st.date_input("Data inicial", value=default_start, min_value=date(2020, 1, 1), max_value=default_end, key="niche_start_date")
4201
4201
  end_date = st.date_input("Data final", value=default_end, min_value=date(2020, 1, 1), max_value=default_end, key="niche_end_date")
4202
4202
  tags_text = st.text_input("Tags opcionais", key="niche_tags_text", placeholder="Ex.: history, facts, documentary")
4203
- analyse = st.form_submit_button("Analisar Nichos", type="primary", use_container_width=True)
4203
+ analyse = st.form_submit_button("Analisar Nichos", type="primary", width="stretch")
4204
4204
 
4205
4205
  current_parameters = {
4206
4206
  "n_clusters": n_clusters,
@@ -4262,7 +4262,7 @@ def render_niche_finder():
4262
4262
  cluster_table = cluster_table[cluster_table["palavras"].str.contains(keyword.strip(), case=False, na=False)]
4263
4263
  tab_clusters, tab_rules, tab_data = render_localized_tabs(["Clusters encontrados", "Regras de associação", "Dados analisados"])
4264
4264
  with tab_clusters:
4265
- st.dataframe(cluster_table, use_container_width=True, hide_index=True)
4265
+ st.dataframe(cluster_table, width="stretch", hide_index=True)
4266
4266
  if not points.empty:
4267
4267
  try:
4268
4268
  import plotly.express as px
@@ -4270,19 +4270,19 @@ def render_niche_finder():
4270
4270
  hover = [column for column in ["title", "channel_name", "country", "view_count", "engagement_rate"] if column in points.columns]
4271
4271
  figure = px.scatter(points, x="x", y="y", color="cluster", hover_data=hover, title="Distribuição dos clusters")
4272
4272
  figure.update_layout(legend_title_text="Cluster")
4273
- st.plotly_chart(figure, use_container_width=True)
4273
+ st.plotly_chart(figure, width="stretch")
4274
4274
  except ImportError:
4275
4275
  st.error("A visualização da análise não está disponível nesta instalação.")
4276
4276
  with tab_rules:
4277
4277
  if rules_table.empty:
4278
4278
  st.info("Não foram encontradas regras com os filtros actuais. Reduza o suporte mínimo ou escolha outro filtro.")
4279
4279
  else:
4280
- st.dataframe(rules_table, use_container_width=True, hide_index=True)
4280
+ st.dataframe(rules_table, width="stretch", hide_index=True)
4281
4281
  if not items_table.empty:
4282
4282
  st.subheader("Itemsets frequentes")
4283
- st.dataframe(items_table, use_container_width=True, hide_index=True)
4283
+ st.dataframe(items_table, width="stretch", hide_index=True)
4284
4284
  with tab_data:
4285
- st.dataframe(results["raw_data"], use_container_width=True, hide_index=True)
4285
+ st.dataframe(results["raw_data"], width="stretch", hide_index=True)
4286
4286
 
4287
4287
 
4288
4288
  def render_niche_finder_apify():
@@ -4331,7 +4331,7 @@ def render_niche_finder_apify():
4331
4331
  download_subtitles = st.checkbox("Descarregar legendas", value=True, key="niche_apify_download_subtitles")
4332
4332
  with filter_cols[3]:
4333
4333
  has_cc = st.checkbox("Apenas vídeos com CC", value=False, key="niche_apify_has_cc")
4334
- analyse_apify = st.form_submit_button("Pesquisar no Apify", type="primary", use_container_width=True)
4334
+ analyse_apify = st.form_submit_button("Pesquisar no Apify", type="primary", width="stretch")
4335
4335
 
4336
4336
  if analyse_apify:
4337
4337
  try:
@@ -4405,17 +4405,17 @@ def render_niche_finder_apify():
4405
4405
  result_frame = pd.DataFrame(visible_results)
4406
4406
  display_columns = ["title", "channel_name", "duration", "view_count", "subscriber_count", "comments_count", "vsc_ratio", "url", "transcript_status", "summary_status", "summary"]
4407
4407
  display_columns = [column for column in display_columns if column in result_frame.columns]
4408
- st.dataframe(result_frame[display_columns], use_container_width=True, hide_index=True)
4408
+ st.dataframe(result_frame[display_columns], width="stretch", hide_index=True)
4409
4409
  export_frame = result_frame.drop(columns=["transcript"], errors="ignore")
4410
4410
  export_json = export_frame.to_json(orient="records", force_ascii=False, indent=2)
4411
4411
  export_csv = export_frame.to_csv(index=False).encode("utf-8")
4412
4412
  export_cols = st.columns(2)
4413
4413
  with export_cols[0]:
4414
- 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")
4414
+ 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")
4415
4415
  with export_cols[1]:
4416
- 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")
4416
+ 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")
4417
4417
  except ImportError:
4418
- st.dataframe(visible_results, use_container_width=True, hide_index=True)
4418
+ st.dataframe(visible_results, width="stretch", hide_index=True)
4419
4419
 
4420
4420
  last_run = st.session_state.get("niche_apify_last_run", {})
4421
4421
  if last_run:
@@ -4457,7 +4457,7 @@ def render_media_download():
4457
4457
  with option_cols[2]:
4458
4458
  embed_metadata = st.checkbox("Incorporar metadados", value=False, key="media_download_embed_metadata")
4459
4459
  st.caption("Playlist desactivada por padrão para evitar downloads acidentais em massa.")
4460
- start_download = st.form_submit_button("Iniciar download", type="primary", use_container_width=True)
4460
+ start_download = st.form_submit_button("Iniciar download", type="primary", width="stretch")
4461
4461
 
4462
4462
  if start_download:
4463
4463
  progress = st.progress(0, text="A preparar o download…")
@@ -4571,7 +4571,7 @@ def render_cuts():
4571
4571
  with url_tab:
4572
4572
  url_value = st.text_input("URL directa do vídeo", placeholder="https://exemplo.com/video.mp4", key="cuts_video_url")
4573
4573
  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.")
4574
- if st.button("Descarregar vídeo", key="cuts_download_url", use_container_width=True):
4574
+ if st.button("Descarregar vídeo", key="cuts_download_url", width="stretch"):
4575
4575
  try:
4576
4576
  source_path = download_direct_video_url(url_value)
4577
4577
  st.session_state["cuts_source_path"] = str(source_path)
@@ -4586,7 +4586,7 @@ def render_cuts():
4586
4586
  else:
4587
4587
  generated_labels = [f"{path.name} — {path}" for path in generated_paths]
4588
4588
  selected_generated = st.selectbox("Vídeo gerado", range(len(generated_paths)), format_func=lambda index: generated_labels[index], key="cuts_generated_index")
4589
- if st.button("Usar vídeo seleccionado", key="cuts_use_generated", use_container_width=True):
4589
+ if st.button("Usar vídeo seleccionado", key="cuts_use_generated", width="stretch"):
4590
4590
  source_path = generated_paths[selected_generated]
4591
4591
  st.session_state["cuts_source_path"] = str(source_path)
4592
4592
  st.session_state["cuts_source_label"] = f"Pipeline · {source_path.name}"
@@ -4598,7 +4598,7 @@ def render_cuts():
4598
4598
  else:
4599
4599
  folder_labels = [f"{path.name} — {path}" for path in folder_paths]
4600
4600
  selected_folder = st.selectbox("Vídeo da pasta", range(len(folder_paths)), format_func=lambda index: folder_labels[index], key="cuts_folder_index")
4601
- if st.button("Usar vídeo da pasta", key="cuts_use_folder", use_container_width=True):
4601
+ if st.button("Usar vídeo da pasta", key="cuts_use_folder", width="stretch"):
4602
4602
  source_path = folder_paths[selected_folder]
4603
4603
  st.session_state["cuts_source_path"] = str(source_path)
4604
4604
  st.session_state["cuts_source_label"] = f"Pasta local · {source_path.name}"
@@ -4657,7 +4657,7 @@ def render_cuts():
4657
4657
  generate_button = st.button(
4658
4658
  "Gerar Clips",
4659
4659
  type="primary",
4660
- use_container_width=True,
4660
+ width="stretch",
4661
4661
  disabled=not (source_path and source_path.is_file() and rights_confirmed),
4662
4662
  key="cuts_generate_button",
4663
4663
  )
@@ -4706,13 +4706,13 @@ def render_cuts():
4706
4706
  with st.container(border=True):
4707
4707
  st.caption(f"Clip {clip.get('index', '—')} · {float(clip.get('duration', 0)):.1f}s")
4708
4708
  st.video(str(clip["path"]))
4709
- 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)
4709
+ 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")
4710
4710
  try:
4711
4711
  _, archive_bytes = zip_cut_run(last_run)
4712
- 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)
4712
+ 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")
4713
4713
  except CutsError as exc:
4714
4714
  st.warning(str(exc))
4715
- 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)
4715
+ 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")
4716
4716
  elif last_run.get("error"):
4717
4717
  st.error(last_run["error"])
4718
4718
 
@@ -4721,7 +4721,7 @@ def render_cuts():
4721
4721
  with st.expander("Histórico do Clip Generator"):
4722
4722
  st.dataframe(
4723
4723
  [{"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]],
4724
- use_container_width=True,
4724
+ width="stretch",
4725
4725
  hide_index=True,
4726
4726
  )
4727
4727
 
@@ -4792,7 +4792,7 @@ def render_python_editor():
4792
4792
  width = st.number_input("Largura", min_value=16, max_value=7680, value=1280, step=2, key="python_editor_width")
4793
4793
  with resize_cols[1]:
4794
4794
  height = st.number_input("Altura", min_value=16, max_value=7680, value=720, step=2, key="python_editor_height")
4795
- apply_operation = st.form_submit_button(f"Aplicar: {operation}", type="primary", use_container_width=True)
4795
+ apply_operation = st.form_submit_button(f"Aplicar: {operation}", type="primary", width="stretch")
4796
4796
  if apply_operation:
4797
4797
  try:
4798
4798
  ffmpeg_path = read_json("settings.json", {}).get("ffmpeg_path", "")
@@ -4828,7 +4828,7 @@ def render_python_editor():
4828
4828
  records = list_python_editor_records()
4829
4829
  if records:
4830
4830
  with st.expander("Histórico do Editor Python"):
4831
- 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)
4831
+ 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)
4832
4832
 
4833
4833
  with code_tab:
4834
4834
  st.subheader("Scripts Python locais")
@@ -5168,7 +5168,7 @@ def render_videos():
5168
5168
  file_name=video_file.name,
5169
5169
  mime='video/mp4',
5170
5170
  key=f"pipeline_video_download_{task['id']}",
5171
- use_container_width=True,
5171
+ width="stretch",
5172
5172
  )
5173
5173
  elif video_path:
5174
5174
  st.caption(f'Vídeo registado: {video_path}')
@@ -5184,11 +5184,11 @@ def render_videos():
5184
5184
  state = str(task.get("state") or "")
5185
5185
  start_col, stop_col = st.columns(2)
5186
5186
  with start_col:
5187
- if st.button("Start", key=f"automation_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
5187
+ if st.button("Start", key=f"automation_start_{task['id']}", width="stretch", disabled=state not in {"to_do", "blocked", "failed"}):
5188
5188
  _start_pipeline_task(str(task["id"]), state)
5189
5189
  st.rerun()
5190
5190
  with stop_col:
5191
- if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
5191
+ if st.button("Stop", key=f"automation_stop_{task['id']}", width="stretch", disabled=state != "doing"):
5192
5192
  transition_task(task["id"], "blocked")
5193
5193
  st.rerun()
5194
5194
 
@@ -5255,7 +5255,7 @@ def _render_music_file_card(record: dict[str, Any]) -> None:
5255
5255
  file_name=music_path.name,
5256
5256
  mime=mimetypes.guess_type(music_path.name)[0] or "audio/mpeg",
5257
5257
  key=f"music_backlog_download_{record_id}",
5258
- use_container_width=True,
5258
+ width="stretch",
5259
5259
  )
5260
5260
  edit_key = _render_library_name_editor("music", music_path, title)
5261
5261
  with st.columns([0.2, 0.8])[0]:
@@ -5282,11 +5282,11 @@ def _render_music_file_card(record: dict[str, Any]) -> None:
5282
5282
  state = str(record.get("state") or "")
5283
5283
  start_col, stop_col = st.columns(2)
5284
5284
  with start_col:
5285
- if st.button("Start", key=f"music_backlog_start_{record_id}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
5285
+ if st.button("Start", key=f"music_backlog_start_{record_id}", width="stretch", disabled=state not in {"to_do", "blocked", "failed"}):
5286
5286
  run_music_task(record_id, read_json("settings.json", {}))
5287
5287
  st.rerun()
5288
5288
  with stop_col:
5289
- if st.button("Stop", key=f"music_backlog_stop_{record_id}", use_container_width=True, disabled=state != "doing"):
5289
+ if st.button("Stop", key=f"music_backlog_stop_{record_id}", width="stretch", disabled=state != "doing"):
5290
5290
  transition_music_task(record_id, "blocked")
5291
5291
  st.rerun()
5292
5292
 
@@ -5304,7 +5304,7 @@ def _render_lyrics_card(record: dict[str, Any]) -> None:
5304
5304
  st.write(f"**{title}**")
5305
5305
  st.caption("Lyrics")
5306
5306
  with download_col:
5307
- st.download_button("Descarregar lyrics", data=path.read_bytes(), file_name=path.name, mime="text/markdown", key=f"music_lyrics_download_{record_id}", use_container_width=True)
5307
+ st.download_button("Descarregar lyrics", data=path.read_bytes(), file_name=path.name, mime="text/markdown", key=f"music_lyrics_download_{record_id}", width="stretch")
5308
5308
  with edit_col:
5309
5309
  edit_key = _render_library_name_editor("music_lyrics", path, title)
5310
5310
  _render_card_pencil(edit_key)
@@ -5321,7 +5321,7 @@ def render_music_backlog() -> None:
5321
5321
  with music_tab:
5322
5322
  with st.form("music_backlog_upload_form", clear_on_submit=True):
5323
5323
  uploaded_music = st.file_uploader("Adicionar músicas à pasta acima", type=["mp3", "mpeg", "wav", "m4a", "aac", "flac", "ogg"], accept_multiple_files=True, key="music_backlog_upload")
5324
- upload_music = st.form_submit_button("Guardar músicas", type="primary", use_container_width=True)
5324
+ upload_music = st.form_submit_button("Guardar músicas", type="primary", width="stretch")
5325
5325
  if upload_music and uploaded_music:
5326
5326
  for uploaded in uploaded_music:
5327
5327
  store_music_file(uploaded.name, uploaded.getvalue())
@@ -5345,7 +5345,7 @@ def render_music_backlog() -> None:
5345
5345
  st.caption(f"As lyrics são guardadas em `{script_storage_path()}` no formato Markdown local do Thunderbolt.")
5346
5346
  with st.form("music_backlog_lyrics_upload_form", clear_on_submit=True):
5347
5347
  uploaded_lyrics = st.file_uploader("Adicionar lyrics", type=["md", "txt"], key="music_backlog_lyrics_upload")
5348
- upload_lyrics = st.form_submit_button("Guardar lyrics", use_container_width=True)
5348
+ upload_lyrics = st.form_submit_button("Guardar lyrics", width="stretch")
5349
5349
  if upload_lyrics and uploaded_lyrics:
5350
5350
  content = uploaded_lyrics.getvalue().decode("utf-8", errors="replace").strip()
5351
5351
  if content:
@@ -5403,7 +5403,7 @@ def render_thumbnails():
5403
5403
  image_path = record.get("image_path")
5404
5404
  if image_path and image_path.is_file():
5405
5405
  # Pass bytes to Streamlit and avoid the deprecated
5406
- # ``use_container_width`` argument. On Windows the
5406
+ # legacy full-width argument. On Windows the
5407
5407
  # deprecation warning can be emitted after the bootstrap
5408
5408
  # stream has been closed, which masks the real page with
5409
5409
  # ``ValueError: I/O operation on closed file``.
@@ -5415,7 +5415,7 @@ def render_thumbnails():
5415
5415
  file_name=image_path.name,
5416
5416
  mime="image/jpeg" if image_path.suffix.lower() in {".jpg", ".jpeg"} else "image/png",
5417
5417
  key=f"thumbnail_download_{task_id}_{record['variant_index']}",
5418
- use_container_width=True,
5418
+ width="stretch",
5419
5419
  )
5420
5420
  else:
5421
5421
  st.markdown("### Sem imagem")
@@ -5435,7 +5435,7 @@ def render_thumbnails():
5435
5435
  "Refazer Prompt Thumb",
5436
5436
  key=f"regenerate_thumbnail_{task_id}",
5437
5437
  icon=":material/refresh:",
5438
- use_container_width=True,
5438
+ width="stretch",
5439
5439
  disabled=not bool(record["title"] or record["topic"]),
5440
5440
  ):
5441
5441
  try:
@@ -5470,7 +5470,7 @@ def render_thumbnails():
5470
5470
  "Gerar Imagem",
5471
5471
  key=f"generate_thumbnail_image_{task_id}",
5472
5472
  icon=":material/image:",
5473
- use_container_width=True,
5473
+ width="stretch",
5474
5474
  disabled=not bool(record["prompt"]),
5475
5475
  ):
5476
5476
  try:
@@ -5494,7 +5494,7 @@ def render_thumbnails():
5494
5494
  "Refazer Prompt e Gerar Imagem",
5495
5495
  key=f"regenerate_thumbnail_prompt_{task_id}",
5496
5496
  icon=":material/auto_awesome:",
5497
- use_container_width=True,
5497
+ width="stretch",
5498
5498
  disabled=not bool(record["title"] or record["topic"]),
5499
5499
  ):
5500
5500
  try:
@@ -5525,7 +5525,7 @@ def render_thumbnails():
5525
5525
  "Refazer Lettering",
5526
5526
  key=f"regenerate_thumbnail_lettering_{task_id}",
5527
5527
  icon=":material/title:",
5528
- use_container_width=True,
5528
+ width="stretch",
5529
5529
  disabled=not bool(record.get("image_path") and record["image_path"].is_file()),
5530
5530
  ):
5531
5531
  try:
@@ -5617,7 +5617,7 @@ def _render_tiktok_automation_cards():
5617
5617
  file_name=_automation_download_name("Thumbnail9:16", task, thumbnail_path, ".png"),
5618
5618
  mime="image/png",
5619
5619
  key=f"tiktok_automation_download_thumbnail_{task_id}",
5620
- use_container_width=True,
5620
+ width="stretch",
5621
5621
  disabled=thumbnail_path is None,
5622
5622
  )
5623
5623
  with prompt_download_col:
@@ -5628,7 +5628,7 @@ def _render_tiktok_automation_cards():
5628
5628
  file_name=_automation_download_name("Thumbnail-Prompt", task, thumbnail_prompt_path, ".txt"),
5629
5629
  mime="application/json" if thumbnail_prompt_path else "text/plain",
5630
5630
  key=f"tiktok_automation_download_thumbnail_prompt_{task_id}",
5631
- use_container_width=True,
5631
+ width="stretch",
5632
5632
  disabled=thumbnail_prompt_path is None and not thumbnail_prompt,
5633
5633
  )
5634
5634
  with task_cols[1]:
@@ -5641,11 +5641,11 @@ def _render_tiktok_automation_cards():
5641
5641
  state = str(task.get("state") or "")
5642
5642
  start_col, stop_col, delete_col = st.columns(3)
5643
5643
  with start_col:
5644
- if st.button("Start", key=f"tiktok_automation_start_{task_id}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
5644
+ if st.button("Start", key=f"tiktok_automation_start_{task_id}", width="stretch", disabled=state not in {"to_do", "blocked", "failed"}):
5645
5645
  if _start_pipeline_task(task_id, state):
5646
5646
  st.rerun()
5647
5647
  with stop_col:
5648
- if st.button("Stop", key=f"tiktok_automation_stop_{task_id}", use_container_width=True, disabled=state != "doing"):
5648
+ if st.button("Stop", key=f"tiktok_automation_stop_{task_id}", width="stretch", disabled=state != "doing"):
5649
5649
  stop_task_by_user(task_id)
5650
5650
  st.rerun()
5651
5651
  script_download_col, video_download_col = st.columns(2, gap="small")
@@ -5656,7 +5656,7 @@ def _render_tiktok_automation_cards():
5656
5656
  file_name=_automation_download_name("Script", task, script_path, ".md"),
5657
5657
  mime="text/markdown",
5658
5658
  key=f"tiktok_automation_download_script_{task_id}",
5659
- use_container_width=True,
5659
+ width="stretch",
5660
5660
  disabled=script_path is None,
5661
5661
  )
5662
5662
  with video_download_col:
@@ -5666,7 +5666,7 @@ def _render_tiktok_automation_cards():
5666
5666
  file_name=_automation_download_name("Vídeo9:16", task, video_path, ".mp4"),
5667
5667
  mime="video/mp4",
5668
5668
  key=f"tiktok_automation_download_video_{task_id}",
5669
- use_container_width=True,
5669
+ width="stretch",
5670
5670
  disabled=video_path is None,
5671
5671
  )
5672
5672
  if st.button(
@@ -5674,7 +5674,7 @@ def _render_tiktok_automation_cards():
5674
5674
  key=f"tiktok_automation_remake_video_{task_id}",
5675
5675
  icon=":material/refresh:",
5676
5676
  type="primary",
5677
- use_container_width=True,
5677
+ width="stretch",
5678
5678
  disabled=state == "doing",
5679
5679
  help="Remonta apenas o vídeo, mantendo o mesmo roteiro, Blueprint/Prompt Master, tags, thumbnail, voz e artefactos já baixados.",
5680
5680
  ):
@@ -5682,14 +5682,14 @@ def _render_tiktok_automation_cards():
5682
5682
  st.rerun()
5683
5683
  with delete_col:
5684
5684
  confirm_delete_key = f"tiktok_automation_confirm_delete_{task_id}"
5685
- if st.button("Apagar", key=f"tiktok_automation_delete_{task_id}", use_container_width=True, disabled=state == "doing"):
5685
+ if st.button("Apagar", key=f"tiktok_automation_delete_{task_id}", width="stretch", disabled=state == "doing"):
5686
5686
  st.session_state[confirm_delete_key] = True
5687
5687
  st.rerun()
5688
5688
  if st.session_state.get(confirm_delete_key):
5689
5689
  st.warning("Remover este vídeo da fila? Os ficheiros de artefactos serão preservados.")
5690
5690
  confirm_col, cancel_col = st.columns(2)
5691
5691
  with confirm_col:
5692
- if st.button("Confirmar", key=f"tiktok_automation_confirm_delete_button_{task_id}", use_container_width=True, type="primary"):
5692
+ if st.button("Confirmar", key=f"tiktok_automation_confirm_delete_button_{task_id}", width="stretch", type="primary"):
5693
5693
  try:
5694
5694
  delete_task(task["id"])
5695
5695
  st.session_state.pop(confirm_delete_key, None)
@@ -5697,7 +5697,7 @@ def _render_tiktok_automation_cards():
5697
5697
  except ValueError as exc:
5698
5698
  st.error(str(exc))
5699
5699
  with cancel_col:
5700
- if st.button("Cancelar", key=f"tiktok_automation_cancel_delete_{task_id}", use_container_width=True):
5700
+ if st.button("Cancelar", key=f"tiktok_automation_cancel_delete_{task_id}", width="stretch"):
5701
5701
  st.session_state.pop(confirm_delete_key, None)
5702
5702
  st.rerun()
5703
5703
 
@@ -5758,7 +5758,7 @@ def render_tiktok_automation():
5758
5758
  with default_cols[4]:
5759
5759
  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}")
5760
5760
  with default_cols[5]:
5761
- if st.button("Guardar", key=f"tiktok_automation_save_{channel_id}", use_container_width=True, type="primary"):
5761
+ if st.button("Guardar", key=f"tiktok_automation_save_{channel_id}", width="stretch", type="primary"):
5762
5762
  if not valid_hhmm(schedule_time):
5763
5763
  st.error("Use o formato HH:MM, por exemplo 08:30.")
5764
5764
  elif not valid_hhmm(average_video_time):
@@ -5801,7 +5801,7 @@ def _render_youtube_automation_cards():
5801
5801
  file_name=_automation_download_name("Thumbnail", task, thumbnail_path, ".png"),
5802
5802
  mime="image/png",
5803
5803
  key=f"automation_download_thumbnail_{task['id']}",
5804
- use_container_width=True,
5804
+ width="stretch",
5805
5805
  disabled=thumbnail_path is None,
5806
5806
  )
5807
5807
  with prompt_download_col:
@@ -5812,7 +5812,7 @@ def _render_youtube_automation_cards():
5812
5812
  file_name=_automation_download_name("Thumbnail-Prompt", task, thumbnail_prompt_path, ".txt"),
5813
5813
  mime="application/json" if thumbnail_prompt_path else "text/plain",
5814
5814
  key=f"automation_download_thumbnail_prompt_{task['id']}",
5815
- use_container_width=True,
5815
+ width="stretch",
5816
5816
  disabled=thumbnail_prompt_path is None and not thumbnail_prompt,
5817
5817
  )
5818
5818
  with task_cols[1]:
@@ -5824,11 +5824,11 @@ def _render_youtube_automation_cards():
5824
5824
  state = str(task.get("state") or "")
5825
5825
  start_col, stop_col, delete_col = st.columns(3)
5826
5826
  with start_col:
5827
- if st.button("Start", key=f"automation_start_{task['id']}", use_container_width=True, disabled=state not in {"to_do", "blocked", "failed"}):
5827
+ if st.button("Start", key=f"automation_start_{task['id']}", width="stretch", disabled=state not in {"to_do", "blocked", "failed"}):
5828
5828
  if _start_pipeline_task(str(task["id"]), state):
5829
5829
  st.rerun()
5830
5830
  with stop_col:
5831
- if st.button("Stop", key=f"automation_stop_{task['id']}", use_container_width=True, disabled=state != "doing"):
5831
+ if st.button("Stop", key=f"automation_stop_{task['id']}", width="stretch", disabled=state != "doing"):
5832
5832
  stop_task_by_user(task["id"])
5833
5833
  st.rerun()
5834
5834
  script_download_col, video_download_col = st.columns(2, gap="small")
@@ -5839,7 +5839,7 @@ def _render_youtube_automation_cards():
5839
5839
  file_name=_automation_download_name("Script", task, script_path, ".md"),
5840
5840
  mime="text/markdown",
5841
5841
  key=f"automation_download_script_{task['id']}",
5842
- use_container_width=True,
5842
+ width="stretch",
5843
5843
  disabled=script_path is None,
5844
5844
  )
5845
5845
  with video_download_col:
@@ -5849,7 +5849,7 @@ def _render_youtube_automation_cards():
5849
5849
  file_name=_automation_download_name("Vídeo", task, video_path, ".mp4"),
5850
5850
  mime="video/mp4",
5851
5851
  key=f"automation_download_video_{task['id']}",
5852
- use_container_width=True,
5852
+ width="stretch",
5853
5853
  disabled=video_path is None,
5854
5854
  )
5855
5855
  if st.button(
@@ -5857,7 +5857,7 @@ def _render_youtube_automation_cards():
5857
5857
  key=f"automation_remake_video_{task['id']}",
5858
5858
  icon=":material/refresh:",
5859
5859
  type="primary",
5860
- use_container_width=True,
5860
+ width="stretch",
5861
5861
  disabled=state == "doing",
5862
5862
  help="Remonta apenas o vídeo, mantendo o mesmo roteiro, Blueprint/Prompt Master, tags, thumbnail, voz e artefactos já baixados.",
5863
5863
  ):
@@ -5865,14 +5865,14 @@ def _render_youtube_automation_cards():
5865
5865
  st.rerun()
5866
5866
  with delete_col:
5867
5867
  confirm_delete_key = f"automation_confirm_delete_{task['id']}"
5868
- if st.button("Apagar", key=f"automation_delete_{task['id']}", use_container_width=True, disabled=state == "doing"):
5868
+ if st.button("Apagar", key=f"automation_delete_{task['id']}", width="stretch", disabled=state == "doing"):
5869
5869
  st.session_state[confirm_delete_key] = True
5870
5870
  st.rerun()
5871
5871
  if st.session_state.get(confirm_delete_key):
5872
5872
  st.warning("Remover este vídeo da fila? Os ficheiros de artefactos serão preservados.")
5873
5873
  confirm_col, cancel_col = st.columns(2)
5874
5874
  with confirm_col:
5875
- if st.button("Confirmar", key=f"automation_confirm_delete_button_{task['id']}", use_container_width=True, type="primary"):
5875
+ if st.button("Confirmar", key=f"automation_confirm_delete_button_{task['id']}", width="stretch", type="primary"):
5876
5876
  try:
5877
5877
  delete_task(task["id"])
5878
5878
  st.session_state.pop(confirm_delete_key, None)
@@ -5880,7 +5880,7 @@ def _render_youtube_automation_cards():
5880
5880
  except ValueError as exc:
5881
5881
  st.error(str(exc))
5882
5882
  with cancel_col:
5883
- if st.button("Cancelar", key=f"automation_cancel_delete_{task['id']}", use_container_width=True):
5883
+ if st.button("Cancelar", key=f"automation_cancel_delete_{task['id']}", width="stretch"):
5884
5884
  st.session_state.pop(confirm_delete_key, None)
5885
5885
  st.rerun()
5886
5886
 
@@ -5961,7 +5961,7 @@ def render_automation():
5961
5961
  with control_cols[2]:
5962
5962
  enabled = st.toggle("Automação ligada", value=bool(channel.get("automation_on", False)), key=f"automation_on_{channel_id}")
5963
5963
  with control_cols[3]:
5964
- if st.button("Guardar", key=f"automation_save_{channel_id}", use_container_width=True, type="primary"):
5964
+ if st.button("Guardar", key=f"automation_save_{channel_id}", width="stretch", type="primary"):
5965
5965
  if not valid_hhmm(schedule_time):
5966
5966
  st.error("Use o formato HH:MM, por exemplo 08:30.")
5967
5967
  elif not valid_hhmm(average_video_time):
@@ -6076,7 +6076,7 @@ def _render_music_source(prefix: str, extensions: set[str]) -> str:
6076
6076
  type=sorted(extension.lstrip(".") for extension in extensions),
6077
6077
  key=f"{prefix}_music_file_upload",
6078
6078
  )
6079
- if uploaded is not None and st.button("Guardar música no storage local", key=f"{prefix}_music_store", use_container_width=True):
6079
+ if uploaded is not None and st.button("Guardar música no storage local", key=f"{prefix}_music_store", width="stretch"):
6080
6080
  try:
6081
6081
  stored = store_music_file(uploaded.name, uploaded.getvalue())
6082
6082
  selected_path = str(stored)
@@ -6149,7 +6149,7 @@ def _render_jewelmusic_upload_tab() -> None:
6149
6149
  base_url = st.text_input("Base URL", value=str(settings.get("jewelmusic_base_url") or "https://api.jewelmusic.com"))
6150
6150
  proxy_url = st.text_input("Proxy opcional", value=str(settings.get("jewelmusic_proxy_url") or ""), placeholder="http://127.0.0.1:8080")
6151
6151
  timeout_seconds = st.number_input("Timeout (segundos)", min_value=5, max_value=900, value=int(settings.get("jewelmusic_timeout_seconds", 120)), step=5)
6152
- save = st.form_submit_button("Guardar configuração JewelMusic", type="primary", use_container_width=True)
6152
+ save = st.form_submit_button("Guardar configuração JewelMusic", type="primary", width="stretch")
6153
6153
  if save:
6154
6154
  settings = _persist_music_upload_settings({
6155
6155
  "jewelmusic_enabled": bool(enabled),
@@ -6160,7 +6160,7 @@ def _render_jewelmusic_upload_tab() -> None:
6160
6160
  })
6161
6161
  st.success("Configuração JewelMusic guardada no storage local.")
6162
6162
  adapter = JewelMusicAdapter(settings)
6163
- if st.button("Testar conexão JewelMusic", key="jewelmusic_test", use_container_width=True):
6163
+ if st.button("Testar conexão JewelMusic", key="jewelmusic_test", width="stretch"):
6164
6164
  test_result = adapter.test_connection()
6165
6165
  (st.success if test_result.ok else st.error)(test_result.message)
6166
6166
  music_path = _render_music_source("jewelmusic", MUSIC_UPLOAD_EXTENSIONS)
@@ -6174,7 +6174,7 @@ def _render_jewelmusic_upload_tab() -> None:
6174
6174
  with metadata_cols[3]:
6175
6175
  year = st.text_input("Ano", key="jewelmusic_year")
6176
6176
  genre = st.text_input("Género", key="jewelmusic_genre")
6177
- if st.button("Enviar música para JewelMusic", type="primary", key="jewelmusic_upload", use_container_width=True, disabled=not bool(music_path)):
6177
+ if st.button("Enviar música para JewelMusic", type="primary", key="jewelmusic_upload", width="stretch", disabled=not bool(music_path)):
6178
6178
  result = JewelMusicAdapter(read_json("settings.json", {})).upload_track(music_path, title=title, artist=artist, album=album, year=year, genre=genre)
6179
6179
  _record_music_upload("JewelMusic", result, music_path=music_path, target={"artist": artist, "title": title})
6180
6180
  (st.success if result.ok else st.error)(result.message)
@@ -6209,7 +6209,7 @@ def _render_pushtunes_upload_tab() -> None:
6209
6209
  spotify_client_secret = st.text_input("Spotify Client Secret", value=str(settings.get("pushtunes_spotify_client_secret") or ""), type="password")
6210
6210
  spotify_redirect_uri = st.text_input("Spotify Redirect URI", value=str(settings.get("pushtunes_spotify_redirect_uri") or ""))
6211
6211
  timeout_seconds = st.number_input("Timeout Pushtunes (segundos)", min_value=30, max_value=3600, value=int(settings.get("pushtunes_timeout_seconds", 1800)), step=30)
6212
- save = st.form_submit_button("Guardar configuração Pushtunes", type="primary", use_container_width=True)
6212
+ save = st.form_submit_button("Guardar configuração Pushtunes", type="primary", width="stretch")
6213
6213
  if save:
6214
6214
  settings = _persist_music_upload_settings({
6215
6215
  "pushtunes_enabled": bool(enabled),
@@ -6233,17 +6233,17 @@ def _render_pushtunes_upload_tab() -> None:
6233
6233
  if source == "csv":
6234
6234
  st.caption("Pode carregar o CSV nesta página e usar o caminho guardado como origem Pushtunes.")
6235
6235
  csv_upload = st.file_uploader("Carregar CSV de origem Pushtunes", type=["csv"], key="pushtunes_csv_upload")
6236
- if csv_upload is not None and st.button("Guardar CSV Pushtunes no storage", key="pushtunes_csv_store", use_container_width=True):
6236
+ if csv_upload is not None and st.button("Guardar CSV Pushtunes no storage", key="pushtunes_csv_store", width="stretch"):
6237
6237
  stored_csv = _store_pushtunes_csv(csv_upload)
6238
6238
  settings = _persist_music_upload_settings({"pushtunes_csv_file": stored_csv})
6239
6239
  st.success(f"CSV guardado em `{stored_csv}`.")
6240
6240
  adapter = PushtunesAdapter(read_json("settings.json", {}))
6241
6241
  status = adapter.status()
6242
6242
  (st.success if status.ok else st.warning)(status.message)
6243
- if st.button("Validar instalação e configuração Pushtunes", key="pushtunes_test", use_container_width=True):
6243
+ if st.button("Validar instalação e configuração Pushtunes", key="pushtunes_test", width="stretch"):
6244
6244
  check = PushtunesAdapter(read_json("settings.json", {})).status()
6245
6245
  (st.success if check.ok else st.error)(check.message)
6246
- if st.button("Executar sincronização Pushtunes", type="primary", key="pushtunes_sync", use_container_width=True, disabled=not status.ok):
6246
+ if st.button("Executar sincronização Pushtunes", type="primary", key="pushtunes_sync", width="stretch", disabled=not status.ok):
6247
6247
  result = PushtunesAdapter(read_json("settings.json", {})).sync()
6248
6248
  _record_music_upload(f"Pushtunes ({adapter.source} → {adapter.target})", result, target={"source": adapter.source, "target": adapter.target, "operation": adapter.operation})
6249
6249
  (st.success if result.ok else st.error)(result.message)
@@ -6271,7 +6271,7 @@ def _render_ytmusicapi_upload_tab() -> None:
6271
6271
  auth_file = st.text_input("Caminho do browser.json", value=str(settings.get("ytmusicapi_auth_file") or ""))
6272
6272
  proxy_url = st.text_input("Proxy opcional", value=str(settings.get("ytmusicapi_proxy_url") or ""), placeholder="http://127.0.0.1:8080")
6273
6273
  timeout_seconds = st.number_input("Timeout (segundos)", min_value=30, max_value=900, value=int(settings.get("ytmusicapi_timeout_seconds", 240)), step=10)
6274
- save = st.form_submit_button("Guardar configuração ytmusicapi", type="primary", use_container_width=True)
6274
+ save = st.form_submit_button("Guardar configuração ytmusicapi", type="primary", width="stretch")
6275
6275
  if save:
6276
6276
  settings = _persist_music_upload_settings({
6277
6277
  "ytmusicapi_enabled": bool(enabled),
@@ -6281,18 +6281,18 @@ def _render_ytmusicapi_upload_tab() -> None:
6281
6281
  })
6282
6282
  st.success("Configuração ytmusicapi guardada no storage local.")
6283
6283
  auth_upload = st.file_uploader("Carregar browser.json", type=["json"], key="ytmusicapi_auth_upload")
6284
- if auth_upload is not None and st.button("Guardar browser.json no storage local", key="ytmusicapi_auth_store", use_container_width=True):
6284
+ if auth_upload is not None and st.button("Guardar browser.json no storage local", key="ytmusicapi_auth_store", width="stretch"):
6285
6285
  stored_auth = _store_ytmusicapi_auth(auth_upload)
6286
6286
  settings = _persist_music_upload_settings({"ytmusicapi_auth_file": stored_auth, "ytmusicapi_enabled": True})
6287
6287
  st.success(f"Ficheiro de autenticação guardado em `{stored_auth}`.")
6288
6288
  adapter = YTMusicApiAdapter(read_json("settings.json", {}))
6289
6289
  status = adapter.status()
6290
6290
  (st.success if status.ok else st.warning)(status.message)
6291
- if st.button("Testar autenticação ytmusicapi", key="ytmusicapi_test", use_container_width=True):
6291
+ if st.button("Testar autenticação ytmusicapi", key="ytmusicapi_test", width="stretch"):
6292
6292
  result = YTMusicApiAdapter(read_json("settings.json", {})).test_connection()
6293
6293
  (st.success if result.ok else st.error)(result.message)
6294
6294
  music_path = _render_music_source("ytmusicapi", YT_MUSIC_UPLOAD_EXTENSIONS)
6295
- 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):
6295
+ 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):
6296
6296
  result = YTMusicApiAdapter(read_json("settings.json", {})).upload_song(music_path)
6297
6297
  _record_music_upload("ytmusicapi / YouTube Music", result, music_path=music_path, target={"service": "youtube_music"})
6298
6298
  (st.success if result.ok else st.error)(result.message)
@@ -6324,7 +6324,7 @@ def _render_distrokid_upload_tab() -> None:
6324
6324
  last_name = st.text_input("Apelido", value=str(settings.get("distrokid_last_name") or ""))
6325
6325
  record_label = st.text_input("Record label", value=str(settings.get("distrokid_record_label") or ""))
6326
6326
  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.")
6327
- save = st.form_submit_button("Guardar configuração DistroKid", type="primary", use_container_width=True)
6327
+ save = st.form_submit_button("Guardar configuração DistroKid", type="primary", width="stretch")
6328
6328
  if save:
6329
6329
  _persist_music_upload_settings({
6330
6330
  "distrokid_enabled": bool(enabled), "distrokid_cookie": cookie.strip(), "distrokid_account": account.strip(),
@@ -6336,11 +6336,11 @@ def _render_distrokid_upload_tab() -> None:
6336
6336
  adapter = DistroKidAdapter(settings)
6337
6337
  status = adapter.status()
6338
6338
  (st.success if status.ok else st.warning)(status.message)
6339
- if st.button("Testar sessão DistroKid", key="distrokid_test", use_container_width=True):
6339
+ if st.button("Testar sessão DistroKid", key="distrokid_test", width="stretch"):
6340
6340
  result = DistroKidAdapter(read_json("settings.json", {})).test_connection()
6341
6341
  (st.success if result.ok else st.error)(result.message)
6342
6342
  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")
6343
- if uploaded_tracks and st.button("Guardar faixas no storage local", key="distrokid_store_tracks", use_container_width=True):
6343
+ if uploaded_tracks and st.button("Guardar faixas no storage local", key="distrokid_store_tracks", width="stretch"):
6344
6344
  stored_paths: list[str] = []
6345
6345
  for uploaded in uploaded_tracks:
6346
6346
  try:
@@ -6359,7 +6359,7 @@ def _render_distrokid_upload_tab() -> None:
6359
6359
  instrumental = st.checkbox("Instrumental", value=False, key=f"distrokid_track_instrumental_{index}")
6360
6360
  track_rows.append({"path": path, "title": track_title.strip() or Path(path).stem, "instrumental": instrumental})
6361
6361
  cover_upload = st.file_uploader("Capa do lançamento (opcional)", type=sorted(extension.lstrip(".") for extension in DISTROKID_COVER_EXTENSIONS), key="distrokid_cover_upload")
6362
- if cover_upload and st.button("Guardar capa no storage local", key="distrokid_store_cover", use_container_width=True):
6362
+ if cover_upload and st.button("Guardar capa no storage local", key="distrokid_store_cover", width="stretch"):
6363
6363
  try:
6364
6364
  st.session_state["distrokid_cover_path"] = _store_distrokid_cover(cover_upload)
6365
6365
  st.success("Capa guardada no storage local.")
@@ -6369,11 +6369,11 @@ def _render_distrokid_upload_tab() -> None:
6369
6369
  if cover_path and Path(cover_path).is_file():
6370
6370
  st.caption(f"Capa seleccionada: `{cover_path}`")
6371
6371
  session_id = str(st.session_state.get("distrokid_session_id") or "")
6372
- if session_id and st.button("Fechar browser DistroKid", key="distrokid_close_browser", use_container_width=True):
6372
+ if session_id and st.button("Fechar browser DistroKid", key="distrokid_close_browser", width="stretch"):
6373
6373
  result = close_distrokid_session(session_id)
6374
6374
  (st.success if result.ok else st.warning)(result.message)
6375
6375
  st.session_state.pop("distrokid_session_id", None)
6376
- 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):
6376
+ 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):
6377
6377
  current = read_json("settings.json", {})
6378
6378
  result = DistroKidAdapter(current).prepare_upload(
6379
6379
  track_rows, artist=str(current.get("distrokid_artist") or ""), release_title=str(current.get("distrokid_release_title") or ""),
@@ -6426,7 +6426,7 @@ def render_upload_composio():
6426
6426
  return
6427
6427
  query = st.text_input("Pesquisar ferramenta Composio", value="upload a video file", key="composio_upload_query")
6428
6428
  toolkit_filter = st.text_input("Toolkit preferido (opcional)", value=str(settings.get("composio_toolkit") or ""), key="composio_upload_toolkit")
6429
- if st.button("Descobrir ferramentas", key="composio_upload_discover", use_container_width=True):
6429
+ if st.button("Descobrir ferramentas", key="composio_upload_discover", width="stretch"):
6430
6430
  try:
6431
6431
  st.session_state["composio_upload_tools"] = discover_tools(api_key, user_id, query, toolkit_filter)
6432
6432
  if not st.session_state["composio_upload_tools"]:
@@ -6449,7 +6449,7 @@ def render_upload_composio():
6449
6449
  st.json(schema)
6450
6450
  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")
6451
6451
  arguments_json = st.text_area("Argumentos JSON adicionais", value=str(st.session_state.get("composio_upload_arguments") or "{}"), height=150, key="composio_upload_arguments")
6452
- if st.button("Autorizar toolkit no Composio", key="composio_upload_authorize", use_container_width=True):
6452
+ if st.button("Autorizar toolkit no Composio", key="composio_upload_authorize", width="stretch"):
6453
6453
  try:
6454
6454
  auth = authorize_toolkit(api_key, user_id, str(selected_tool.get("toolkit") or ""))
6455
6455
  if auth.get("redirect_url"):
@@ -6469,7 +6469,7 @@ def render_upload_composio():
6469
6469
  video_path = str((task.get("artifacts", {}) or {}).get("video") or "")
6470
6470
  st.caption(video_path or "Sem caminho de vídeo registado")
6471
6471
  st.info(f"Resumo: `{selected_slug}` · toolkit `{selected_tool.get('toolkit') or 'não indicado'}` · campo `{file_field or 'não indicado'}`")
6472
- if st.button("Enviar vídeo via Composio", type="primary", key=f"composio_upload_send_{selected_task_id}", use_container_width=True):
6472
+ if st.button("Enviar vídeo via Composio", type="primary", key=f"composio_upload_send_{selected_task_id}", width="stretch"):
6473
6473
  try:
6474
6474
  parse_arguments(arguments_json)
6475
6475
  result = execute_upload(api_key, user_id, selected_slug, video_path, file_field, arguments_json, connected_account_id)
@@ -6742,14 +6742,14 @@ def render_upload_conventional():
6742
6742
  status_cols = st.columns(2)
6743
6743
  with status_cols[0]:
6744
6744
  (st.success if status["agent"].ok else st.warning)(f"Agente: {status['agent'].message}")
6745
- if st.button("Autorizar agente YouTube", key="youtube_authorize_agent", use_container_width=True):
6745
+ if st.button("Autorizar agente YouTube", key="youtube_authorize_agent", width="stretch"):
6746
6746
  result = youtube.authorize_agent()
6747
6747
  (st.success if result.ok else st.error)(result.message)
6748
6748
  if result.ok:
6749
6749
  st.rerun()
6750
6750
  with status_cols[1]:
6751
6751
  (st.info if status["fallback"].ok else st.caption)(f"Fallback OAuth: {status['fallback'].message}")
6752
- if st.button("Autorizar fallback OAuth", key="youtube_authorize_fallback", use_container_width=True):
6752
+ if st.button("Autorizar fallback OAuth", key="youtube_authorize_fallback", width="stretch"):
6753
6753
  result = youtube.authorize_fallback()
6754
6754
  (st.success if result.ok else st.error)(result.message)
6755
6755
  if result.ok:
@@ -6802,7 +6802,7 @@ def render_upload_conventional():
6802
6802
  st.session_state.pop(description_error_key, None)
6803
6803
 
6804
6804
  description = st.text_area("Descrição", value=task.get("description", ""), key=description_state_key, height=100)
6805
- st.button("Gerar descrição com IA", key=f"yt_description_ai_{task['id']}", use_container_width=False, on_click=generate_upload_description_callback)
6805
+ st.button("Gerar descrição com IA", key=f"yt_description_ai_{task['id']}", width="content", on_click=generate_upload_description_callback)
6806
6806
  if description_error := str(st.session_state.get(description_error_key) or "").strip():
6807
6807
  st.error(f"Não foi possível gerar a descrição: {description_error}")
6808
6808
  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']}")
@@ -6970,11 +6970,11 @@ def render_tiktok_api_cards(settings: dict[str, Any]) -> None:
6970
6970
  client_secret = st.text_input("TikTok Client Secret", value=card["client_secret"], type="password", key=f"tiktok_api_{card_id}_client_secret")
6971
6971
  action_cols = st.columns(3)
6972
6972
  with action_cols[0]:
6973
- test_clicked = st.form_submit_button("Testar chamada API", use_container_width=True)
6973
+ test_clicked = st.form_submit_button("Testar chamada API", width="stretch")
6974
6974
  with action_cols[1]:
6975
- save_clicked = st.form_submit_button("Guardar card", type="primary", use_container_width=True)
6975
+ save_clicked = st.form_submit_button("Guardar card", type="primary", width="stretch")
6976
6976
  with action_cols[2]:
6977
- delete_clicked = st.form_submit_button("Apagar card", use_container_width=True)
6977
+ delete_clicked = st.form_submit_button("Apagar card", width="stretch")
6978
6978
  edited = {"id": card_id, "client_id": client_id.strip(), "client_secret": client_secret.strip()}
6979
6979
  if test_clicked:
6980
6980
  cards[index] = edited
@@ -6996,7 +6996,7 @@ def render_tiktok_api_cards(settings: dict[str, Any]) -> None:
6996
6996
  _persist_tiktok_api_cards(settings, cards)
6997
6997
  st.success("Card TikTok apagado.")
6998
6998
  st.rerun()
6999
- if st.button("Adicionar nova API", type="primary", use_container_width=True, key="add_tiktok_api_card"):
6999
+ if st.button("Adicionar nova API", type="primary", width="stretch", key="add_tiktok_api_card"):
7000
7000
  cards.append({"id": f"tiktok-api-{uuid.uuid4().hex[:10]}", "client_id": "", "client_secret": ""})
7001
7001
  _persist_tiktok_api_cards(settings, cards)
7002
7002
  st.success("Novo card TikTok criado.")
@@ -7048,11 +7048,11 @@ def render_bilibili_api_cards(settings: dict[str, Any]) -> None:
7048
7048
  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")
7049
7049
  action_cols = st.columns(3)
7050
7050
  with action_cols[0]:
7051
- test_clicked = st.form_submit_button("Testar chamada API", use_container_width=True)
7051
+ test_clicked = st.form_submit_button("Testar chamada API", width="stretch")
7052
7052
  with action_cols[1]:
7053
- save_clicked = st.form_submit_button("Guardar card", type="primary", use_container_width=True)
7053
+ save_clicked = st.form_submit_button("Guardar card", type="primary", width="stretch")
7054
7054
  with action_cols[2]:
7055
- delete_clicked = st.form_submit_button("Apagar card", use_container_width=True)
7055
+ delete_clicked = st.form_submit_button("Apagar card", width="stretch")
7056
7056
  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()}
7057
7057
  if test_clicked:
7058
7058
  cards[index] = edited
@@ -7069,7 +7069,7 @@ def render_bilibili_api_cards(settings: dict[str, Any]) -> None:
7069
7069
  _persist_bilibili_api_cards(settings, cards)
7070
7070
  st.success("Card Bilibili apagado.")
7071
7071
  st.rerun()
7072
- if st.button("Adicionar nova API", type="primary", use_container_width=True, key="add_bilibili_api_card"):
7072
+ if st.button("Adicionar nova API", type="primary", width="stretch", key="add_bilibili_api_card"):
7073
7073
  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": ""})
7074
7074
  _persist_bilibili_api_cards(settings, cards)
7075
7075
  st.success("Novo card Bilibili criado.")
@@ -7155,7 +7155,7 @@ def render_google_accounts(*, include_innertube: bool = True):
7155
7155
  key=f"batch_session_info_captured_at_{account_id}",
7156
7156
  help="Data em que o sessionInfo token foi capturado. É usada para calcular o alerta de expiração e não revela o token.",
7157
7157
  )
7158
- save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
7158
+ save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", width="stretch")
7159
7159
 
7160
7160
  st.markdown("**Documento de cookies/credenciais desta conta Google**")
7161
7161
  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.")
@@ -7170,26 +7170,26 @@ def render_google_accounts(*, include_innertube: bool = True):
7170
7170
  else:
7171
7171
  st.success("Documento completo para a conta Google")
7172
7172
  st.caption(f"Documento guardado em: {direct_status['document_file']}")
7173
- document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
7173
+ document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", width="stretch")
7174
7174
 
7175
7175
  account_status = youtube_batch_account_status(batch_account, STORAGE)
7176
7176
  status_cols = st.columns(4)
7177
7177
  with status_cols[0]:
7178
7178
  (st.success if account_status.ok else st.warning)(account_status.message)
7179
7179
  with status_cols[1]:
7180
- if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", use_container_width=True):
7180
+ if st.button("Autorizar/Reautorizar", key=f"batch_authorize_settings_{account_id}", width="stretch"):
7181
7181
  result = authorize_youtube_batch_account(batch_account, STORAGE)
7182
7182
  (st.success if result.ok else st.error)(result.message)
7183
7183
  if result.ok:
7184
7184
  st.rerun()
7185
7185
  with status_cols[2]:
7186
- if st.button("Autorizar upload YouTube", key=f"batch_authorize_upload_{account_id}", use_container_width=True, help="Autoriza esta conta com o escopo de publicação YouTube; é separado da autorização de leitura/listagem dos canais." ):
7186
+ if st.button("Autorizar upload YouTube", key=f"batch_authorize_upload_{account_id}", width="stretch", help="Autoriza esta conta com o escopo de publicação YouTube; é separado da autorização de leitura/listagem dos canais." ):
7187
7187
  result = authorize_youtube_agent(settings, STORAGE, account=batch_account)
7188
7188
  (st.success if result.ok else st.error)(result.message)
7189
7189
  if result.ok:
7190
7190
  st.rerun()
7191
7191
  with status_cols[3]:
7192
- if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", use_container_width=True):
7192
+ if st.button("Apagar conta", icon=":material/delete:", key=f"batch_remove_settings_{account_id}", width="stretch"):
7193
7193
  delete_youtube_batch_token(batch_account, STORAGE)
7194
7194
  delete_credentials_document(STORAGE, batch_account)
7195
7195
  remaining_accounts = [account for account in batch_accounts if str(account.get("id")) != account_id]
@@ -7254,7 +7254,7 @@ def render_google_accounts(*, include_innertube: bool = True):
7254
7254
  value=bool(batch_account.get("auto_renew_before_upload", False)),
7255
7255
  key=f"auto_renew_before_upload_{account_id}",
7256
7256
  )
7257
- if renewal_cols[1].button("Renovar agora", key=f"renew_session_info_{account_id}", type="primary", use_container_width=True):
7257
+ if renewal_cols[1].button("Renovar agora", key=f"renew_session_info_{account_id}", type="primary", width="stretch"):
7258
7258
  result = renew_account_session(STORAGE, batch_account, settings, force=True, wait_seconds=10)
7259
7259
  (st.success if result.ok else st.error)(result.message)
7260
7260
  if result.ok:
@@ -7314,7 +7314,7 @@ def render_google_accounts(*, include_innertube: bool = True):
7314
7314
  lambda: test_innertube_api_key(innertube_api_key_value),
7315
7315
  widget_key="api_test_innertube",
7316
7316
  )
7317
- save_innertube_api_key = st.form_submit_button("Guardar INNERTUBE_API_KEY global", type="primary", use_container_width=True)
7317
+ save_innertube_api_key = st.form_submit_button("Guardar INNERTUBE_API_KEY global", type="primary", width="stretch")
7318
7318
  if save_innertube_api_key:
7319
7319
  settings["direct_innertube_api_key"] = innertube_api_key_value.strip()
7320
7320
  settings.pop("INNERTUBE_API_KEY", None)
@@ -7329,7 +7329,7 @@ def render_google_accounts(*, include_innertube: bool = True):
7329
7329
  st.divider()
7330
7330
  add_account = False
7331
7331
  add_form_open = bool(st.session_state.get("add_batch_account_form_open", False))
7332
- if not add_form_open and st.button("Adicionar conta Google/YouTube", type="primary", use_container_width=True, key="open_add_batch_account_form"):
7332
+ if not add_form_open and st.button("Adicionar conta Google/YouTube", type="primary", width="stretch", key="open_add_batch_account_form"):
7333
7333
  st.session_state["add_batch_account_form_open"] = True
7334
7334
  st.rerun()
7335
7335
  if add_form_open:
@@ -7345,7 +7345,7 @@ def render_google_accounts(*, include_innertube: bool = True):
7345
7345
  new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
7346
7346
  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.")
7347
7347
  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.")
7348
- add_account = st.form_submit_button("Guardar conta Google/YouTube", type="primary", use_container_width=True)
7348
+ add_account = st.form_submit_button("Guardar conta Google/YouTube", type="primary", width="stretch")
7349
7349
  if st.button("Cancelar", key="cancel_add_batch_account_form"):
7350
7350
  st.session_state["add_batch_account_form_open"] = False
7351
7351
  st.rerun()
@@ -7397,7 +7397,7 @@ def render_google_accounts(*, include_innertube: bool = True):
7397
7397
  key="google_page_youtube_api_key",
7398
7398
  help="Credencial Google Cloud separada, usada apenas para métricas oficiais da YouTube Data API.",
7399
7399
  )
7400
- save_google_global = st.form_submit_button("Guardar configuração global do YouTube", type="primary", use_container_width=True)
7400
+ save_google_global = st.form_submit_button("Guardar configuração global do YouTube", type="primary", width="stretch")
7401
7401
  if save_google_global:
7402
7402
  settings.update({
7403
7403
  "youtube_api_key": youtube_api_key.strip(),
@@ -7474,7 +7474,7 @@ def _render_material_source_card(settings: dict[str, Any], cards: list[dict[str,
7474
7474
  lambda: test_material_source_credentials(provider, api_key),
7475
7475
  widget_key=f"api_test_material_{card_id}",
7476
7476
  )
7477
- save_card = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"material_card_{card_id}_save")
7477
+ save_card = st.form_submit_button("Salvar", type="primary", width="stretch", key=f"material_card_{card_id}_save")
7478
7478
  if save_card:
7479
7479
  cards[index] = {
7480
7480
  **card,
@@ -7507,7 +7507,7 @@ def render_material_source_api_keys(settings: dict[str, Any], *, embedded: bool
7507
7507
  format_func=lambda value: _material_source_card_definition(value)["label"],
7508
7508
  key="material_new_provider_choice",
7509
7509
  )
7510
- 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")
7510
+ 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")
7511
7511
  if add_source_clicked:
7512
7512
  new_card = new_material_card(provider_to_add, card_id=f"material-{provider_to_add}-{uuid.uuid4().hex[:8]}")
7513
7513
  new_card["priority"] = max(
@@ -7598,7 +7598,7 @@ def _render_api_test_control(
7598
7598
  consistent: testing it must not appear successful while its values remain
7599
7599
  only in the browser session.
7600
7600
  """
7601
- if st.form_submit_button("Testar chamada API", use_container_width=True, key=widget_key):
7601
+ if st.form_submit_button("Testar chamada API", width="stretch", key=widget_key):
7602
7602
  with st.spinner(ui_text("A testar chamada API…", current_ui_language())):
7603
7603
  try:
7604
7604
  if persist_callback is not None:
@@ -7698,9 +7698,9 @@ def _render_llm_card(settings: dict[str, Any], cards: list[dict[str, Any]], inde
7698
7698
  with action_col:
7699
7699
  action_buttons = st.columns(2)
7700
7700
  with action_buttons[0]:
7701
- refresh_clicked = st.form_submit_button("Consultar modelos", use_container_width=True, key=f"llm_card_{card_id}_refresh")
7701
+ refresh_clicked = st.form_submit_button("Consultar modelos", width="stretch", key=f"llm_card_{card_id}_refresh")
7702
7702
  with action_buttons[1]:
7703
- test_clicked = st.form_submit_button("Testar chamada API", use_container_width=True, key=f"llm_card_{card_id}_test")
7703
+ test_clicked = st.form_submit_button("Testar chamada API", width="stretch", key=f"llm_card_{card_id}_test")
7704
7704
 
7705
7705
  extra_values: dict[str, str] = {}
7706
7706
  if definition.extra_fields:
@@ -7737,10 +7737,10 @@ def _render_llm_card(settings: dict[str, Any], cards: list[dict[str, Any]], inde
7737
7737
  if telegram_llm:
7738
7738
  st.caption("Exclusivo para Notificações de Telegram — prioridade ignorada no pool LLM.")
7739
7739
 
7740
- save_clicked = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"llm_card_{card_id}_save")
7740
+ save_clicked = st.form_submit_button("Salvar", type="primary", width="stretch", key=f"llm_card_{card_id}_save")
7741
7741
  remove_clicked = False
7742
7742
  if definition.code != "openai":
7743
- remove_clicked = st.form_submit_button("Remover cartão", use_container_width=True, key=f"llm_card_{card_id}_remove")
7743
+ remove_clicked = st.form_submit_button("Remover cartão", width="stretch", key=f"llm_card_{card_id}_remove")
7744
7744
 
7745
7745
  edited = dict(card)
7746
7746
  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})
@@ -7802,9 +7802,9 @@ def render_llm_provider_cards(settings: dict[str, Any], *, embedded: bool = Fals
7802
7802
  with rpm_cols[2]:
7803
7803
  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")
7804
7804
  save_llm_rpm_clicked = (
7805
- st.form_submit_button("Guardar limite LLM NVIDIA NIM", type="primary", use_container_width=True, key="save_llm_rpm_limit")
7805
+ st.form_submit_button("Guardar limite LLM NVIDIA NIM", type="primary", width="stretch", key="save_llm_rpm_limit")
7806
7806
  if embedded
7807
- else st.button("Guardar limite LLM NVIDIA NIM", type="primary", use_container_width=True, key="save_llm_rpm_limit")
7807
+ else st.button("Guardar limite LLM NVIDIA NIM", type="primary", width="stretch", key="save_llm_rpm_limit")
7808
7808
  )
7809
7809
  if save_llm_rpm_clicked:
7810
7810
  settings.update({
@@ -7826,9 +7826,9 @@ def render_llm_provider_cards(settings: dict[str, Any], *, embedded: bool = Fals
7826
7826
  key="llm_new_provider_choice",
7827
7827
  )
7828
7828
  add_provider_clicked = (
7829
- st.form_submit_button("Configurar Novo Provedor LLM", type="primary", use_container_width=True, key="add_llm_provider_card")
7829
+ st.form_submit_button("Configurar Novo Provedor LLM", type="primary", width="stretch", key="add_llm_provider_card")
7830
7830
  if embedded
7831
- else st.button("Configurar Novo Provedor LLM", type="primary", use_container_width=True, key="add_llm_provider_card")
7831
+ else st.button("Configurar Novo Provedor LLM", type="primary", width="stretch", key="add_llm_provider_card")
7832
7832
  )
7833
7833
  if add_provider_clicked:
7834
7834
  new_card = new_llm_card(provider_to_add, card_id=f"llm-{provider_to_add}-{uuid.uuid4().hex[:8]}")
@@ -7998,7 +7998,7 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
7998
7998
  )
7999
7999
  model = "" if selected_model == "__select_model__" else selected_model
8000
8000
  if definition.code == "canva":
8001
- refresh_clicked = st.form_submit_button("Consultar Modelos", use_container_width=True, key=f"media_card_{card_id}_refresh")
8001
+ refresh_clicked = st.form_submit_button("Consultar Modelos", width="stretch", key=f"media_card_{card_id}_refresh")
8002
8002
  if definition.code == "canva":
8003
8003
  base_url = definition.default_base_url
8004
8004
  st.text_input("Base URL", value=base_url, disabled=True, key=f"media_card_{card_id}_base_url_display")
@@ -8031,7 +8031,7 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
8031
8031
  if not has_credentials:
8032
8032
  st.info("Preencha Client Id, Client Secret e Redirect Uri para iniciar a autorização.")
8033
8033
  else:
8034
- 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")
8034
+ 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")
8035
8035
  if authorize_clicked:
8036
8036
  verifier, challenge = create_pkce_pair()
8037
8037
  state = create_state()
@@ -8060,13 +8060,13 @@ def _render_media_provider_card(settings: dict[str, Any], cards: list[dict[str,
8060
8060
  action_cols = st.columns(4)
8061
8061
  with action_cols[0]:
8062
8062
  if definition.code != "canva":
8063
- refresh_clicked = st.form_submit_button("Consultar Modelos", use_container_width=True, key=f"media_card_{card_id}_refresh")
8063
+ refresh_clicked = st.form_submit_button("Consultar Modelos", width="stretch", key=f"media_card_{card_id}_refresh")
8064
8064
  with action_cols[1]:
8065
- test_clicked = st.form_submit_button("Testar Chamada API", use_container_width=True, key=f"media_card_{card_id}_test")
8065
+ test_clicked = st.form_submit_button("Testar Chamada API", width="stretch", key=f"media_card_{card_id}_test")
8066
8066
  with action_cols[2]:
8067
- save_clicked = st.form_submit_button("Salvar", type="primary", use_container_width=True, key=f"media_card_{card_id}_save")
8067
+ save_clicked = st.form_submit_button("Salvar", type="primary", width="stretch", key=f"media_card_{card_id}_save")
8068
8068
  with action_cols[3]:
8069
- remove_clicked = st.form_submit_button("Remover provider", use_container_width=True, key=f"media_card_{card_id}_remove")
8069
+ remove_clicked = st.form_submit_button("Remover provider", width="stretch", key=f"media_card_{card_id}_remove")
8070
8070
  edited = dict(card)
8071
8071
  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})
8072
8072
  cards[index] = edited
@@ -8118,7 +8118,7 @@ def render_media_provider_cards(settings: dict[str, Any], *, embedded: bool = Fa
8118
8118
  st.markdown("**Adicionar provider de imagem/vídeo**")
8119
8119
  provider_codes = [item["code"] for item in media_provider_catalog()]
8120
8120
  provider_to_add = st.selectbox("Provider de media", provider_codes, format_func=lambda value: media_provider_definition(value).label, key="media_new_provider_choice")
8121
- 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")
8121
+ 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")
8122
8122
  if add_clicked:
8123
8123
  cards.append(new_media_card(provider_to_add, card_id=f"media-{provider_to_add}-{uuid.uuid4().hex[:8]}"))
8124
8124
  _persist_media_cards(settings, cards)
@@ -8156,7 +8156,7 @@ def render_settings():
8156
8156
  for index, (key, label) in enumerate((("json", "JSON/API"), ("log", "Logs"), ("pdf", "PDF"), ("csv", "CSV"), ("diff", "Diffs"), ("code", "Código"))):
8157
8157
  with optimizer_cols[index % 4]:
8158
8158
  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")
8159
- if st.button("Guardar optimizador", key="save_token_optimizer", use_container_width=True):
8159
+ if st.button("Guardar optimizador", key="save_token_optimizer", width="stretch"):
8160
8160
  settings.update({"token_optimizer_enabled": optimizer_enabled, **optimizer_values})
8161
8161
  write_json("settings.json", settings)
8162
8162
  st.success("Configuração do jusTokenMax guardada.")
@@ -8229,7 +8229,7 @@ def render_settings():
8229
8229
  })
8230
8230
  write_json("settings.json", settings)
8231
8231
 
8232
- if st.form_submit_button("Guardar Azure Speech", type="primary", use_container_width=True, key="save_azure_speech"):
8232
+ if st.form_submit_button("Guardar Azure Speech", type="primary", width="stretch", key="save_azure_speech"):
8233
8233
  save_azure_speech()
8234
8234
  st.success("Azure Speech guardado.")
8235
8235
  _render_api_test_control(
@@ -8337,7 +8337,7 @@ def render_settings():
8337
8337
  settings.update({"lyria_api_key": lyria_api_key.strip(), "lyria_model": lyria_model})
8338
8338
  write_json("settings.json", settings)
8339
8339
 
8340
- if st.form_submit_button("Guardar Google Lyria", type="primary", use_container_width=True, key="save_google_lyria"):
8340
+ if st.form_submit_button("Guardar Google Lyria", type="primary", width="stretch", key="save_google_lyria"):
8341
8341
  save_google_lyria()
8342
8342
  st.success("Google Lyria guardado.")
8343
8343
  _render_api_test_control(
@@ -8421,7 +8421,7 @@ def render_settings():
8421
8421
  with st.form("upload_innertube_api_key_form"):
8422
8422
  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.")
8423
8423
  _render_api_test_control(settings, "innertube_upload", lambda: test_innertube_api_key(upload_innertube), widget_key="api_test_innertube_upload")
8424
- save_upload_innertube = st.form_submit_button("Guardar INNERTUBE_API_KEY global", type="primary", use_container_width=True)
8424
+ save_upload_innertube = st.form_submit_button("Guardar INNERTUBE_API_KEY global", type="primary", width="stretch")
8425
8425
  if save_upload_innertube:
8426
8426
  settings["direct_innertube_api_key"] = upload_innertube.strip()
8427
8427
  settings.pop("INNERTUBE_API_KEY", None)
@@ -8481,9 +8481,9 @@ def render_settings():
8481
8481
  _render_credential_status(composio_api_key)
8482
8482
  composio_action_cols = st.columns(2)
8483
8483
  with composio_action_cols[0]:
8484
- save_composio = st.button("Guardar Composio", type="primary", use_container_width=True, key="upload_composio_save")
8484
+ save_composio = st.button("Guardar Composio", type="primary", width="stretch", key="upload_composio_save")
8485
8485
  with composio_action_cols[1]:
8486
- test_composio = st.button("Testar configuração", use_container_width=True, key="upload_composio_test")
8486
+ test_composio = st.button("Testar configuração", width="stretch", key="upload_composio_test")
8487
8487
  if save_composio:
8488
8488
  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_connected_account_id": composio_connected_account_id.strip(), "composio_toolkit": composio_toolkit, "composio_tool_slug": composio_tool_slug, "composio_file_field": "videoFilePath", "composio_channel_field": composio_channel_field, "composio_privacy_status": composio_privacy_status, "composio_category_id": composio_category_id, "composio_language": composio_language, "composio_privacy_field": "privacyStatus", "composio_category_field": "categoryId", "composio_language_field": "defaultLanguage", "composio_arguments_json": composio_arguments_json or "{}"})
8489
8489
  write_json("settings.json", settings)
@@ -8503,7 +8503,7 @@ def render_settings():
8503
8503
  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")
8504
8504
  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")
8505
8505
  _render_credential_status(upload_post_key_upload, required=True)
8506
- if st.button("Guardar Upload-Post", type="primary", use_container_width=True, key="upload_tab_save_upload_post"):
8506
+ if st.button("Guardar Upload-Post", type="primary", width="stretch", key="upload_tab_save_upload_post"):
8507
8507
  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)})
8508
8508
  write_json("settings.json", settings)
8509
8509
  st.success("Upload-Post guardado.")
@@ -8516,7 +8516,7 @@ def render_settings():
8516
8516
  postiz_integration_upload = st.text_input("Postiz integração padrão", value=str(settings.get("postiz_integration_id") or ""), key="upload_tab_postiz_integration")
8517
8517
  postiz_auto_upload = st.checkbox("Permitir publicação imediata no Postiz", bool(settings.get("postiz_auto_publish", False)), key="upload_tab_postiz_auto")
8518
8518
  _render_credential_status(postiz_key_upload, required=True)
8519
- if st.button("Guardar Postiz", type="primary", use_container_width=True, key="upload_tab_save_postiz"):
8519
+ if st.button("Guardar Postiz", type="primary", width="stretch", key="upload_tab_save_postiz"):
8520
8520
  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)})
8521
8521
  write_json("settings.json", settings)
8522
8522
  st.success("Postiz guardado.")
@@ -8542,8 +8542,8 @@ def render_settings():
8542
8542
  influencer_supabase_url = text_setting("Supabase Project URL", "influencer_supabase_url", help_text="URL do projecto, por exemplo https://project-id.supabase.co")
8543
8543
  with db_cols[1]:
8544
8544
  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.")
8545
- test_backend_clicked = st.form_submit_button("Testar ligação do backend", use_container_width=True)
8546
- save_backend_clicked = st.form_submit_button("Guardar configuração do backend", type="primary", use_container_width=True)
8545
+ test_backend_clicked = st.form_submit_button("Testar ligação do backend", width="stretch")
8546
+ save_backend_clicked = st.form_submit_button("Guardar configuração do backend", type="primary", width="stretch")
8547
8547
  effective_settings = dict(settings)
8548
8548
  effective_settings.update({
8549
8549
  "influencer_db_backend": influencer_db_backend,
@@ -8648,7 +8648,7 @@ def _render_telegram_notification_settings() -> None:
8648
8648
  lambda: test_telegram_credentials(telegram_bot_token, telegram_chat_id),
8649
8649
  widget_key="api_test_telegram",
8650
8650
  )
8651
- if st.form_submit_button("Guardar configuração Telegram", type="primary", use_container_width=True):
8651
+ if st.form_submit_button("Guardar configuração Telegram", type="primary", width="stretch"):
8652
8652
  settings.update({
8653
8653
  "telegram_enabled": bool(telegram_enabled),
8654
8654
  "telegram_bot_token": telegram_bot_token.strip(),
@@ -8696,7 +8696,7 @@ def render_logs():
8696
8696
 
8697
8697
  action_cols = st.columns([1, 1, 3])
8698
8698
  with action_cols[0]:
8699
- if st.button("Actualizar logs", use_container_width=True):
8699
+ if st.button("Actualizar logs", width="stretch"):
8700
8700
  st.rerun()
8701
8701
  with action_cols[1]:
8702
8702
  st.metric("Registos", len(initial_records))
@@ -8712,7 +8712,7 @@ def render_logs():
8712
8712
  rows = logs_to_rows(records)
8713
8713
  st.dataframe(
8714
8714
  rows,
8715
- use_container_width=True,
8715
+ width="stretch",
8716
8716
  height=520,
8717
8717
  hide_index=True,
8718
8718
  column_config={
@@ -8752,16 +8752,16 @@ def render_notifications():
8752
8752
 
8753
8753
  action_cols = st.columns([1.4, 1.4, 2.2])
8754
8754
  with action_cols[0]:
8755
- if st.button("Marcar todas como lidas", use_container_width=True, disabled=unread_count == 0):
8755
+ if st.button("Marcar todas como lidas", width="stretch", disabled=unread_count == 0):
8756
8756
  mark_all_notifications_read()
8757
8757
  st.rerun()
8758
8758
  with action_cols[1]:
8759
- if st.button("Actualizar notificações", use_container_width=True):
8759
+ if st.button("Actualizar notificações", width="stretch"):
8760
8760
  reconcile_persisted_notifications()
8761
8761
  st.rerun()
8762
8762
  with action_cols[2]:
8763
8763
  confirm_clear = st.checkbox("Confirmar limpeza do histórico", key="confirm_clear_notifications")
8764
- if st.button("Limpar histórico", use_container_width=True, disabled=not confirm_clear):
8764
+ if st.button("Limpar histórico", width="stretch", disabled=not confirm_clear):
8765
8765
  clear_notifications()
8766
8766
  st.session_state.pop("confirm_clear_notifications", None)
8767
8767
  st.rerun()
@@ -8783,7 +8783,7 @@ def render_notifications():
8783
8783
  help=event["description"],
8784
8784
  key=f"notification_preference_{event['code']}",
8785
8785
  )
8786
- if st.form_submit_button("Guardar preferências", type="primary", use_container_width=True):
8786
+ if st.form_submit_button("Guardar preferências", type="primary", width="stretch"):
8787
8787
  save_notification_preferences(pending_preferences)
8788
8788
  st.success("Preferências de notificação guardadas.")
8789
8789
  st.rerun()
@@ -8818,7 +8818,7 @@ def render_notifications():
8818
8818
  st.caption(f"{item.get('category', 'Sistema')} · {item.get('created_at', '—')}")
8819
8819
  st.write("Lida" if item.get("read") else "Não lida")
8820
8820
  with notification_cols[2]:
8821
- if not item.get("read") and st.button("Marcar como lida", key=f"mark_notification_{item.get('id')}", use_container_width=True):
8821
+ if not item.get("read") and st.button("Marcar como lida", key=f"mark_notification_{item.get('id')}", width="stretch"):
8822
8822
  mark_notification_read(str(item.get("id")))
8823
8823
  st.rerun()
8824
8824
 
@@ -8947,7 +8947,7 @@ def render_mcp():
8947
8947
  st.rerun()
8948
8948
 
8949
8949
  st.caption(integration.get("endpoint_note", "Porta editável para o serviço local."))
8950
- if st.button("Guardar porta", key=f"mcp_save_port_{integration_id}", use_container_width=True):
8950
+ if st.button("Guardar porta", key=f"mcp_save_port_{integration_id}", width="stretch"):
8951
8951
  update_integration(integration_id, port=int(port))
8952
8952
  st.success(f"Porta de {integration['name']} guardada: {int(port)}")
8953
8953
  st.rerun()
@@ -8981,7 +8981,7 @@ def render_mcp():
8981
8981
  st.error("Para expor o servidor fora do computador local, defina um token de acesso MCP.")
8982
8982
 
8983
8983
  action_label = "Guardar e iniciar Servidor MCP" if server_enabled else "Guardar e parar Servidor MCP"
8984
- if st.button(action_label, type="primary", key="mcp_server_save", use_container_width=True):
8984
+ if st.button(action_label, type="primary", key="mcp_server_save", width="stretch"):
8985
8985
  try:
8986
8986
  saved = save_server_config(enabled=server_enabled, host=server_host, port=int(server_port), auth_token=server_token, write_enabled=write_enabled)
8987
8987
  if saved["enabled"]:
@@ -9021,7 +9021,7 @@ def render_mcp():
9021
9021
  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.")
9022
9022
  skill_cols = st.columns(2)
9023
9023
  with skill_cols[0]:
9024
- if st.button("Guardar skill localmente", type="primary", use_container_width=True, key="mcp_install_mpt_skill"):
9024
+ if st.button("Guardar skill localmente", type="primary", width="stretch", key="mcp_install_mpt_skill"):
9025
9025
  try:
9026
9026
  destination = install_skill_locally()
9027
9027
  st.success(f"Skill guardada em `{destination}`")
@@ -9038,7 +9038,7 @@ def render_mcp():
9038
9038
  data=skill_data,
9039
9039
  file_name="moneyprinterturbo-video.md",
9040
9040
  mime="text/markdown",
9041
- use_container_width=True,
9041
+ width="stretch",
9042
9042
  key="mcp_download_mpt_skill",
9043
9043
  )
9044
9044
  else:
@@ -9140,8 +9140,8 @@ def render_metadata_cleaner():
9140
9140
  st.subheader("Resultado")
9141
9141
  st.write(f"**Ficheiro limpo:** `{output.name}`")
9142
9142
  mime = "video/mp4" if output.suffix.lower() == ".mp4" else "video/*"
9143
- 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")
9144
- 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")
9143
+ st.download_button("Descarregar vídeo limpo", data=output.read_bytes(), file_name=output.name, mime=mime, width="stretch", key="metadata_download_video")
9144
+ 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")
9145
9145
  with st.expander("Pré-visualizar metadados"):
9146
9146
  st.json(record["metadata"])
9147
9147
 
@@ -9170,7 +9170,7 @@ def render_update_youtube_videos():
9170
9170
  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")
9171
9171
  updater = YouTubeVideoUpdater(settings, STORAGE)
9172
9172
  refresh_key = f"update_youtube_loaded_{channel.get('id', '')}"
9173
- 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:
9173
+ if st.button("Carregar vídeos do canal", type="primary", width="stretch", key="update_youtube_load") or refresh_key not in st.session_state:
9174
9174
  with st.spinner("A carregar vídeos publicados…"):
9175
9175
  result = updater.list_videos(channel, max_results=50)
9176
9176
  st.session_state[refresh_key] = result.data if result.ok else {"error": result.message, "videos": []}
@@ -9192,7 +9192,7 @@ def render_update_youtube_videos():
9192
9192
  top = st.columns([1.2, 3.8, 1.2])
9193
9193
  with top[0]:
9194
9194
  if video.get("thumbnail_url"):
9195
- st.image(video["thumbnail_url"], use_container_width=True)
9195
+ st.image(video["thumbnail_url"], width="stretch")
9196
9196
  with top[1]:
9197
9197
  st.markdown(f"**{video.get('title') or 'Sem título'}**")
9198
9198
  st.caption(f"{video.get('published_at') or 'Data indisponível'} · {video.get('privacy_status') or 'estado desconhecido'} · `{video_id}`")
@@ -9207,7 +9207,7 @@ def render_update_youtube_videos():
9207
9207
  ai_cols = st.columns(3)
9208
9208
  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), "")
9209
9209
  with ai_cols[0]:
9210
- if st.button("Gerar título", key=f"{card_key}_ai_title", use_container_width=True):
9210
+ if st.button("Gerar título", key=f"{card_key}_ai_title", width="stretch"):
9211
9211
  try:
9212
9212
  generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="title")
9213
9213
  st.session_state[title_key] = generated["title"]
@@ -9215,7 +9215,7 @@ def render_update_youtube_videos():
9215
9215
  except CreativeGenerationError as exc:
9216
9216
  st.error(str(exc))
9217
9217
  with ai_cols[1]:
9218
- if st.button("Gerar descrição", key=f"{card_key}_ai_description", use_container_width=True):
9218
+ if st.button("Gerar descrição", key=f"{card_key}_ai_description", width="stretch"):
9219
9219
  try:
9220
9220
  generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="description")
9221
9221
  st.session_state[desc_key] = generated["description"]
@@ -9223,7 +9223,7 @@ def render_update_youtube_videos():
9223
9223
  except CreativeGenerationError as exc:
9224
9224
  st.error(str(exc))
9225
9225
  with ai_cols[2]:
9226
- if st.button("Gerar título e descrição", key=f"{card_key}_ai_both", use_container_width=True):
9226
+ if st.button("Gerar título e descrição", key=f"{card_key}_ai_both", width="stretch"):
9227
9227
  try:
9228
9228
  generated = generate_video_update_metadata(settings, channel, video, script=script, blueprint=blueprint, mode="both")
9229
9229
  st.session_state[title_key] = generated.get("title", title)
@@ -9232,7 +9232,7 @@ def render_update_youtube_videos():
9232
9232
  except CreativeGenerationError as exc:
9233
9233
  st.error(str(exc))
9234
9234
  thumbnail = st.file_uploader("Trocar thumbnail (opcional)", type=["jpg", "jpeg", "png"], key=f"{card_key}_thumbnail")
9235
- if st.button("Actualizar no YouTube", type="primary", key=f"{card_key}_save", use_container_width=True):
9235
+ if st.button("Actualizar no YouTube", type="primary", key=f"{card_key}_save", width="stretch"):
9236
9236
  thumbnail_path = None
9237
9237
  if thumbnail is not None:
9238
9238
  thumbnail_path = STORAGE / "youtube_update" / f"{video_id}_{thumbnail.name}"
@@ -9416,7 +9416,7 @@ def main():
9416
9416
 
9417
9417
  def render_nav_button(target: str, icon: str, label: str, scope: str):
9418
9418
  display_label = ui_text(label, ui_language)
9419
- 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"):
9419
+ if st.button(display_label, key=f"nav_{scope}_{target}", icon=icon, width="stretch", type="primary" if is_nav_item_active(target) else "secondary"):
9420
9420
  navigate(target)
9421
9421
 
9422
9422
  with st.sidebar: