commit - 418394b83f2ec0036a9953fffbf57be5c0ec36e4
commit + 67e37ea4e902fd4b618e5df04e97d18df742d5ab
blob - 097a72e20e41acd456b02cc89e3597b744065c59
blob + e315514f4a5db03fa3d154b4651e66412a61237b
--- nezzontli_ctl/screens/edit_album.py
+++ nezzontli_ctl/screens/edit_album.py
-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
"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"))
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
+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
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"),
]
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
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
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
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
-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
)
+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
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)
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
+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
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
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()