commit - bd3729dd6f06c79e0afe271673ff815f499637fc
commit + 9674a8f813d32178fc9de63eedffd15586af6c07
blob - d4009889a9672adf4dd541e34133bd29f82f9d5b
blob + 4f8dd7a0d1455b9bb9a8d6959c55c7b3ed419651
--- README.md
+++ README.md
## 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
+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
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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+#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
+"""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,
+)