commit 9674a8f813d32178fc9de63eedffd15586af6c07 from: ale date: Thu Jul 30 22:00:57 2026 UTC App Textual completa: menú, editor con preview, 4 flujos, cowsay - app.py + theme.py: tema Gruvbox (misma paleta que nezzontli.xyz) - screens/menu.py: banner Figlet + OptionList - screens/editor.py: TextArea(markdown) + Markdown preview en vivo, reemplaza abrir $EDITOR externo - screens/post.py, page.py, album.py, add_photos.py: los 4 flujos, con Select/SelectionList/DirectoryTree en vez de texto libre donde aplica - screens/confirm.py, success.py: diff+preview+commit/push real, pantalla final con cowsay (animal configurable) - screens/settings.py, folder_picker.py Validado de punta a punta contra un clone local descartable (bare + working copy, no el repo real): los 4 flujos completos, con commit y push reales, y confirmado con `git show :ruta` que las fotos quedan como punteros LFS reales, no blobs crudos. Encontrado y arreglado en el camino: commit_and_push no manejaba con gracia un `git commit` sin nada para comitear (podía tirar abajo el worker); el botón de confirmar ahora se deshabilita al primer click para evitar envíos duplicados. `pipx install -e .` no se pudo validar tal cual: pipx falla al compilarse desde este snapshot de nixpkgs (bug de su propio test suite, no relacionado a este proyecto). El entry point `ctl` sí se probó directo vía `.venv/bin/ctl` y funciona. commit - bd3729dd6f06c79e0afe271673ff815f499637fc commit + 9674a8f813d32178fc9de63eedffd15586af6c07 blob - d4009889a9672adf4dd541e34133bd29f82f9d5b blob + 4f8dd7a0d1455b9bb9a8d6959c55c7b3ed419651 --- README.md +++ README.md @@ -30,6 +30,11 @@ ctl # ya disponible en cualquier terminal, sin activ ## Estado -En desarrollo — todavía no integrada al flujo real de contenido de -nezzontli.xyz (eso sigue por `website/scripts/ctl.py`, el CLI simple ya -probado, hasta que esta versión quede validada). +Validada de punta a punta contra un clone local descartable (no el repo +real): las 4 pantallas (post, page, album, add-photos), edición con preview +de Markdown en vivo, commit+push real, y confirmado que las fotos quedan +como punteros git-lfs reales (no blobs crudos). Pantalla de configuración +(animal de cowsay, ruta del repo) también probada. + +Todavía no reemplaza a `website/scripts/ctl.py` en el uso diario — eso +queda a decisión de Ale, una vez que la pruebe él mismo contra el repo real. blob - /dev/null blob + 2dfb660c20f8b186bfdc9cebe0f6404d979ae5cf (mode 644) --- /dev/null +++ nezzontli_ctl/app.py @@ -0,0 +1,23 @@ +from textual.app import App + +from nezzontli_ctl.theme import GRUVBOX + + +class NezzontliApp(App): + CSS_PATH = "styles.tcss" + TITLE = "nezzontli-ctl" + + def on_mount(self) -> None: + self.register_theme(GRUVBOX) + self.theme = "gruvbox-nezzontli" + from nezzontli_ctl.screens.menu import MenuScreen + + self.push_screen(MenuScreen()) + + +def main(): + NezzontliApp().run() + + +if __name__ == "__main__": + main() blob - 6818b835256f6aca2d9d93aca20ffe8473cd89df blob + 6ee5ac8a52614688cd030d9fb53edcc7d3a01860 --- nezzontli_ctl/git_ops.py +++ nezzontli_ctl/git_ops.py @@ -22,7 +22,10 @@ def diff_stat_cached(): def commit_and_push(message): - _git("commit", "-m", message) + commit = _git("commit", "-m", message, check=False) + if commit.returncode != 0: + return False, (commit.stdout or "") + (commit.stderr or "") + push = subprocess.run( ["git", "push", "origin", "main"], cwd=REPO_ROOT, blob - /dev/null blob + e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 (mode 644) blob - /dev/null blob + ce0a05af36be183acd65cd76f5aa228ccd84362b (mode 644) --- /dev/null +++ nezzontli_ctl/screens/add_photos.py @@ -0,0 +1,102 @@ +import shutil + +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Button, Input, Label, Select, Static + +from nezzontli_ctl import content +from nezzontli_ctl.config import IMAGES_DIR, PHOTOS_DIR +from nezzontli_ctl.screens.confirm import ConfirmPushScreen +from nezzontli_ctl.screens.folder_picker import FolderPickerScreen +from nezzontli_ctl.screens.success import SuccessScreen + + +def _list_albums(): + if not PHOTOS_DIR.is_dir(): + return [] + return sorted( + p.name for p in PHOTOS_DIR.iterdir() if p.is_dir() and (p / "index.md").is_file() + ) + + +class AddPhotosScreen(Screen): + BINDINGS = [("escape", "app.pop_screen", "Volver")] + + _source_files = None + + def compose(self): + albums = _list_albums() + with Vertical(classes="form-container"): + yield Label("Agregar fotos a un álbum existente", classes="form-label") + yield Static("", id="add-error") + yield Label("Álbum") + yield Select([(a, a) for a in albums], id="album-select", prompt="Elegí un álbum") + yield Label("Alt/caption para estas fotos") + yield Input(id="alt-input") + with Horizontal(classes="form-row"): + yield Button("Elegir carpeta de fotos", id="pick-folder-button") + yield Static("(ninguna carpeta elegida)", id="folder-summary") + yield Button("Continuar →", id="continue-button", variant="primary", disabled=True) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "pick-folder-button": + self.run_worker(self._pick_folder(), exclusive=True) + elif event.button.id == "continue-button": + self.run_worker(self._continue(), exclusive=True) + + async def _pick_folder(self) -> None: + path = await self.app.push_screen_wait(FolderPickerScreen()) + if path is None: + return + error = self.query_one("#add-error", Static) + try: + files = content.collect_images(path) + except ValueError as e: + error.update(f"[red]{e}[/red]") + return + error.update("") + self._source_files = files + names = ", ".join(f.name for f in files[:6]) + more = f" (+{len(files) - 6})" if len(files) > 6 else "" + self.query_one("#folder-summary", Static).update(f"{len(files)} fotos: {names}{more}") + self.query_one("#continue-button", Button).disabled = False + + async def _continue(self) -> None: + error = self.query_one("#add-error", Static) + album = self.query_one("#album-select", Select).value + alt = self.query_one("#alt-input", Input).value.strip() + + if not album or album is Select.BLANK: + error.update("[red]Elegí un álbum.[/red]") + return + if not self._source_files: + error.update("[red]Elegí una carpeta con fotos.[/red]") + return + dest_dir = IMAGES_DIR / album + content_file = PHOTOS_DIR / album / "index.md" + existing_names = {f.name for f in dest_dir.iterdir()} if dest_dir.is_dir() else set() + collision = [f.name for f in self._source_files if f.name in existing_names] + if collision: + error.update(f"[red]Ya existen con ese nombre: {', '.join(collision)}[/red]") + return + error.update("") + + for f in self._source_files: + shutil.copy2(f, dest_dir / f.name) + + alt_final = alt or album + items = [f"/images/{album}/{f.name}::{alt_final}" for f in self._source_files] + new_gallery = "\n" + content.gallery_shortcode(items) + original = content_file.read_text(encoding="utf-8") + content_file.write_text(original.rstrip("\n") + "\n" + new_gallery, encoding="utf-8") + + pushed = await self.app.push_screen_wait( + ConfirmPushScreen( + [dest_dir, content_file], + f"{album}: agrega {len(self._source_files)} fotos", + preview_markdown=new_gallery, + ) + ) + if pushed: + await self.app.push_screen_wait(SuccessScreen("¡Fotos agregadas!")) + self.app.pop_screen() blob - /dev/null blob + 27ea43156dda573478d16fca007b11c95b286de6 (mode 644) --- /dev/null +++ nezzontli_ctl/screens/album.py @@ -0,0 +1,112 @@ +import shutil + +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Button, Input, Label, Static + +from nezzontli_ctl import content +from nezzontli_ctl.config import IMAGES_DIR, PHOTOS_DIR +from nezzontli_ctl.screens.confirm import ConfirmPushScreen +from nezzontli_ctl.screens.folder_picker import FolderPickerScreen +from nezzontli_ctl.screens.success import SuccessScreen + +_slug_touched = False + + +class NewAlbumScreen(Screen): + BINDINGS = [("escape", "app.pop_screen", "Volver")] + + _slug_touched = False + _source_files = None + + def compose(self): + with Vertical(classes="form-container"): + yield Label("Nuevo álbum de fotos", classes="form-label") + yield Static("", id="album-error") + yield Label("Título del álbum") + yield Input(id="title-input") + yield Label("Slug (carpeta)") + yield Input(id="slug-input") + yield Label("Descripción") + yield Input(id="description-input") + yield Label("Tags (separados por coma)") + yield Input(id="tags-input") + with Horizontal(classes="form-row"): + yield Button("Elegir carpeta de fotos", id="pick-folder-button") + yield Static("(ninguna carpeta elegida)", id="folder-summary") + yield Button("Continuar →", id="continue-button", variant="primary", disabled=True) + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "title-input" and not self._slug_touched: + self.query_one("#slug-input", Input).value = content.slugify(event.value) + elif event.input.id == "slug-input": + self._slug_touched = True + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "pick-folder-button": + self.run_worker(self._pick_folder(), exclusive=True) + elif event.button.id == "continue-button": + self.run_worker(self._continue(), exclusive=True) + + async def _pick_folder(self) -> None: + path = await self.app.push_screen_wait(FolderPickerScreen()) + if path is None: + return + error = self.query_one("#album-error", Static) + try: + files = content.collect_images(path) + except ValueError as e: + error.update(f"[red]{e}[/red]") + return + error.update("") + self._source_files = files + names = ", ".join(f.name for f in files[:6]) + more = f" (+{len(files) - 6})" if len(files) > 6 else "" + self.query_one("#folder-summary", Static).update(f"{len(files)} fotos: {names}{more}") + self.query_one("#continue-button", Button).disabled = False + + async def _continue(self) -> None: + error = self.query_one("#album-error", Static) + title = self.query_one("#title-input", Input).value.strip() + description = self.query_one("#description-input", Input).value.strip() + slug = content.slugify(self.query_one("#slug-input", Input).value.strip() or title) + + if not title or not description: + error.update("[red]Título y descripción son obligatorios.[/red]") + return + if not self._source_files: + error.update("[red]Elegí una carpeta con fotos.[/red]") + return + if (IMAGES_DIR / slug).exists() or (PHOTOS_DIR / slug).exists(): + error.update(f"[red]El álbum '{slug}' ya existe.[/red]") + return + error.update("") + + tags = [t.strip() for t in self.query_one("#tags-input", Input).value.split(",") if t.strip()] + + dest_dir = IMAGES_DIR / slug + dest_dir.mkdir(parents=True) + for f in self._source_files: + shutil.copy2(f, dest_dir / f.name) + + items = [f"/images/{slug}/{f.name}::{slug}" for f in self._source_files] + body = content.gallery_shortcode(items) + frontmatter = content.build_album_frontmatter( + title, description, tags, f"/images/{slug}/{self._source_files[0].name}" + ) + content_dir = PHOTOS_DIR / slug + content_dir.mkdir(parents=True) + (content_dir / "index.md").write_text( + frontmatter + f"\n# {title}\n\n" + body, encoding="utf-8" + ) + + pushed = await self.app.push_screen_wait( + ConfirmPushScreen( + [dest_dir, content_dir], + f"Álbum nuevo: {title}", + preview_markdown=f"# {title}\n\n{len(self._source_files)} fotos: {body}", + ) + ) + if pushed: + await self.app.push_screen_wait(SuccessScreen("¡Álbum publicado!")) + self.app.pop_screen() blob - /dev/null blob + ccbb15077a0462999e57526686cc53ae1479401b (mode 644) --- /dev/null +++ nezzontli_ctl/screens/confirm.py @@ -0,0 +1,53 @@ +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Label, Markdown, Static + +from nezzontli_ctl import git_ops + + +class ConfirmPushScreen(ModalScreen[bool]): + """Hace git add de los paths, muestra el diff --stat + preview, y pide + confirmación antes de commitear y pushear. Devuelve True si se pusheó, + False si se canceló (los archivos quedan igual, staged, sin commitear).""" + + BINDINGS = [("escape", "cancel", "Cancelar")] + + def __init__(self, paths, commit_message: str, preview_markdown: str = ""): + super().__init__() + self._paths = paths + self._commit_message = commit_message + self._preview_markdown = preview_markdown + + def compose(self): + with Vertical(classes="form-container"): + yield Label("Confirmar", classes="form-label") + yield Static("", id="confirm-diff") + if self._preview_markdown: + yield Markdown(self._preview_markdown, id="confirm-preview") + yield Static("", id="confirm-error") + with Horizontal(): + yield Button("Commitear y pushear", id="confirm-yes", variant="success") + yield Button("Cancelar", id="confirm-no") + + def on_mount(self) -> None: + git_ops.stage(self._paths) + diff = git_ops.diff_stat_cached() or "(sin cambios detectados)" + self.query_one("#confirm-diff", Static).update(diff) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "confirm-yes": + event.button.disabled = True + self.query_one("#confirm-no", Button).disabled = True + self.run_worker(self._do_push(), exclusive=True) + else: + self.action_cancel() + + async def _do_push(self) -> None: + ok, output = git_ops.commit_and_push(self._commit_message) + if ok: + self.dismiss(True) + else: + self.query_one("#confirm-error", Static).update(f"[red]Error al pushear:[/red]\n{output}") + + def action_cancel(self) -> None: + self.dismiss(False) blob - /dev/null blob + 29079fcf882fe122f05af357d3cac88cb2ebcc2a (mode 644) --- /dev/null +++ nezzontli_ctl/screens/editor.py @@ -0,0 +1,48 @@ +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Footer, Markdown, Static, TextArea + + +class EditorScreen(ModalScreen[str]): + """Editor de cuerpo Markdown con preview en vivo. Devuelve el texto final + al llamador (ctrl+s / botón Listo) o None si se cancela (escape).""" + + BINDINGS = [ + ("ctrl+s", "submit", "Listo"), + ("escape", "cancel", "Cancelar"), + ] + + def __init__(self, initial_text: str = "", title: str = "Cuerpo"): + super().__init__() + self._initial_text = initial_text + self._title = title + + def compose(self): + with Vertical(): + yield Static(f" {self._title} — Ctrl+S para guardar, Esc para cancelar", id="editor-title") + with Horizontal(classes="editor-split"): + yield TextArea( + self._initial_text, + language="markdown", + show_line_numbers=True, + id="editor-textarea", + classes="editor-pane", + ) + yield Markdown(self._initial_text, id="editor-preview", classes="editor-pane") + yield Footer() + + def on_mount(self) -> None: + text_area = self.query_one("#editor-textarea", TextArea) + text_area.focus() + text_area.cursor_location = text_area.document.end + + def on_text_area_changed(self, event: TextArea.Changed) -> None: + preview = self.query_one("#editor-preview", Markdown) + preview.update(event.text_area.text) + + def action_submit(self) -> None: + text = self.query_one("#editor-textarea", TextArea).text + self.dismiss(text) + + def action_cancel(self) -> None: + self.dismiss(None) blob - /dev/null blob + fc14684a1efb70124633f5ef462ee5395ca6186a (mode 644) --- /dev/null +++ nezzontli_ctl/screens/folder_picker.py @@ -0,0 +1,27 @@ +from pathlib import Path + +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import DirectoryTree, Static + + +class FolderPickerScreen(ModalScreen[Path]): + """Navegador de carpetas. Enter sobre una carpeta la selecciona. + Devuelve el Path elegido, o None si se cancela (escape).""" + + BINDINGS = [("escape", "cancel", "Cancelar")] + + def __init__(self, start_path: Path | None = None): + super().__init__() + self._start_path = start_path or Path.home() + + def compose(self): + with Vertical(classes="form-container"): + yield Static(" Elegí la carpeta con las fotos — Enter para confirmar, Esc para cancelar") + yield DirectoryTree(str(self._start_path), id="folder-tree") + + def on_directory_tree_directory_selected(self, event: DirectoryTree.DirectorySelected) -> None: + self.dismiss(event.path) + + def action_cancel(self) -> None: + self.dismiss(None) blob - /dev/null blob + 8e5e51db427e37ae7ed7b4c2e937b93438e80bbb (mode 644) --- /dev/null +++ nezzontli_ctl/screens/menu.py @@ -0,0 +1,59 @@ +import pyfiglet +from textual.containers import Vertical +from textual.screen import Screen +from textual.widgets import Footer, OptionList, Static +from textual.widgets.option_list import Option + +BANNER = pyfiglet.Figlet(font="small").renderText("nezzontli") + +MENU_ITEMS = [ + ("post", "Nuevo post de blog"), + ("page", "Nueva página en un proyecto existente"), + ("album", "Nuevo álbum de fotos"), + ("add-photos", "Agregar fotos a un álbum existente"), + ("settings", "Configuración"), + ("quit", "Salir"), +] + + +class MenuScreen(Screen): + def compose(self): + with Vertical(id="menu-container"): + yield Static(BANNER, id="banner") + yield Static("gestor de contenido — sin CMS", id="subtitle") + yield OptionList( + *[Option(label, id=key) for key, label in MENU_ITEMS], + id="menu-options", + ) + yield Footer() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + key = event.option.id + if key == "quit": + self.app.exit() + return + if key == "settings": + from nezzontli_ctl.screens.settings import SettingsScreen + + self.app.push_screen(SettingsScreen()) + return + if key == "post": + from nezzontli_ctl.screens.post import NewPostScreen + + self.app.push_screen(NewPostScreen()) + return + if key == "page": + from nezzontli_ctl.screens.page import NewPageScreen + + self.app.push_screen(NewPageScreen()) + return + if key == "album": + from nezzontli_ctl.screens.album import NewAlbumScreen + + self.app.push_screen(NewAlbumScreen()) + return + if key == "add-photos": + from nezzontli_ctl.screens.add_photos import AddPhotosScreen + + self.app.push_screen(AddPhotosScreen()) + return blob - /dev/null blob + 71daeae1457eea579aacea9ea681fd066d517473 (mode 644) --- /dev/null +++ nezzontli_ctl/screens/page.py @@ -0,0 +1,123 @@ +from textual.containers import Vertical +from textual.screen import Screen +from textual.widgets import Button, Input, Label, Select, SelectionList, Static + +from nezzontli_ctl import content +from nezzontli_ctl.config import BLOG_DIR +from nezzontli_ctl.screens.confirm import ConfirmPushScreen +from nezzontli_ctl.screens.editor import EditorScreen +from nezzontli_ctl.screens.success import SuccessScreen + + +def _list_projects(): + if not BLOG_DIR.is_dir(): + return [] + return sorted( + p.name for p in BLOG_DIR.iterdir() if p.is_dir() and (p / "index.md").is_file() + ) + + +class NewPageScreen(Screen): + BINDINGS = [("escape", "app.pop_screen", "Volver")] + + _slug_touched = False + + def compose(self): + projects = _list_projects() + with Vertical(classes="form-container"): + yield Label("Nueva página en un proyecto existente", classes="form-label") + yield Static("", id="page-error") + yield Label("Proyecto") + yield Select( + [(p, p) for p in projects], id="project-select", allow_blank=True, prompt="Elegí un proyecto" + ) + yield Label("Título") + yield Input(id="title-input") + yield Label("Slug (nombre de archivo)") + yield Input(id="slug-input") + yield Label("Descripción") + yield Input(id="description-input") + yield Label("Tags (separados por coma)") + yield Input(id="tags-input") + yield Label("Páginas relacionadas (opcional)", id="related-label") + yield SelectionList(id="related-list") + yield Button("Continuar →", id="continue-button", variant="primary") + + def on_mount(self) -> None: + self.query_one("#related-label").display = False + self.query_one("#related-list").display = False + + def on_select_changed(self, event: Select.Changed) -> None: + if event.select.id != "project-select": + return + project = event.value + related_list = self.query_one("#related-list", SelectionList) + related_list.clear_options() + if project and project is not Select.BLANK: + project_dir = BLOG_DIR / project + siblings = sorted( + p.stem for p in project_dir.glob("*.md") if p.stem != "index" + ) + if siblings: + for name in siblings: + related_list.add_option((name, name)) + self.query_one("#related-label").display = True + related_list.display = True + return + self.query_one("#related-label").display = False + related_list.display = False + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "title-input" and not self._slug_touched: + self.query_one("#slug-input", Input).value = content.slugify(event.value) + elif event.input.id == "slug-input": + self._slug_touched = True + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "continue-button": + self.run_worker(self._continue(), exclusive=True) + + async def _continue(self) -> None: + error = self.query_one("#page-error", Static) + project = self.query_one("#project-select", Select).value + title = self.query_one("#title-input", Input).value.strip() + description = self.query_one("#description-input", Input).value.strip() + slug = content.slugify(self.query_one("#slug-input", Input).value.strip() or title) + + if not project or project is Select.BLANK: + error.update("[red]Elegí un proyecto.[/red]") + return + if not title or not description: + error.update("[red]Título y descripción son obligatorios.[/red]") + return + target_file = BLOG_DIR / project / f"{slug}.md" + if target_file.exists(): + error.update(f"[red]content/blog/{project}/{slug}.md ya existe.[/red]") + return + error.update("") + + tags = [t.strip() for t in self.query_one("#tags-input", Input).value.split(",") if t.strip()] + related_list = self.query_one("#related-list", SelectionList) + related = list(related_list.selected) if related_list.display else [] + + body = await self.app.push_screen_wait( + EditorScreen(initial_text=f"# {title}\n\n", title=f"Cuerpo de \"{title}\"") + ) + if body is None: + return + + frontmatter = content.build_page_frontmatter( + title, description, tags, ["B.E. Alejandro"], related=related + ) + target_file.write_text(frontmatter + "\n" + body, encoding="utf-8") + + pushed = await self.app.push_screen_wait( + ConfirmPushScreen( + [target_file], + f'{project}: nueva página "{title}"', + preview_markdown=f"# {title}\n\n{body}", + ) + ) + if pushed: + await self.app.push_screen_wait(SuccessScreen("¡Publicado!")) + self.app.pop_screen() blob - /dev/null blob + 7b07c4b7ccd0872de87bd118737c0d9f06f914dc (mode 644) --- /dev/null +++ nezzontli_ctl/screens/post.py @@ -0,0 +1,108 @@ +from textual.containers import Horizontal, Vertical +from textual.screen import Screen +from textual.widgets import Button, Input, Label, Static, Switch + +from nezzontli_ctl import content +from nezzontli_ctl.config import BLOG_DIR +from nezzontli_ctl.screens.confirm import ConfirmPushScreen +from nezzontli_ctl.screens.editor import EditorScreen +from nezzontli_ctl.screens.success import SuccessScreen + + +class NewPostScreen(Screen): + BINDINGS = [("escape", "app.pop_screen", "Volver")] + + _slug_touched = False + + def compose(self): + with Vertical(classes="form-container"): + yield Label("Nuevo post de blog", classes="form-label") + yield Static("", id="post-error") + yield Label("Título") + yield Input(id="title-input") + yield Label("Slug (carpeta)") + yield Input(id="slug-input") + yield Label("Descripción") + yield Input(id="description-input") + yield Label("Tags (separados por coma)") + yield Input(id="tags-input") + with Horizontal(classes="form-row"): + yield Label("KaTeX ") + yield Switch(id="katex-switch") + with Horizontal(classes="form-row"): + yield Label("Comentarios de Mastodon ") + yield Switch(id="comments-switch") + with Vertical(id="comments-fields"): + yield Label("Instancia (host)") + yield Input(value="masto.es", id="comments-host") + yield Label("Usuario (sin @)") + yield Input(id="comments-user") + yield Label("ID del toot") + yield Input(id="comments-id") + yield Button("Continuar →", id="continue-button", variant="primary") + + def on_mount(self) -> None: + self.query_one("#comments-fields").display = False + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "title-input" and not self._slug_touched: + self.query_one("#slug-input", Input).value = content.slugify(event.value) + elif event.input.id == "slug-input": + self._slug_touched = True + + def on_switch_changed(self, event: Switch.Changed) -> None: + if event.switch.id == "comments-switch": + self.query_one("#comments-fields").display = event.value + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "continue-button": + self.run_worker(self._continue(), exclusive=True) + + async def _continue(self) -> None: + title = self.query_one("#title-input", Input).value.strip() + description = self.query_one("#description-input", Input).value.strip() + slug = content.slugify(self.query_one("#slug-input", Input).value.strip() or title) + error = self.query_one("#post-error", Static) + + if not title or not description: + error.update("[red]Título y descripción son obligatorios.[/red]") + return + target_dir = BLOG_DIR / slug + if target_dir.exists(): + error.update(f"[red]content/blog/{slug}/ ya existe.[/red]") + return + error.update("") + + tags = [t.strip() for t in self.query_one("#tags-input", Input).value.split(",") if t.strip()] + katex = self.query_one("#katex-switch", Switch).value + comments = None + if self.query_one("#comments-switch", Switch).value: + comments = { + "host": self.query_one("#comments-host", Input).value.strip(), + "user": self.query_one("#comments-user", Input).value.strip(), + "id": self.query_one("#comments-id", Input).value.strip(), + } + + body = await self.app.push_screen_wait( + EditorScreen(initial_text=f"# {title}\n\n", title=f"Cuerpo de \"{title}\"") + ) + if body is None: + return + + frontmatter = content.build_post_frontmatter( + title, description, tags, ["B.E. Alejandro"], katex=katex, comments=comments + ) + target_dir.mkdir(parents=True) + target_file = target_dir / "index.md" + target_file.write_text(frontmatter + "\n" + body, encoding="utf-8") + + pushed = await self.app.push_screen_wait( + ConfirmPushScreen( + [target_dir], + f"Nuevo post: {title}", + preview_markdown=f"# {title}\n\n{body}", + ) + ) + if pushed: + await self.app.push_screen_wait(SuccessScreen("¡Publicado!")) + self.app.pop_screen() blob - /dev/null blob + c860c4a7079d8f153c095e56afb25ea62e6b3807 (mode 644) --- /dev/null +++ nezzontli_ctl/screens/settings.py @@ -0,0 +1,47 @@ +import cowsay +from textual.containers import Vertical +from textual.screen import Screen +from textual.widgets import Footer, Input, Label, OptionList +from textual.widgets.option_list import Option + +from nezzontli_ctl.config import load_prefs, save_prefs + + +class SettingsScreen(Screen): + BINDINGS = [("escape", "app.pop_screen", "Volver")] + + def compose(self): + prefs = load_prefs() + with Vertical(classes="form-container"): + yield Label("Configuración", classes="form-label") + yield Label("Animal de cowsay para el mensaje de éxito:") + option_list = OptionList( + *[Option(name, id=name) for name in cowsay.char_names], + id="cowsay-picker", + ) + yield option_list + yield Label("Carpeta del repo del sitio:") + yield Input(value=prefs["repo_path"], id="repo-path-input") + yield Footer() + + def on_mount(self) -> None: + prefs = load_prefs() + option_list = self.query_one("#cowsay-picker", OptionList) + try: + index = cowsay.char_names.index(prefs["cowsay_char"]) + option_list.highlighted = index + except ValueError: + pass + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + prefs = load_prefs() + prefs["cowsay_char"] = event.option.id + save_prefs(prefs) + self.notify(f"Animal elegido: {event.option.id}") + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "repo-path-input": + prefs = load_prefs() + prefs["repo_path"] = event.value + save_prefs(prefs) + self.notify("Ruta del repo guardada (efectiva en el próximo inicio).") blob - /dev/null blob + 609c48bb02f93201d646e053823aaeadc6d4c54e (mode 644) --- /dev/null +++ nezzontli_ctl/screens/success.py @@ -0,0 +1,24 @@ +import cowsay +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Static + +from nezzontli_ctl.config import load_prefs + + +class SuccessScreen(ModalScreen[None]): + BINDINGS = [("escape", "close", "Volver al menú"), ("enter", "close", "Volver al menú")] + + def __init__(self, message: str = "¡Publicado!"): + super().__init__() + self._message = message + + def compose(self): + char = load_prefs()["cowsay_char"] + art = cowsay.get_output_string(char, self._message) + with Vertical(id="success-container"): + yield Static(art, id="cowsay-output") + yield Static("(Enter / Esc para volver al menú)") + + def action_close(self) -> None: + self.dismiss(None) blob - /dev/null blob + 2c97151cbe88cf7c9e1d0a32bf02b13e2d76d0ce (mode 644) --- /dev/null +++ nezzontli_ctl/styles.tcss @@ -0,0 +1,70 @@ +#menu-container { + align: center middle; + height: 100%; + width: 100%; +} + +#banner { + width: auto; + color: $accent; + text-style: bold; + content-align: center middle; +} + +#subtitle { + width: auto; + color: $text-muted; + margin-bottom: 1; +} + +#menu-options { + width: 48; + max-height: 12; +} + +.form-container { + padding: 1 2; + height: 100%; +} + +.form-row { + height: auto; + margin-bottom: 1; +} + +.form-label { + width: 100%; + color: $text-muted; + text-style: bold; +} + +.editor-split { + height: 1fr; +} + +.editor-pane { + width: 1fr; + height: 100%; + border: solid $panel; +} + +#confirm-diff { + height: 1fr; + border: solid $panel; + padding: 1; +} + +#confirm-preview { + height: 1fr; + border: solid $panel; + padding: 1; +} + +#success-container { + align: center middle; + height: 100%; +} + +#cowsay-output { + color: $success; +} blob - /dev/null blob + 053dcca8e5e1d430715defbd5c1de6828b836905 (mode 644) --- /dev/null +++ nezzontli_ctl/theme.py @@ -0,0 +1,19 @@ +"""Tema Gruvbox dark — misma paleta que usa nezzontli.xyz +(sass/_variables.scss del sitio: acento #98971a / #b8bb26 en modo dark).""" + +from textual.theme import Theme + +GRUVBOX = Theme( + name="gruvbox-nezzontli", + primary="#83a598", + secondary="#d3869b", + accent="#b8bb26", + warning="#fabd2f", + error="#fb4934", + success="#b8bb26", + foreground="#ebdbb2", + background="#282828", + surface="#3c3836", + panel="#504945", + dark=True, +)