Commit Diff


commit - 415a550db68b43a152ad5b756f4b919ae67f3045
commit + 4b3dbef4eb99c9a691e02a0ec65087a5a749c7d0
blob - ad46fa519f0d0fc8b1b0b4a22b22a7d7add1d4ce
blob + 14d0283655f3642f715371b3a4f3cabf2636525a
--- nezzontli_ctl/content.py
+++ nezzontli_ctl/content.py
@@ -4,12 +4,23 @@ Migrado tal cual del ctl.py anterior (probado ya en es
 """
 
 import re
+import tomllib
 from datetime import date
 from pathlib import Path
 
 from nezzontli_ctl.config import IMAGE_EXTENSIONS
 
 
+def parse_frontmatter(text):
+    """Separa +++ TOML +++ / cuerpo. Devuelve (dict, cuerpo)."""
+    if not text.startswith("+++"):
+        return {}, text
+    _, rest = text.split("+++", 1)
+    raw_toml, body = rest.split("+++", 1)
+    data = tomllib.loads(raw_toml)
+    return data, body.lstrip("\n")
+
+
 def slugify(text):
     text = text.strip().lower()
     text = re.sub(r"[^a-z0-9]+", "-", text)
@@ -24,13 +35,23 @@ def toml_list(values):
     return "[" + ", ".join(toml_str(v) for v in values) + "]"
 
 
+def _toml_date(dt):
+    """dt puede ser un date (lo normal) o un string (si el archivo original
+    tenía la fecha entre comillas — Zola la acepta igual, pero tomllib la
+    parsea como string, no como date)."""
+    if dt is None:
+        dt = date.today()
+    if hasattr(dt, "isoformat"):
+        return dt.isoformat()
+    return toml_str(str(dt))
+
+
 def build_post_frontmatter(title, description, tags, authors, katex=False, comments=None, dt=None):
-    dt = dt or date.today()
     lines = [
         f"title = {toml_str(title)}",
         f"description = {toml_str(description)}",
         f"authors = {toml_list(authors)}",
-        f"date = {dt.isoformat()}",
+        f"date = {_toml_date(dt)}",
     ]
     if tags:
         lines.append("[taxonomies]")
@@ -49,13 +70,12 @@ def build_post_frontmatter(title, description, tags, a
 
 
 def build_page_frontmatter(title, description, tags, authors, related=None, dt=None):
-    dt = dt or date.today()
     lines = [
         'template = "article.html"',
         f"title = {toml_str(title)}",
         f"description = {toml_str(description)}",
         f"authors = {toml_list(authors)}",
-        f"date = {dt.isoformat()}",
+        f"date = {_toml_date(dt)}",
     ]
     if tags:
         lines.append("[taxonomies]")
@@ -67,10 +87,9 @@ def build_page_frontmatter(title, description, tags, a
 
 
 def build_album_frontmatter(title, description, tags, cover_path, dt=None):
-    dt = dt or date.today()
     lines = [
         f"title = {toml_str(title)}",
-        f"date = {dt.isoformat()}",
+        f"date = {_toml_date(dt)}",
         f"description = {toml_str(description)}",
     ]
     if tags:
blob - 29079fcf882fe122f05af357d3cac88cb2ebcc2a
blob + 77afa72c4c380a08898428d2c7d5b293c68b7161
--- nezzontli_ctl/screens/editor.py
+++ nezzontli_ctl/screens/editor.py
@@ -1,4 +1,4 @@
-from textual.containers import Horizontal, Vertical
+from textual.containers import Horizontal, Vertical, VerticalScroll
 from textual.screen import ModalScreen
 from textual.widgets import Footer, Markdown, Static, TextArea
 
@@ -19,7 +19,7 @@ class EditorScreen(ModalScreen[str]):
 
     def compose(self):
         with Vertical():
-            yield Static(f" {self._title} — Ctrl+S para guardar, Esc para cancelar", id="editor-title")
+            yield Static(f" {self._title}", id="editor-title")
             with Horizontal(classes="editor-split"):
                 yield TextArea(
                     self._initial_text,
@@ -28,14 +28,25 @@ class EditorScreen(ModalScreen[str]):
                     id="editor-textarea",
                     classes="editor-pane",
                 )
-                yield Markdown(self._initial_text, id="editor-preview", classes="editor-pane")
+                with VerticalScroll(id="editor-preview-scroll", classes="editor-pane"):
+                    yield Markdown(self._initial_text, id="editor-preview")
         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
+        self.watch(text_area, "scroll_y", self._sync_preview_scroll)
 
+    def _sync_preview_scroll(self, old_value: float, new_value: float) -> None:
+        text_area = self.query_one("#editor-textarea", TextArea)
+        preview_scroll = self.query_one("#editor-preview-scroll", VerticalScroll)
+        max_ta = text_area.max_scroll_y
+        if not max_ta:
+            return
+        ratio = max(0.0, min(1.0, new_value / max_ta))
+        preview_scroll.scroll_y = ratio * preview_scroll.max_scroll_y
+
     def on_text_area_changed(self, event: TextArea.Changed) -> None:
         preview = self.query_one("#editor-preview", Markdown)
         preview.update(event.text_area.text)
blob - /dev/null
blob + 097a72e20e41acd456b02cc89e3597b744065c59 (mode 644)
--- /dev/null
+++ nezzontli_ctl/screens/edit_album.py
@@ -0,0 +1,104 @@
+from textual.containers import 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.screens.confirm import ConfirmPushScreen
+from nezzontli_ctl.screens.editor import EditorScreen
+from nezzontli_ctl.screens.success import SuccessScreen
+
+
+def list_albums():
+    """(label, Path) de cada content/photos/<slug>/index.md."""
+    if not PHOTOS_DIR.is_dir():
+        return []
+    items = []
+    for album_dir in sorted(PHOTOS_DIR.iterdir()):
+        index_file = album_dir / "index.md"
+        if not index_file.is_file():
+            continue
+        try:
+            data, _ = content.parse_frontmatter(index_file.read_text(encoding="utf-8"))
+        except Exception:
+            data = {}
+        title = data.get("title", album_dir.name)
+        items.append((f"{title}  ({album_dir.name})", index_file))
+    return items
+
+
+class EditAlbumScreen(Screen):
+    """Edita título/descripción/tags/cuerpo de un álbum existente. Para
+    agregar fotos nuevas, usar 'Agregar fotos a un álbum existente'."""
+
+    BINDINGS = [("escape", "app.pop_screen", "Volver")]
+
+    def __init__(self, existing_file):
+        super().__init__()
+        self._existing_file = existing_file
+        self._existing_body = ""
+        self._cover_image = None
+        self._existing_date = None
+
+    def compose(self):
+        with Vertical(classes="form-container"):
+            yield Label("Editar álbum", classes="form-label")
+            yield Static("", id="album-error")
+            yield Label("Título")
+            yield Input(id="title-input")
+            yield Label("Descripción")
+            yield Input(id="description-input")
+            yield Label("Tags (separados por coma)")
+            yield Input(id="tags-input")
+            yield Static(
+                "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")
+
+    def on_mount(self) -> None:
+        data, body = content.parse_frontmatter(self._existing_file.read_text(encoding="utf-8"))
+        self._existing_body = body
+        self._cover_image = data.get("extra", {}).get("cover_image")
+        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)
+
+    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("#album-error", Static)
+        title = self.query_one("#title-input", Input).value.strip()
+        description = self.query_one("#description-input", Input).value.strip()
+        if not title or not description:
+            error.update("[red]Título y descripción son obligatorios.[/red]")
+            return
+        error.update("")
+
+        tags = [t.strip() for t in self.query_one("#tags-input", Input).value.split(",") if t.strip()]
+
+        body = await self.app.push_screen_wait(
+            EditorScreen(initial_text=self._existing_body, title=f'Cuerpo de "{title}"')
+        )
+        if body is None:
+            return
+
+        frontmatter = content.build_album_frontmatter(
+            title, description, tags, self._cover_image or "", dt=self._existing_date
+        )
+        self._existing_file.write_text(frontmatter + "\n" + body, encoding="utf-8")
+
+        pushed = await self.app.push_screen_wait(
+            ConfirmPushScreen(
+                [self._existing_file],
+                f'Actualiza álbum: "{title}"',
+                preview_markdown=f"# {title}\n\n{body}",
+            )
+        )
+        if pushed:
+            await self.app.push_screen_wait(SuccessScreen("¡Actualizado!"))
+            self.app.pop_screen()
blob - 8e5e51db427e37ae7ed7b4c2e937b93438e80bbb
blob + 0530ec9eed21f8484ac903f0647ebffaced46a96
--- nezzontli_ctl/screens/menu.py
+++ nezzontli_ctl/screens/menu.py
@@ -9,8 +9,10 @@ BANNER = pyfiglet.Figlet(font="small").renderText("nez
 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"),
     ("settings", "Configuración"),
     ("quit", "Salir"),
 ]
@@ -57,3 +59,37 @@ class MenuScreen(Screen):
 
             self.app.push_screen(AddPhotosScreen())
             return
+        if key == "edit-post":
+            self.run_worker(self._pick_and_edit_post(), exclusive=True)
+            return
+        if key == "edit-album":
+            self.run_worker(self._pick_and_edit_album(), exclusive=True)
+            return
+
+    async def _pick_and_edit_post(self) -> None:
+        from nezzontli_ctl.screens.post import NewPostScreen, list_posts
+        from nezzontli_ctl.screens.select_existing import SelectExistingScreen
+
+        items = list_posts()
+        if not items:
+            self.notify("No hay posts todavía.")
+            return
+        chosen = await self.app.push_screen_wait(
+            SelectExistingScreen(items, title="Elegí un post para editar")
+        )
+        if chosen is not None:
+            self.app.push_screen(NewPostScreen(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
+
+        items = list_albums()
+        if not items:
+            self.notify("No hay álbumes todavía.")
+            return
+        chosen = await self.app.push_screen_wait(
+            SelectExistingScreen(items, title="Elegí un álbum para editar")
+        )
+        if chosen is not None:
+            self.app.push_screen(EditAlbumScreen(existing_file=chosen))
blob - 51cee41e2bf7261fe4dcf7c1a8c1ba0e3cca852b
blob + 8d5958201c891f3c13b90dcb4a0cc28c6d066865
--- nezzontli_ctl/screens/post.py
+++ nezzontli_ctl/screens/post.py
@@ -9,19 +9,48 @@ from nezzontli_ctl.screens.editor import EditorScreen
 from nezzontli_ctl.screens.success import SuccessScreen
 
 
+def list_posts():
+    """(label, Path) de cada content/blog/<slug>/index.md, para el picker de edición."""
+    if not BLOG_DIR.is_dir():
+        return []
+    items = []
+    for project_dir in sorted(BLOG_DIR.iterdir()):
+        index_file = project_dir / "index.md"
+        if not index_file.is_file():
+            continue
+        try:
+            data, _ = content.parse_frontmatter(index_file.read_text(encoding="utf-8"))
+        except Exception:
+            data = {}
+        title = data.get("title", project_dir.name)
+        items.append((f"{title}  ({project_dir.name})", index_file))
+    return items
+
+
 class NewPostScreen(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
         with Vertical(classes="form-container"):
-            yield Label("Nuevo post de blog", classes="form-label")
+            yield Label(
+                "Editar post" if editing else "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")
+            if not editing:
+                yield Label("Slug (carpeta)")
+                yield Input(id="slug-input")
             yield Label("Descripción")
             yield Input(id="description-input")
             yield Label("Tags (separados por coma)")
@@ -39,13 +68,35 @@ class NewPostScreen(Screen):
                 yield Input(id="comments-user")
                 yield Label("ID del toot")
                 yield Input(id="comments-id")
-            yield Button("Continuar →", id="continue-button", variant="primary")
+            yield Button(
+                "Guardar cambios →" if editing else "Continuar →",
+                id="continue-button",
+                variant="primary",
+            )
 
     def on_mount(self) -> None:
         self.query_one("#comments-fields").display = False
+        if self._existing_file is not None:
+            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)
+            extra = data.get("extra", {})
+            self.query_one("#katex-switch", Switch).value = bool(extra.get("katex", False))
+            comments = extra.get("comments")
+            if comments:
+                self.query_one("#comments-switch", Switch).value = True
+                self.query_one("#comments-fields").display = True
+                self.query_one("#comments-host", Input).value = comments.get("host", "masto.es")
+                self.query_one("#comments-user", Input).value = comments.get("user", "")
+                self.query_one("#comments-id", Input).value = comments.get("id", "")
+            self._slug_touched = True  # no aplica al editar, no hay campo de slug
 
-    _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)
@@ -62,18 +113,24 @@ class NewPostScreen(Screen):
             self.run_worker(self._continue(), exclusive=True)
 
     async def _continue(self) -> None:
+        editing = self._existing_file is not 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
+
+        if editing:
+            target_file = self._existing_file
+        else:
+            slug = content.slugify(self.query_one("#slug-input", Input).value.strip() or title)
+            target_dir = BLOG_DIR / slug
+            if target_dir.exists():
+                error.update(f"[red]content/blog/{slug}/ ya existe.[/red]")
+                return
+            target_file = target_dir / "index.md"
         error.update("")
 
         tags = [t.strip() for t in self.query_one("#tags-input", Input).value.split(",") if t.strip()]
@@ -86,26 +143,31 @@ class NewPostScreen(Screen):
                 "id": self.query_one("#comments-id", Input).value.strip(),
             }
 
+        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_post_frontmatter(
-            title, description, tags, ["B.E. Alejandro"], katex=katex, comments=comments
+            title, description, tags, ["B.E. Alejandro"],
+            katex=katex, comments=comments, dt=self._existing_date,
         )
-        target_dir.mkdir(parents=True)
-        target_file = target_dir / "index.md"
+        if not editing:
+            target_file.parent.mkdir(parents=True)
         target_file.write_text(frontmatter + "\n" + body, encoding="utf-8")
 
+        message = f'Actualiza: "{title}"' if editing else f"Nuevo post: {title}"
         pushed = await self.app.push_screen_wait(
             ConfirmPushScreen(
-                [target_dir],
-                f"Nuevo post: {title}",
+                [target_file if editing else target_file.parent],
+                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 - /dev/null
blob + a7c221c679c11bc5425c7e3e1747d16cef0caa96 (mode 644)
--- /dev/null
+++ nezzontli_ctl/screens/select_existing.py
@@ -0,0 +1,30 @@
+from textual.containers import Vertical
+from textual.screen import ModalScreen
+from textual.widgets import OptionList, Static
+from textual.widgets.option_list import Option
+
+
+class SelectExistingScreen(ModalScreen):
+    """Lista genérica de (label, valor) para elegir uno. Devuelve el valor
+    elegido (lo que sea — un Path, un string) o None si se cancela."""
+
+    BINDINGS = [("escape", "cancel", "Cancelar")]
+
+    def __init__(self, items, title="Elegí uno"):
+        super().__init__()
+        self._items = list(items)
+        self._title = title
+
+    def compose(self):
+        with Vertical(classes="form-container"):
+            yield Static(f" {self._title}")
+            yield OptionList(
+                *[Option(label, id=str(i)) for i, (label, _value) in enumerate(self._items)]
+            )
+
+    def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+        index = int(event.option.id)
+        self.dismiss(self._items[index][1])
+
+    def action_cancel(self) -> None:
+        self.dismiss(None)
blob - 2c97151cbe88cf7c9e1d0a32bf02b13e2d76d0ce
blob + 60f5416419df679aa787d2359d83a41de5734327
--- nezzontli_ctl/styles.tcss
+++ nezzontli_ctl/styles.tcss
@@ -46,6 +46,7 @@
     width: 1fr;
     height: 100%;
     border: solid $panel;
+    overflow-y: auto;
 }
 
 #confirm-diff {