Commit Diff


commit - 418394b83f2ec0036a9953fffbf57be5c0ec36e4
commit + 67e37ea4e902fd4b618e5df04e97d18df742d5ab
blob - 097a72e20e41acd456b02cc89e3597b744065c59
blob + e315514f4a5db03fa3d154b4651e66412a61237b
--- nezzontli_ctl/screens/edit_album.py
+++ nezzontli_ctl/screens/edit_album.py
@@ -1,10 +1,13 @@
-from textual.containers import Vertical
+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 PHOTOS_DIR
+from nezzontli_ctl import content, git_ops
+from nezzontli_ctl.config import IMAGES_DIR, PHOTOS_DIR
 from nezzontli_ctl.screens.confirm import ConfirmPushScreen
+from nezzontli_ctl.screens.confirm_delete import ConfirmDeleteScreen
 from nezzontli_ctl.screens.editor import EditorScreen
 from nezzontli_ctl.screens.success import SuccessScreen
 
@@ -54,7 +57,9 @@ class EditAlbumScreen(Screen):
                 "Para agregar fotos, usá \"Agregar fotos a un álbum existente\" en el menú.",
                 classes="form-row",
             )
-            yield Button("Guardar cambios →", id="continue-button", variant="primary")
+            with Horizontal(classes="form-row"):
+                yield Button("Guardar cambios →", id="continue-button", variant="primary")
+                yield Button("Eliminar", id="delete-button", variant="error")
 
     def on_mount(self) -> None:
         data, body = content.parse_frontmatter(self._existing_file.read_text(encoding="utf-8"))
@@ -69,7 +74,34 @@ class EditAlbumScreen(Screen):
     def on_button_pressed(self, event: Button.Pressed) -> None:
         if event.button.id == "continue-button":
             self.run_worker(self._continue(), exclusive=True)
+        elif event.button.id == "delete-button":
+            self.run_worker(self._delete(), exclusive=True)
 
+    async def _delete(self) -> None:
+        album_dir = self._existing_file.parent
+        slug = album_dir.name
+        title = self.query_one("#title-input", Input).value.strip() or slug
+        images_dir = IMAGES_DIR / slug
+        confirmed = await self.app.push_screen_wait(
+            ConfirmDeleteScreen(
+                f'el álbum "{title}" (content/photos/{slug}/ y static/images/{slug}/, fotos incluidas)'
+            )
+        )
+        if not confirmed:
+            return
+        shutil.rmtree(album_dir)
+        paths = [album_dir]
+        if images_dir.is_dir():
+            shutil.rmtree(images_dir)
+            paths.append(images_dir)
+        git_ops.stage(paths)
+        ok, output = git_ops.commit_and_push(f'Elimina álbum: "{title}"')
+        if not ok:
+            self.query_one("#album-error", Static).update(f"[red]Error al pushear:[/red]\n{output}")
+            return
+        await self.app.push_screen_wait(SuccessScreen("¡Eliminado!"))
+        self.app.pop_screen()
+
     async def _continue(self) -> None:
         error = self.query_one("#album-error", Static)
         title = self.query_one("#title-input", Input).value.strip()
