Commit Diff


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,
+)