blob - /dev/null
blob + b4b48a7a0b888e290e222d1283530539c707bacf (mode 644)
--- /dev/null
+++ nezzontli_ctl/screens/confirm_delete.py
@@ -0,0 +1,44 @@
+from textual.containers import Horizontal, Vertical
+from textual.screen import ModalScreen
+from textual.widgets import Button, Label, Static
+
+
+class ConfirmDeleteScreen(ModalScreen[bool]):
+    """Pide confirmar la eliminación DOS VECES (dos preguntas seguidas en el
+    mismo modal) antes de devolver True. False si se cancela en cualquiera
+    de las dos."""
+
+    BINDINGS = [("escape", "cancel", "Cancelar")]
+
+    _MESSAGES = [
+        "¿Seguro que querés eliminar esto?",
+        "¿Estás completamente seguro? Esta acción no se puede deshacer.",
+    ]
+
+    def __init__(self, what: str):
+        super().__init__()
+        self._what = what
+        self._step = 0
+
+    def compose(self):
+        with Vertical(classes="form-container"):
+            yield Label("Eliminar", classes="form-label")
+            yield Static(f"Se va a eliminar: {self._what}", id="delete-target")
+            yield Static(self._MESSAGES[0], id="delete-message")
+            with Horizontal():
+                yield Button("Sí, eliminar", id="delete-yes", variant="error")
+                yield Button("Cancelar", id="delete-no")
+
+    def on_button_pressed(self, event: Button.Pressed) -> None:
+        if event.button.id != "delete-yes":
+            self.dismiss(False)
+            return
+        if self._step == 0:
+            self._step = 1
+            self.query_one("#delete-message", Static).update(self._MESSAGES[1])
+            self.query_one("#delete-yes", Button).label = "Sí, eliminar definitivamente"
+            return
+        self.dismiss(True)
+
+    def action_cancel(self) -> None:
+        self.dismiss(False)
blob - 0530ec9eed21f8484ac903f0647ebffaced46a96
blob + 74523264276e087ffbaaf6b6ac444a307dc4be25
--- nezzontli_ctl/screens/menu.py
+++ nezzontli_ctl/screens/menu.py
@@ -7,12 +7,8 @@ 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"),
-    ("edit-post", "Editar un post o página existente"),
-    ("album", "Nuevo álbum de fotos"),
-    ("add-photos", "Agregar fotos a un álbum existente"),
-    ("edit-album", "Editar un álbum existente"),
+    ("create", "Crear nuevo…"),
+    ("edit", "Editar existente…"),
     ("settings", "Configuración"),
     ("quit", "Salir"),
 ]
@@ -39,6 +35,40 @@ class MenuScreen(Screen):
 
             self.app.push_screen(SettingsScreen())
             return
+        if key == "create":
+            self.app.push_screen(CreateMenuScreen())
+            return
+        if key == "edit":
+            self.app.push_screen(EditMenuScreen())
+            return
+
+
+CREATE_MENU_ITEMS = [
+    ("post", "Nuevo post de blog"),
+    ("page", "Nueva página en un proyecto existente"),
+    ("album", "Nuevo álbum de fotos"),
+    ("back", "← Volver"),
+]
+
+
+class CreateMenuScreen(Screen):
+    BINDINGS = [("escape", "app.pop_screen", "Volver")]
+
+    def compose(self):
+        with Vertical(id="menu-container"):
+            yield Static(BANNER, id="banner")
+            yield Static("crear nuevo", id="subtitle")
+            yield OptionList(
+                *[Option(label, id=key) for key, label in CREATE_MENU_ITEMS],
+                id="menu-options",
+            )
+        yield Footer()
+
+    def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+        key = event.option.id
+        if key == "back":
+            self.app.pop_screen()
+            return
         if key == "post":
             from nezzontli_ctl.screens.post import NewPostScreen
 
@@ -54,6 +84,35 @@ class MenuScreen(Screen):
 
             self.app.push_screen(NewAlbumScreen())
             return
+
+
+EDIT_MENU_ITEMS = [
+    ("edit-post", "Post de blog"),
+    ("edit-page", "Página extra de un proyecto"),
+    ("edit-album", "Álbum de fotos"),
+    ("add-photos", "Agregar fotos a un álbum existente"),
+    ("back", "← Volver"),
+]
+
+
+class EditMenuScreen(Screen):
+    BINDINGS = [("escape", "app.pop_screen", "Volver")]
+
+    def compose(self):
+        with Vertical(id="menu-container"):
+            yield Static(BANNER, id="banner")
+            yield Static("editar existente", id="subtitle")
+            yield OptionList(
+                *[Option(label, id=key) for key, label in EDIT_MENU_ITEMS],
+                id="menu-options",
+            )
+        yield Footer()
+
+    def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+        key = event.option.id
+        if key == "back":
+            self.app.pop_screen()
+            return
         if key == "add-photos":
             from nezzontli_ctl.screens.add_photos import AddPhotosScreen
 
@@ -62,6 +121,9 @@ class MenuScreen(Screen):
         if key == "edit-post":
             self.run_worker(self._pick_and_edit_post(), exclusive=True)
             return
+        if key == "edit-page":
+            self.run_worker(self._pick_and_edit_page(), exclusive=True)
+            return
         if key == "edit-album":
             self.run_worker(self._pick_and_edit_album(), exclusive=True)
             return
@@ -80,6 +142,20 @@ class MenuScreen(Screen):
         if chosen is not None:
             self.app.push_screen(NewPostScreen(existing_file=chosen))
 
+    async def _pick_and_edit_page(self) -> None:
+        from nezzontli_ctl.screens.page import NewPageScreen, list_extra_pages
+        from nezzontli_ctl.screens.select_existing import SelectExistingScreen
+
+        items = list_extra_pages()
+        if not items:
+            self.notify("No hay páginas extra todavía.")
+            return
+        chosen = await self.app.push_screen_wait(
+            SelectExistingScreen(items, title="Elegí una página para editar")
+        )
+        if chosen is not None:
+            self.app.push_screen(NewPageScreen(existing_file=chosen))
+
     async def _pick_and_edit_album(self) -> None:
         from nezzontli_ctl.screens.edit_album import EditAlbumScreen, list_albums
         from nezzontli_ctl.screens.select_existing import SelectExistingScreen
blob - 270f8d92b763d235d0362cd824cf0ed3cf95a210
blob + ce855ff773a42908f87019417993cdd69ef3c232
--- nezzontli_ctl/screens/page.py
+++ nezzontli_ctl/screens/page.py
@@ -1,10 +1,11 @@
-from textual.containers import Vertical
+from textual.containers import Horizontal, Vertical
 from textual.screen import Screen
 from textual.widgets import Button, Input, Label, Select, SelectionList, Static
 
-from nezzontli_ctl import content
+from nezzontli_ctl import content, git_ops
 from nezzontli_ctl.config import BLOG_DIR
 from nezzontli_ctl.screens.confirm import ConfirmPushScreen
+from nezzontli_ctl.screens.confirm_delete import ConfirmDeleteScreen
 from nezzontli_ctl.screens.editor import EditorScreen
 from nezzontli_ctl.screens.success import SuccessScreen
 
@@ -17,36 +18,116 @@ def _list_projects():
     )
 
 
+def list_extra_pages():
+    """(label, Path) de cada content/blog/<proyecto>/<slug>.md que NO es
+    index.md — las páginas extra de un proyecto (ramas, capítulos, etc.),
+    para el picker de edición."""
+    if not BLOG_DIR.is_dir():
+        return []
+    items = []
+    for project_dir in sorted(BLOG_DIR.iterdir()):
+        if not project_dir.is_dir():
+            continue
+        for page_file in sorted(project_dir.glob("*.md")):
+            if page_file.stem == "index":
+                continue
+            try:
+                data, _ = content.parse_frontmatter(page_file.read_text(encoding="utf-8"))
+            except Exception:
+                data = {}
+            title = data.get("title", page_file.stem)
+            items.append((f"{title}  ({project_dir.name}/{page_file.name})", page_file))
+    return items
+
+
 class NewPageScreen(Screen):
     BINDINGS = [("escape", "app.pop_screen", "Volver")]
 
     _slug_touched = False
+    _last_auto_slug = None
 
+    def __init__(self, existing_file=None):
+        super().__init__()
+        self._existing_file = existing_file
+        self._existing_body = ""
+        self._existing_date = None
+
     def compose(self):
+        editing = self._existing_file is not None
         projects = _list_projects()
         with Vertical(classes="form-container"):
-            yield Label("Nueva página en un proyecto existente", classes="form-label")
+            yield Label(
+                "Editar página" if editing else "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"
-            )
+            if editing:
+                yield Label("Proyecto")
+                yield Static(self._existing_file.parent.name, id="project-static")
+            else:
+                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")
+            if not editing:
+                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")
+            with Horizontal(classes="form-row"):
+                yield Button(
+                    "Guardar cambios →" if editing else "Continuar →",
+                    id="continue-button",
+                    variant="primary",
+                )
+                if editing:
+                    yield Button("Eliminar", id="delete-button", variant="error")
 
     def on_mount(self) -> None:
-        self.query_one("#related-label").display = False
-        self.query_one("#related-list").display = False
+        related_list = self.query_one("#related-list", SelectionList)
+        if self._existing_file is None:
+            self.query_one("#related-label").display = False
+            related_list.display = False
+            return
 
+        data, body = content.parse_frontmatter(
+            self._existing_file.read_text(encoding="utf-8")
+        )
+        self._existing_body = body
+        self._existing_date = data.get("date")
+        self.query_one("#title-input", Input).value = data.get("title", "")
+        self.query_one("#description-input", Input).value = data.get("description", "")
+        tags = data.get("taxonomies", {}).get("tags", [])
+        self.query_one("#tags-input", Input).value = ", ".join(tags)
+        self._slug_touched = True  # no aplica al editar, no hay campo de slug
+
+        related_saved = data.get("extra", {}).get("related_pages", [])
+        siblings = sorted(
+            p.stem
+            for p in self._existing_file.parent.glob("*.md")
+            if p.stem not in ("index", self._existing_file.stem)
+        )
+        if siblings:
+            for name in siblings:
+                related_list.add_option((name, name))
+            for name in related_saved:
+                if name in siblings:
+                    related_list.select(name)
+            self.query_one("#related-label").display = True
+            related_list.display = True
+        else:
+            self.query_one("#related-label").display = False
+            related_list.display = False
+
     def on_select_changed(self, event: Select.Changed) -> None:
         if event.select.id != "project-select":
             return
@@ -67,8 +148,6 @@ class NewPageScreen(Screen):
         self.query_one("#related-label").display = False
         related_list.display = False
 
-    _last_auto_slug = None
-
     def on_input_changed(self, event: Input.Changed) -> None:
         if event.input.id == "title-input" and not self._slug_touched:
             self._last_auto_slug = content.slugify(event.value)
@@ -79,48 +158,77 @@ class NewPageScreen(Screen):
     def on_button_pressed(self, event: Button.Pressed) -> None:
         if event.button.id == "continue-button":
             self.run_worker(self._continue(), exclusive=True)
+        elif event.button.id == "delete-button":
+            self.run_worker(self._delete(), exclusive=True)
 
+    async def _delete(self) -> None:
+        project = self._existing_file.parent.name
+        title = self.query_one("#title-input", Input).value.strip() or self._existing_file.stem
+        confirmed = await self.app.push_screen_wait(
+            ConfirmDeleteScreen(f'la página "{title}" ({project}/{self._existing_file.name})')
+        )
+        if not confirmed:
+            return
+        self._existing_file.unlink()
+        git_ops.stage([self._existing_file])
+        ok, output = git_ops.commit_and_push(f'{project}: elimina página "{title}"')
+        if not ok:
+            self.query_one("#page-error", Static).update(f"[red]Error al pushear:[/red]\n{output}")
+            return
+        await self.app.push_screen_wait(SuccessScreen("¡Eliminado!"))
+        self.app.pop_screen()
+
     async def _continue(self) -> None:
+        editing = self._existing_file is not 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 editing:
+            project = self._existing_file.parent.name
+            target_file = self._existing_file
+        else:
+            project = self.query_one("#project-select", Select).value
+            if not project or project is Select.BLANK:
+                error.update("[red]Elegí un proyecto.[/red]")
+                return
+            slug = content.slugify(self.query_one("#slug-input", Input).value.strip() or title)
+            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
         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 []
 
+        initial_body = self._existing_body if editing else f"# {title}\n\n"
         body = await self.app.push_screen_wait(
-            EditorScreen(initial_text=f"# {title}\n\n", title=f"Cuerpo de \"{title}\"")
+            EditorScreen(initial_text=initial_body, title=f"Cuerpo de \"{title}\"")
         )
         if body is None:
             return
 
         frontmatter = content.build_page_frontmatter(
-            title, description, tags, ["B.E. Alejandro"], related=related
+            title, description, tags, ["B.E. Alejandro"],
+            related=related, dt=self._existing_date,
         )
         target_file.write_text(frontmatter + "\n" + body, encoding="utf-8")
 
+        message = f'{project}: actualiza "{title}"' if editing else f'{project}: nueva página "{title}"'
         pushed = await self.app.push_screen_wait(
             ConfirmPushScreen(
                 [target_file],
-                f'{project}: nueva página "{title}"',
+                message,
                 preview_markdown=f"# {title}\n\n{body}",
             )
         )
         if pushed:
-            await self.app.push_screen_wait(SuccessScreen("¡Publicado!"))
+            await self.app.push_screen_wait(
+                SuccessScreen("¡Actualizado!" if editing else "¡Publicado!")
+            )
             self.app.pop_screen()
blob - 8d5958201c891f3c13b90dcb4a0cc28c6d066865
blob + a3bcac9feb9c939f3a49d53684643596593f92b4
--- nezzontli_ctl/screens/post.py
+++ nezzontli_ctl/screens/post.py
@@ -1,10 +1,13 @@
+import shutil
+
 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 import content, git_ops
 from nezzontli_ctl.config import BLOG_DIR
 from nezzontli_ctl.screens.confirm import ConfirmPushScreen
+from nezzontli_ctl.screens.confirm_delete import ConfirmDeleteScreen
 from nezzontli_ctl.screens.editor import EditorScreen
 from nezzontli_ctl.screens.success import SuccessScreen
 
@@ -68,11 +71,14 @@ class NewPostScreen(Screen):
                 yield Input(id="comments-user")
                 yield Label("ID del toot")
                 yield Input(id="comments-id")
-            yield Button(
-                "Guardar cambios →" if editing else "Continuar →",
-                id="continue-button",
-                variant="primary",
-            )
+            with Horizontal(classes="form-row"):
+                yield Button(
+                    "Guardar cambios →" if editing else "Continuar →",
+                    id="continue-button",
+                    variant="primary",
+                )
+                if editing:
+                    yield Button("Eliminar", id="delete-button", variant="error")
 
     def on_mount(self) -> None:
         self.query_one("#comments-fields").display = False
@@ -111,7 +117,26 @@ class NewPostScreen(Screen):
     def on_button_pressed(self, event: Button.Pressed) -> None:
         if event.button.id == "continue-button":
             self.run_worker(self._continue(), exclusive=True)
+        elif event.button.id == "delete-button":
+            self.run_worker(self._delete(), exclusive=True)
 
+    async def _delete(self) -> None:
+        project_dir = self._existing_file.parent
+        title = self.query_one("#title-input", Input).value.strip() or project_dir.name
+        confirmed = await self.app.push_screen_wait(
+            ConfirmDeleteScreen(f'el post "{title}" (content/blog/{project_dir.name}/ completo)')
+        )
+        if not confirmed:
+            return
+        shutil.rmtree(project_dir)
+        git_ops.stage([project_dir])
+        ok, output = git_ops.commit_and_push(f'Elimina post: "{title}"')
+        if not ok:
+            self.query_one("#post-error", Static).update(f"[red]Error al pushear:[/red]\n{output}")
+            return
+        await self.app.push_screen_wait(SuccessScreen("¡Eliminado!"))
+        self.app.pop_screen()
+
     async def _continue(self) -> None:
         editing = self._existing_file is not None
         title = self.query_one("#title-input", Input).value.strip()