diff --git a/.gitignore b/.gitignore index ca05f50..629a919 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,146 @@ +# Database files database.db -__pycache__ -/.idea/ + +.build-vend/ +dist_nuitka/ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ +build/ +temp/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +Pipfile.lock + +# poetry +poetry.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.env.* +.venv +venv/ +ENV/ +env/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# IDE settings +.vscode/ +.idea/ diff --git a/.idea/Pilgrim.iml b/.idea/Pilgrim.iml deleted file mode 100644 index 3ce9a18..0000000 --- a/.idea/Pilgrim.iml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index daedced..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml deleted file mode 100644 index cd36295..0000000 --- a/.idea/workspace.xml +++ /dev/null @@ -1,200 +0,0 @@ - - - - - - - - - - - - - - - - - - { - "lastFilter": { - "state": "OPEN", - "assignee": "gmbrax" - } -} - { - "selectedUrlAndAccountId": { - "url": "https://github.com/gmbrax/Pilgrim.git", - "accountId": "213d8456-c67d-4cfd-99a6-337d47c35b4a" - } -} - { - "associatedIndex": 0 -} - - - - - - - - - - - - - - - - - - - - - - - - 1748985568579 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/README.md b/README.md index d9baacb..7d8d17a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,50 @@ # Python_Pilgrim -Python Based Travel Diary \ No newline at end of file +## Overview + +**Python_Pilgrim** is a Python-based travel diary application designed to help users document and manage their travel experiences. The project provides tools for recording trips, organizing travel notes, and storing memories in a structured and accessible format. + +## Features + +- Create and manage travel diaries +- Add, edit, and delete travel entries +- Organize trips by date, location, or theme +- Store photos, notes, and other media +- Export and share travel logs + +## Requirements +- Python 3.8 or higher +- Linux operating system (tested on Ubuntu 20.04+) +- Visual Studio Code (VSCode) for development (optional but strongly recommended) +- pip (Python package installer) +- Optional: virtualenv for isolated environments + +## Installation + +1. Clone the repository: + ```bash + git clone https://github.com/gmbrax/Pilgrim.git + ``` +2. Navigate to the project directory: + ```bash + cd Pilgrim + ``` +3. Create a virtual environment and, then, activate it: + ```bash + python -m venv .venv + source .venv/bin/activate + ``` +4. Install the required dependencies: + ```bash + pip install -r requirements.txt + ``` + +## Usage + +To run the main application, execute: + +```bash +python .py +``` + +This will start the Python_Pilgrim application. Follow the on-screen instructions to create and manage your travel diaries. \ No newline at end of file diff --git a/src/pilgrim/application.py b/src/pilgrim/application.py index abf7b68..674cc0c 100644 --- a/src/pilgrim/application.py +++ b/src/pilgrim/application.py @@ -1,5 +1,4 @@ from pilgrim.database import Database -from pilgrim.service.mocks.service_manager_mock import ServiceManagerMock from pilgrim.service.servicemanager import ServiceManager from pilgrim.ui.ui import UIApp diff --git a/src/pilgrim/database.py b/src/pilgrim/database.py index 389b15b..b940c54 100644 --- a/src/pilgrim/database.py +++ b/src/pilgrim/database.py @@ -12,10 +12,13 @@ class Database: echo=False, connect_args={"check_same_thread": False}, ) - self.session = sessionmaker(bind=self.engine, autoflush=False, autocommit=False) + self._session_maker = sessionmaker(bind=self.engine, autoflush=False, autocommit=False) def create(self): Base.metadata.create_all(self.engine) + def session(self): + return self._session_maker() + def get_db(self): - return self.session() + return self._session_maker() diff --git a/src/pilgrim/models/photo.py b/src/pilgrim/models/photo.py index f6e0406..a164474 100644 --- a/src/pilgrim/models/photo.py +++ b/src/pilgrim/models/photo.py @@ -1,6 +1,8 @@ from typing import Any +from datetime import datetime +from pathlib import Path -from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy import Column, Integer, String, ForeignKey, DateTime from sqlalchemy.orm import relationship from pilgrim.models.photo_in_entry import photo_entry_association @@ -12,7 +14,7 @@ class Photo(Base): id = Column(Integer, primary_key=True) filepath = Column(String) name = Column(String) - addition_date = Column(String) + addition_date = Column(DateTime, default=datetime.now) caption = Column(String) entries = relationship( "Entry", @@ -22,10 +24,16 @@ class Photo(Base): fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False) - def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, **kw: Any): + def __init__(self, filepath, name, addition_date=None, caption=None, entries=None, fk_travel_diary_id=None, **kw: Any): super().__init__(**kw) - self.filepath = filepath + # Convert Path to string if needed + if isinstance(filepath, Path): + self.filepath = str(filepath) + else: + self.filepath = filepath self.name = name - self.addition_date = addition_date + self.addition_date = addition_date if addition_date is not None else datetime.now() self.caption = caption - self.entries = entries + self.entries = entries if entries is not None else [] + if fk_travel_diary_id is not None: + self.fk_travel_diary_id = fk_travel_diary_id diff --git a/src/pilgrim/service/mocks/photo_service_mock.py b/src/pilgrim/service/mocks/photo_service_mock.py index 1b02c56..0f9078a 100644 --- a/src/pilgrim/service/mocks/photo_service_mock.py +++ b/src/pilgrim/service/mocks/photo_service_mock.py @@ -12,7 +12,14 @@ class PhotoServiceMock(PhotoService): self._next_id = 1 def create(self, filepath: Path, name: str, travel_diary_id, addition_date=None, caption=None) -> Photo | None: - new_photo = Photo(filepath, name, addition_date=addition_date, caption=caption) + new_photo = Photo( + filepath=filepath, + name=name, + addition_date=addition_date, + caption=caption, + fk_travel_diary_id=travel_diary_id + ) + new_photo.id = self._next_id self.mock_data[self._next_id] = new_photo self._next_id += 1 return new_photo @@ -24,19 +31,18 @@ class PhotoServiceMock(PhotoService): def read_all(self) -> List[Photo]: return list(self.mock_data.values()) - def update(self, photo_id: Photo, photo_dst: Photo) -> Photo | None: - item_to_update:Photo = self.mock_data.get(photo_id) + def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None: + item_to_update: Photo = self.mock_data.get(photo_src.id) if item_to_update: item_to_update.filepath = photo_dst.filepath if photo_dst.filepath else item_to_update.filepath item_to_update.name = photo_dst.name if photo_dst.name else item_to_update.name item_to_update.caption = photo_dst.caption if photo_dst.caption else item_to_update.caption - item_to_update.addition_date = photo_dst.addition_date if photo_dst.addition_date\ - else item_to_update.addition_date - item_to_update.fk_travel_diary_id = photo_dst.fk_travel_diary_id if photo_dst.fk_travel_diary_id \ - else item_to_update.fk_travel_diary_id - item_to_update.entries.extend(photo_dst.entries) + item_to_update.addition_date = photo_dst.addition_date if photo_dst.addition_date else item_to_update.addition_date + item_to_update.fk_travel_diary_id = photo_dst.fk_travel_diary_id if photo_dst.fk_travel_diary_id else item_to_update.fk_travel_diary_id + if photo_dst.entries: + item_to_update.entries = photo_dst.entries return item_to_update return None - def delete(self, photo_id: int) -> Photo | None: - return self.mock_data.pop(photo_id, None) + def delete(self, photo_src: Photo) -> Photo | None: + return self.mock_data.pop(photo_src.id, None) diff --git a/src/pilgrim/service/photo_service.py b/src/pilgrim/service/photo_service.py index ef7c0c6..df87902 100644 --- a/src/pilgrim/service/photo_service.py +++ b/src/pilgrim/service/photo_service.py @@ -1,5 +1,6 @@ from pathlib import Path from typing import List +from datetime import datetime from pilgrim.models.photo import Photo @@ -9,11 +10,25 @@ class PhotoService: def __init__(self, session): self.session = session - def create(self, filepath:Path, name:str, travel_diary_id, addition_date=None, caption=None, ) -> Photo | None: + def create(self, filepath: Path, name: str, travel_diary_id: int, caption=None, addition_date=None) -> Photo | None: travel_diary = self.session.query(TravelDiary).filter(TravelDiary.id == travel_diary_id).first() if not travel_diary: return None - new_photo = Photo(filepath, name, addition_date=addition_date, caption=caption) + + # Convert addition_date string to datetime if needed + if isinstance(addition_date, str): + try: + addition_date = datetime.strptime(addition_date, "%Y-%m-%d %H:%M:%S") + except ValueError: + addition_date = None + + new_photo = Photo( + filepath=filepath, + name=name, + caption=caption, + fk_travel_diary_id=travel_diary_id, + addition_date=addition_date + ) self.session.add(new_photo) self.session.commit() self.session.refresh(new_photo) @@ -25,24 +40,37 @@ class PhotoService: def read_all(self) -> List[Photo]: return self.session.query(Photo).all() - def update(self,photo_src:Photo,photo_dst:Photo) -> Photo | None: - original:Photo = self.read_by_id(photo_src.id) + def update(self, photo_src: Photo, photo_dst: Photo) -> Photo | None: + original: Photo = self.read_by_id(photo_src.id) if original: original.filepath = photo_dst.filepath original.name = photo_dst.name original.addition_date = photo_dst.addition_date original.caption = photo_dst.caption - original.entries.extend(photo_dst.entries) + if photo_dst.entries and len(photo_dst.entries) > 0: + if original.entries is None: + original.entries = [] + original.entries = photo_dst.entries # Replace instead of extend self.session.commit() self.session.refresh(original) return original return None - def delete(self, photo_src:Photo) -> Photo | None: + def delete(self, photo_src: Photo) -> Photo | None: excluded = self.read_by_id(photo_src.id) if excluded: + # Store photo data before deletion + deleted_photo = Photo( + filepath=excluded.filepath, + name=excluded.name, + addition_date=excluded.addition_date, + caption=excluded.caption, + fk_travel_diary_id=excluded.fk_travel_diary_id, + id=excluded.id + ) + self.session.delete(excluded) self.session.commit() - self.session.refresh(excluded) - return excluded + + return deleted_photo return None diff --git a/src/pilgrim/service/servicemanager.py b/src/pilgrim/service/servicemanager.py index b0b0cde..b3ef9d0 100644 --- a/src/pilgrim/service/servicemanager.py +++ b/src/pilgrim/service/servicemanager.py @@ -1,4 +1,5 @@ from pilgrim.service.entry_service import EntryService +from pilgrim.service.photo_service import PhotoService from pilgrim.service.travel_diary_service import TravelDiaryService @@ -17,3 +18,7 @@ class ServiceManager: if self.session is not None: return TravelDiaryService(self.session) return None + def get_photo_service(self): + if self.session is not None: + return PhotoService(self.session) + return None \ No newline at end of file diff --git a/src/pilgrim/ui/screens/edit_entry_screen.py b/src/pilgrim/ui/screens/edit_entry_screen.py index a38b8df..1408ccd 100644 --- a/src/pilgrim/ui/screens/edit_entry_screen.py +++ b/src/pilgrim/ui/screens/edit_entry_screen.py @@ -1,15 +1,21 @@ from typing import Optional, List import asyncio from datetime import datetime +from pathlib import Path from textual.app import ComposeResult from textual.screen import Screen -from textual.widgets import Header, Footer, Static, TextArea +from textual.widgets import Header, Footer, Static, TextArea, OptionList, Input, Button from textual.binding import Binding -from textual.containers import Container, Horizontal +from textual.containers import Container, Horizontal, Vertical, ScrollableContainer from pilgrim.models.entry import Entry from pilgrim.models.travel_diary import TravelDiary +from pilgrim.models.photo import Photo +from pilgrim.ui.screens.modals.add_photo_modal import AddPhotoModal +from pilgrim.ui.screens.modals.edit_photo_modal import EditPhotoModal +from pilgrim.ui.screens.modals.confirm_delete_modal import ConfirmDeleteModal +from pilgrim.ui.screens.modals.file_picker_modal import FilePickerModal from pilgrim.ui.screens.rename_entry_modal import RenameEntryModal @@ -21,13 +27,16 @@ class EditEntryScreen(Screen): Binding("ctrl+n", "next_entry", "Next/New Entry"), Binding("ctrl+b", "prev_entry", "Previous Entry"), Binding("ctrl+r", "rename_entry", "Rename Entry"), - Binding("escape", "back_to_list", "Back to List") + Binding("escape", "back_to_list", "Back to List"), + Binding("f8", "toggle_sidebar", "Toggle Sidebar"), + Binding("f9", "toggle_focus", "Focus Sidebar/Editor"), ] def __init__(self, diary_id: int = 1): + print("DEBUG: EditEntryScreen INIT") super().__init__() self.diary_id = diary_id - self.diary_name = f"Diary {diary_id}" # Use a better default name + self.diary_name = f"Diary {diary_id}" self.current_entry_index = 0 self.entries: List[Entry] = [] self.is_new_entry = False @@ -38,6 +47,9 @@ class EditEntryScreen(Screen): self._updating_display = False self._original_content = "" self.is_refreshing = False + self.sidebar_visible = False + self.sidebar_focused = False + self._sidebar_opened_once = False # Main header self.header = Header(name="Pilgrim v6", classes="EditEntryScreen-header") @@ -60,6 +72,27 @@ class EditEntryScreen(Screen): # Text area self.text_entry = TextArea(id="text_entry", classes="EditEntryScreen-text-entry") + # Sidebar widgets + self.sidebar_title = Static("📸 Photos", classes="EditEntryScreen-sidebar-title") + self.photo_list = OptionList(id="photo_list", classes="EditEntryScreen-sidebar-photo-list") + self.photo_info = Static("", classes="EditEntryScreen-sidebar-photo-info") + self.help_text = Static("", classes="EditEntryScreen-sidebar-help") + + # Sidebar container: photo list and info in a flexible container, help_text fixed at bottom + self.sidebar_content = Vertical( + self.photo_list, + self.photo_info, + id="sidebar_content", + classes="EditEntryScreen-sidebar-content" + ) + self.sidebar = Vertical( + self.sidebar_title, + self.sidebar_content, + self.help_text, # Always at the bottom, never scrolls + id="sidebar", + classes="EditEntryScreen-sidebar" + ) + # Main container self.main = Container( self.sub_header, @@ -71,16 +104,32 @@ class EditEntryScreen(Screen): # Footer self.footer = Footer(classes="EditEntryScreen-footer") + def _update_footer_context(self): + """Forces footer refresh to show updated bindings""" + self.refresh() + def compose(self) -> ComposeResult: + print("DEBUG: EditEntryScreen COMPOSE", getattr(self, 'sidebar_visible', None)) yield self.header - yield self.main + yield Horizontal( + self.main, + self.sidebar, + id="content_container", + classes="EditEntryScreen-content-container" + ) yield self.footer def on_mount(self) -> None: """Called when the screen is mounted""" + self.sidebar.display = False + self.sidebar_visible = False + # First update diary info, then refresh entries self.update_diary_info() self.refresh_entries() + + # Initialize footer with editor context + self._update_footer_context() def update_diary_info(self): """Updates diary information""" @@ -93,17 +142,14 @@ class EditEntryScreen(Screen): self.diary_name = diary.name self.diary_info.update(f"Diary: {self.diary_name}") else: - # If diary not found, try to get a default name self.diary_name = f"Diary {self.diary_id}" self.diary_info.update(f"Diary: {self.diary_name}") self.notify(f"Diary {self.diary_id} not found, using default name") except Exception as e: - # If there's an error, use a default name but don't break the app self.diary_name = f"Diary {self.diary_id}" self.diary_info.update(f"Diary: {self.diary_name}") self.notify(f"Error loading diary info: {str(e)}") - # Always ensure the diary info is updated self._ensure_diary_info_updated() def _ensure_diary_info_updated(self): @@ -111,7 +157,6 @@ class EditEntryScreen(Screen): try: self.diary_info.update(f"Diary: {self.diary_name}") except Exception as e: - # If even this fails, at least try to show something self.diary_info.update(f"Diary: {self.diary_id}") def refresh_entries(self): @@ -120,14 +165,10 @@ class EditEntryScreen(Screen): service_manager = self.app.service_manager entry_service = service_manager.get_entry_service() - # Get all entries for this diary all_entries = entry_service.read_all() self.entries = [entry for entry in all_entries if entry.fk_travel_diary_id == self.diary_id] - - # Sort by ID self.entries.sort(key=lambda x: x.id) - # Update next entry ID if self.entries: self.next_entry_id = max(entry.id for entry in self.entries) + 1 else: @@ -139,41 +180,8 @@ class EditEntryScreen(Screen): except Exception as e: self.notify(f"Error loading entries: {str(e)}") - # Ensure diary info is updated even if entries fail to load self._ensure_diary_info_updated() - async def async_refresh_entries(self): - """Asynchronous version of refresh""" - if self.is_refreshing: - return - - self.is_refreshing = True - - try: - service_manager = self.app.service_manager - entry_service = service_manager.get_entry_service() - - # For now, use synchronous method since mock doesn't have async - all_entries = entry_service.read_all() - self.entries = [entry for entry in all_entries if entry.fk_travel_diary_id == self.diary_id] - - # Sort by ID - self.entries.sort(key=lambda x: x.id) - - # Update next entry ID - if self.entries: - self.next_entry_id = max(entry.id for entry in self.entries) + 1 - else: - self.next_entry_id = 1 - - self._update_entry_display() - self._update_sub_header() - - except Exception as e: - self.notify(f"Error loading entries: {str(e)}") - finally: - self.is_refreshing = False - def _update_status_indicator(self, text: str, css_class: str): """Helper to update status indicator text and class.""" self.status_indicator.update(text) @@ -211,6 +219,8 @@ class EditEntryScreen(Screen): """Finishes the display update by reactivating change detection""" self._updating_display = False self._update_sub_header() + if self.sidebar_visible: + self._update_sidebar_content() def _update_entry_display(self): """Updates the display of the current entry""" @@ -237,6 +247,342 @@ class EditEntryScreen(Screen): self.call_after_refresh(self._finish_display_update) + def _update_sidebar_content(self): + """Updates the sidebar content with photos for the current diary""" + photos = self._load_photos_for_diary(self.diary_id) + + # Clear existing options safely + self.photo_list.clear_options() + + # Add 'Ingest Photo' option at the top + self.photo_list.add_option("➕ Ingest Photo") + + if not photos: + self.photo_info.update("No photos found for this diary") + self.help_text.update("📸 No photos available\n\nUse Photo Manager to add photos") + return + + # Add photos to the list + for photo in photos: + self.photo_list.add_option(f"📷 {photo.name}") + + self.photo_info.update(f"📸 {len(photos)} photos in diary") + + # English, visually distinct help text + help_text = ( + "[b]⌨️ Sidebar Shortcuts[/b]\n" + "[b][green]i[/green][/b]: Insert photo into entry\n" + "[b][green]n[/green][/b]: Add new photo\n" + "[b][green]d[/green][/b]: Delete selected photo\n" + "[b][green]e[/green][/b]: Edit selected photo\n" + "[b][yellow]Tab[/yellow][/b]: Back to editor\n" + "[b][yellow]F8[/yellow][/b]: Show/hide sidebar\n" + "[b][yellow]F9[/yellow][/b]: Switch focus (if needed)" + ) + self.help_text.update(help_text) + + def _load_photos_for_diary(self, diary_id: int) -> List[Photo]: + """Loads all photos for the specific diary""" + try: + service_manager = self.app.service_manager + photo_service = service_manager.get_photo_service() + + all_photos = photo_service.read_all() + photos = [photo for photo in all_photos if photo.fk_travel_diary_id == diary_id] + photos.sort(key=lambda x: x.id) + return photos + except Exception as e: + self.notify(f"Error loading photos: {str(e)}") + return [] + + def action_toggle_sidebar(self): + """Toggles the sidebar visibility""" + print("DEBUG: TOGGLE SIDEBAR", self.sidebar_visible) + self.sidebar_visible = not self.sidebar_visible + + if self.sidebar_visible: + self.sidebar.display = True + self._update_sidebar_content() + # Automatically focus the sidebar when opening + self.sidebar_focused = True + self.photo_list.focus() + # Notification when opening the sidebar for the first time + if not self._sidebar_opened_once: + self.notify( + "Sidebar opened and focused! Use the shortcuts shown in the help panel.", + severity="info" + ) + self._sidebar_opened_once = True + else: + self.sidebar.display = False + self.sidebar_focused = False # Reset focus when hiding + self.text_entry.focus() # Return focus to editor + + # Update footer after context change + self._update_footer_context() + self.refresh(layout=True) + + def action_toggle_focus(self): + """Toggles focus between editor and sidebar""" + print("DEBUG: TOGGLE FOCUS", self.sidebar_visible, self.sidebar_focused) + if not self.sidebar_visible: + # If sidebar is not visible, show it and focus it + self.action_toggle_sidebar() + return + + self.sidebar_focused = not self.sidebar_focused + if self.sidebar_focused: + self.photo_list.focus() + else: + self.text_entry.focus() + + # Update footer after focus change + self._update_footer_context() + + def action_insert_photo(self): + """Insert selected photo into text""" + if not self.sidebar_focused or not self.sidebar_visible: + self.notify("Use F8 to open the sidebar first.", severity="warning") + return + + # Get selected photo + if self.photo_list.highlighted is None: + self.notify("No photo selected", severity="warning") + return + + # Adjust index because of 'Ingest Photo' at the top + photo_index = self.photo_list.highlighted - 1 + + photos = self._load_photos_for_diary(self.diary_id) + if photo_index < 0 or photo_index >= len(photos): + self.notify("No photo selected", severity="warning") + return + + selected_photo = photos[photo_index] + + # Insert photo reference into text + photo_ref = f"\n[📷 {selected_photo.name}]({selected_photo.filepath})\n" + if selected_photo.caption: + photo_ref += f"*{selected_photo.caption}*\n" + + # Insert at cursor position or at end + current_text = self.text_entry.text + cursor_position = len(current_text) # Insert at end for now + new_text = current_text + photo_ref + self.text_entry.text = new_text + + self.notify(f"Inserted photo: {selected_photo.name}") + + def action_ingest_new_photo(self): + """Ingest a new photo using modal""" + if not self.sidebar_focused or not self.sidebar_visible: + self.notify("Use F8 to open the sidebar first.", severity="warning") + return + + # Open add photo modal + self.app.push_screen( + AddPhotoModal(diary_id=self.diary_id), + self.handle_add_photo_result + ) + + def handle_add_photo_result(self, result: dict | None) -> None: + """Callback that processes the add photo modal result.""" + if result is None: + self.notify("Add photo cancelled") + return + + # Photo was already created in the modal, just refresh the sidebar + if self.sidebar_visible: + self._update_sidebar_content() + self.notify(f"Photo '{result['name']}' added successfully!") + + async def _async_create_photo(self, photo_data: dict): + """Creates a new photo asynchronously""" + try: + service_manager = self.app.service_manager + photo_service = service_manager.get_photo_service() + + current_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + new_photo = photo_service.create( + filepath=Path(photo_data["filepath"]), + name=photo_data["name"], + travel_diary_id=self.diary_id, + addition_date=current_date, + caption=photo_data["caption"] + ) + + if new_photo: + self.notify(f"Photo '{new_photo.name}' added successfully!") + # Refresh sidebar content + if self.sidebar_visible: + self._update_sidebar_content() + else: + self.notify("Error creating photo") + + except Exception as e: + self.notify(f"Error creating photo: {str(e)}") + + def action_delete_photo(self): + """Delete selected photo""" + if not self.sidebar_focused or not self.sidebar_visible: + self.notify("Use F8 to open the sidebar first.", severity="warning") + return + + if self.photo_list.highlighted is None: + self.notify("No photo selected", severity="warning") + return + + # Adjust index because of 'Ingest Photo' at the top + photo_index = self.photo_list.highlighted - 1 + + photos = self._load_photos_for_diary(self.diary_id) + if photo_index < 0 or photo_index >= len(photos): + self.notify("No photo selected", severity="warning") + return + + selected_photo = photos[photo_index] + + # Open confirm delete modal + self.app.push_screen( + ConfirmDeleteModal(photo=selected_photo), + self.handle_delete_photo_result + ) + + def handle_delete_photo_result(self, result: bool) -> None: + """Callback that processes the delete photo modal result.""" + if result: + # Get the selected photo with adjusted index + photos = self._load_photos_for_diary(self.diary_id) + photo_index = self.photo_list.highlighted - 1 # Adjust for 'Ingest Photo' at top + + if self.photo_list.highlighted is None or photo_index < 0 or photo_index >= len(photos): + self.notify("Photo no longer available", severity="error") + return + + selected_photo = photos[photo_index] + + # Schedule async deletion + self.call_later(self._async_delete_photo, selected_photo) + else: + self.notify("Delete cancelled") + + async def _async_delete_photo(self, photo: Photo): + """Deletes a photo asynchronously""" + try: + service_manager = self.app.service_manager + photo_service = service_manager.get_photo_service() + + result = photo_service.delete(photo) + + if result: + self.notify(f"Photo '{photo.name}' deleted successfully!") + # Refresh sidebar content + if self.sidebar_visible: + self._update_sidebar_content() + else: + self.notify("Error deleting photo") + + except Exception as e: + self.notify(f"Error deleting photo: {str(e)}") + + def action_edit_photo(self): + """Edit selected photo using modal""" + if not self.sidebar_focused or not self.sidebar_visible: + self.notify("Use F8 to open the sidebar first.", severity="warning") + return + + if self.photo_list.highlighted is None: + self.notify("No photo selected", severity="warning") + return + + # Adjust index because of 'Ingest Photo' at the top + photo_index = self.photo_list.highlighted - 1 + + photos = self._load_photos_for_diary(self.diary_id) + if photo_index < 0 or photo_index >= len(photos): + self.notify("No photo selected", severity="warning") + return + + selected_photo = photos[photo_index] + + # Open edit photo modal + self.app.push_screen( + EditPhotoModal(photo=selected_photo), + self.handle_edit_photo_result + ) + + def handle_edit_photo_result(self, result: dict | None) -> None: + """Callback that processes the edit photo modal result.""" + if result is None: + self.notify("Edit photo cancelled") + return + + # Get the selected photo with adjusted index + photos = self._load_photos_for_diary(self.diary_id) + photo_index = self.photo_list.highlighted - 1 # Adjust for 'Ingest Photo' at top + + if self.photo_list.highlighted is None or photo_index < 0 or photo_index >= len(photos): + self.notify("Photo no longer available", severity="error") + return + + selected_photo = photos[photo_index] + + # Schedule async update + self.call_later(self._async_update_photo, selected_photo, result) + + async def _async_update_photo(self, original_photo: Photo, photo_data: dict): + """Updates a photo asynchronously""" + try: + service_manager = self.app.service_manager + photo_service = service_manager.get_photo_service() + + # Create updated photo object + updated_photo = Photo( + filepath=photo_data["filepath"], + name=photo_data["name"], + addition_date=original_photo.addition_date, + caption=photo_data["caption"], + entries=original_photo.entries if original_photo.entries is not None else [], + id=original_photo.id + ) + + result = photo_service.update(original_photo, updated_photo) + + if result: + self.notify(f"Photo '{updated_photo.name}' updated successfully!") + # Refresh sidebar content + if self.sidebar_visible: + self._update_sidebar_content() + else: + self.notify("Error updating photo") + + except Exception as e: + self.notify(f"Error updating photo: {str(e)}") + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + """Handles photo selection in the sidebar""" + if not self.sidebar_visible: + return + # If 'Ingest Photo' is selected (always index 0) + if event.option_index == 0: + self.action_ingest_new_photo() + return + photos = self._load_photos_for_diary(self.diary_id) + # Adjust index because of 'Ingest Photo' at the top + photo_index = event.option_index - 1 + if not photos or photo_index >= len(photos): + return + selected_photo = photos[photo_index] + self.notify(f"Selected photo: {selected_photo.name}") + # Update photo info with details + photo_details = f"📷 {selected_photo.name}\n" + photo_details += f"📅 {selected_photo.addition_date}\n" + if selected_photo.caption: + photo_details += f"💬 {selected_photo.caption}\n" + photo_details += f"📁 {selected_photo.filepath}" + self.photo_info.update(photo_details) + def on_text_area_changed(self, event) -> None: """Detects text changes to mark as unsaved""" if (hasattr(self, 'text_entry') and not self.text_entry.read_only and @@ -251,6 +597,17 @@ class EditEntryScreen(Screen): self.has_unsaved_changes = False self._update_sub_header() + def on_focus(self, event) -> None: + """Captures focus changes to update footer""" + # Check if focus changed to/from sidebar + if hasattr(event.widget, 'id'): + if event.widget.id == "photo_list": + self.sidebar_focused = True + self._update_footer_context() + elif event.widget.id == "text_entry": + self.sidebar_focused = False + self._update_footer_context() + def action_back_to_list(self) -> None: """Goes back to the diary list""" if self.is_new_entry and not self.text_entry.text.strip() and not self.has_unsaved_changes: @@ -375,7 +732,6 @@ class EditEntryScreen(Screen): service_manager = self.app.service_manager entry_service = service_manager.get_entry_service() - # Get current date as datetime object current_date = datetime.now() new_entry = entry_service.create( @@ -389,7 +745,6 @@ class EditEntryScreen(Screen): self.entries.append(new_entry) self.entries.sort(key=lambda x: x.id) - # Find the new entry index for i, entry in enumerate(self.entries): if entry.id == new_entry.id: self.current_entry_index = i @@ -419,7 +774,6 @@ class EditEntryScreen(Screen): current_entry = self.entries[self.current_entry_index] updated_content = self.text_entry.text - # Create updated entry object updated_entry = Entry( title=current_entry.title, text=updated_content, @@ -445,8 +799,44 @@ class EditEntryScreen(Screen): except Exception as e: self.notify(f"Error updating entry: {str(e)}") - def action_force_refresh(self): - """Forces manual refresh""" - self.notify("Forcing refresh...") - self.refresh_entries() - self.call_later(self.async_refresh_entries) \ No newline at end of file + def on_key(self, event): + # Sidebar contextual shortcuts + if self.sidebar_focused and self.sidebar_visible: + if event.key == "i": + self.action_insert_photo() + event.stop() + elif event.key == "n": + self.action_ingest_new_photo() + event.stop() + elif event.key == "d": + self.action_delete_photo() + event.stop() + elif event.key == "e": + self.action_edit_photo() + event.stop() + # Shift+Tab: remove indent + elif self.focused is self.text_entry and event.key == "shift+tab": + textarea = self.text_entry + row, col = textarea.cursor_location + lines = textarea.text.splitlines() + if row < len(lines): + line = lines[row] + if line.startswith('\t'): + lines[row] = line[1:] + textarea.text = '\n'.join(lines) + textarea.cursor_location = (row, max(col - 1, 0)) + elif line.startswith(' '): # 4 spaces + lines[row] = line[4:] + textarea.text = '\n'.join(lines) + textarea.cursor_location = (row, max(col - 4, 0)) + elif line.startswith(' '): + n = len(line) - len(line.lstrip(' ')) + to_remove = min(n, 4) + lines[row] = line[to_remove:] + textarea.text = '\n'.join(lines) + textarea.cursor_location = (row, max(col - to_remove, 0)) + event.stop() + # Tab: insert tab + elif self.focused is self.text_entry and event.key == "tab": + self.text_entry.insert('\t') + event.stop() \ No newline at end of file diff --git a/src/pilgrim/ui/screens/modals/add_photo_modal.py b/src/pilgrim/ui/screens/modals/add_photo_modal.py new file mode 100644 index 0000000..e0ac578 --- /dev/null +++ b/src/pilgrim/ui/screens/modals/add_photo_modal.py @@ -0,0 +1,105 @@ +import os +from pathlib import Path +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Static, Input, Button +from textual.containers import Horizontal, Container +from .file_picker_modal import FilePickerModal + +class AddPhotoModal(Screen): + """Modal for adding a new photo""" + def __init__(self, diary_id: int): + super().__init__() + self.diary_id = diary_id + self.result = None + + def compose(self) -> ComposeResult: + yield Container( + Static("📷 Add New Photo", classes="AddPhotoModal-Title"), + Static("File path:", classes="AddPhotoModal-Label"), + Horizontal( + Input(placeholder="Enter file path...", id="filepath-input", classes="AddPhotoModal-Input"), + Button("Escolher arquivo...", id="choose-file-button", classes="AddPhotoModal-Button"), + classes="AddPhotoModal-FileRow" + ), + Static("Photo name:", classes="AddPhotoModal-Label"), + Input(placeholder="Enter photo name...", id="name-input", classes="AddPhotoModal-Input"), + Static("Caption (optional):", classes="AddPhotoModal-Label"), + Input(placeholder="Enter caption...", id="caption-input", classes="AddPhotoModal-Input"), + Horizontal( + Button("Add Photo", id="add-button", classes="AddPhotoModal-Button"), + Button("Cancel", id="cancel-button", classes="AddPhotoModal-Button"), + classes="AddPhotoModal-Buttons" + ), + classes="AddPhotoModal-Dialog" + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "choose-file-button": + self.app.push_screen( + FilePickerModal(), + self.handle_file_picker_result + ) + return + if event.button.id == "add-button": + filepath = self.query_one("#filepath-input", Input).value + name = self.query_one("#name-input", Input).value + caption = self.query_one("#caption-input", Input).value + if not filepath.strip() or not name.strip(): + self.notify("File path and name are required", severity="error") + return + + # Try to create the photo in the database + self.call_later(self._async_create_photo, { + "filepath": filepath.strip(), + "name": name.strip(), + "caption": caption.strip() if caption.strip() else None + }) + elif event.button.id == "cancel-button": + self.dismiss() + + async def _async_create_photo(self, photo_data: dict): + """Creates a new photo asynchronously using PhotoService""" + try: + service_manager = self.app.service_manager + photo_service = service_manager.get_photo_service() + + new_photo = photo_service.create( + filepath=Path(photo_data["filepath"]), + name=photo_data["name"], + travel_diary_id=self.diary_id, + caption=photo_data["caption"] + ) + + if new_photo: + self.notify(f"Photo '{new_photo.name}' added successfully!") + # Return the created photo data to the calling screen + self.result = { + "filepath": photo_data["filepath"], + "name": photo_data["name"], + "caption": photo_data["caption"], + "photo_id": new_photo.id + } + self.dismiss(self.result) + else: + self.notify("Error creating photo in database", severity="error") + + except Exception as e: + self.notify(f"Error creating photo: {str(e)}", severity="error") + + def handle_file_picker_result(self, result: str | None) -> None: + if result: + # Set the filepath input value + filepath_input = self.query_one("#filepath-input", Input) + filepath_input.value = result + # Trigger the input change event to update the UI + filepath_input.refresh() + # Auto-fill the name field with the filename (without extension) + filename = Path(result).stem + name_input = self.query_one("#name-input", Input) + if not name_input.value.strip(): + name_input.value = filename + name_input.refresh() + else: + # User cancelled the file picker + self.notify("File selection cancelled", severity="information") \ No newline at end of file diff --git a/src/pilgrim/ui/screens/modals/confirm_delete_modal.py b/src/pilgrim/ui/screens/modals/confirm_delete_modal.py new file mode 100644 index 0000000..b987de4 --- /dev/null +++ b/src/pilgrim/ui/screens/modals/confirm_delete_modal.py @@ -0,0 +1,32 @@ +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Static, Button +from textual.containers import Container, Horizontal +from pilgrim.models.photo import Photo + +class ConfirmDeleteModal(Screen): + """Modal for confirming photo deletion""" + def __init__(self, photo: Photo): + super().__init__() + self.photo = photo + self.result = None + + def compose(self) -> ComposeResult: + yield Container( + Static("🗑️ Confirm Deletion", classes="ConfirmDeleteModal-Title"), + Static(f"Are you sure you want to delete the photo '{self.photo.name}'?", classes="ConfirmDeleteModal-Message"), + Static("This action cannot be undone.", classes="ConfirmDeleteModal-Warning"), + Horizontal( + Button("Delete", variant="error", id="delete-button", classes="ConfirmDeleteModal-Button"), + Button("Cancel", variant="default", id="cancel-button", classes="ConfirmDeleteModal-Button"), + classes="ConfirmDeleteModal-Buttons" + ), + classes="ConfirmDeleteModal-Dialog" + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "delete-button": + self.result = True + self.dismiss(True) + elif event.button.id == "cancel-button": + self.dismiss(False) \ No newline at end of file diff --git a/src/pilgrim/ui/screens/modals/edit_photo_modal.py b/src/pilgrim/ui/screens/modals/edit_photo_modal.py new file mode 100644 index 0000000..5af3011 --- /dev/null +++ b/src/pilgrim/ui/screens/modals/edit_photo_modal.py @@ -0,0 +1,68 @@ +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Static, Input, Button +from textual.containers import Container, Horizontal +from pilgrim.models.photo import Photo + +class EditPhotoModal(Screen): + """Modal for editing an existing photo (name and caption only)""" + def __init__(self, photo: Photo): + super().__init__() + self.photo = photo + self.result = None + + def compose(self) -> ComposeResult: + yield Container( + Static("✏️ Edit Photo", classes="EditPhotoModal-Title"), + Static("File path (read-only):", classes="EditPhotoModal-Label"), + Input( + value=self.photo.filepath, + id="filepath-input", + classes="EditPhotoModal-Input", + disabled=True + ), + Static("Photo name:", classes="EditPhotoModal-Label"), + Input( + value=self.photo.name, + placeholder="Enter photo name...", + id="name-input", + classes="EditPhotoModal-Input" + ), + Static("Caption (optional):", classes="EditPhotoModal-Label"), + Input( + value=self.photo.caption or "", + placeholder="Enter caption...", + id="caption-input", + classes="EditPhotoModal-Input" + ), + Horizontal( + Button("Save Changes", id="save-button", classes="EditPhotoModal-Button"), + Button("Cancel", id="cancel-button", classes="EditPhotoModal-Button"), + classes="EditPhotoModal-Buttons" + ), + classes="EditPhotoModal-Dialog" + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "save-button": + name = self.query_one("#name-input", Input).value + caption = self.query_one("#caption-input", Input).value + + if not name.strip(): + self.notify("Photo name is required", severity="error") + return + + # Return the updated photo data + self.result = { + "filepath": self.photo.filepath, # Keep original filepath + "name": name.strip(), + "caption": caption.strip() if caption.strip() else None + } + self.dismiss(self.result) + + elif event.button.id == "cancel-button": + self.dismiss() + + def on_mount(self) -> None: + """Focus on the name input when modal opens""" + self.query_one("#name-input", Input).focus() \ No newline at end of file diff --git a/src/pilgrim/ui/screens/modals/file_picker_modal.py b/src/pilgrim/ui/screens/modals/file_picker_modal.py new file mode 100644 index 0000000..c65f206 --- /dev/null +++ b/src/pilgrim/ui/screens/modals/file_picker_modal.py @@ -0,0 +1,66 @@ +import os +from pathlib import Path +from typing import Iterable +from textual.app import ComposeResult +from textual.screen import Screen +from textual.widgets import Static, DirectoryTree, Button +from textual.containers import Horizontal, Container + +class ImageDirectoryTree(DirectoryTree): + """DirectoryTree that only shows image files""" + + def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: + """Filter to show only directories and image files""" + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} + return [ + path for path in paths + if path.is_dir() or path.suffix.lower() in image_extensions + ] + +class FilePickerModal(Screen): + """Modal for picking an image file using DirectoryTree""" + + def __init__(self, start_path=None): + super().__init__() + self.start_path = Path(start_path or os.getcwd()) + # Start one level up to make navigation easier + self.current_path = self.start_path.parent + + def compose(self) -> ComposeResult: + yield Container( + Static(f"Current: {self.current_path}", id="title", classes="FilePickerModal-Title"), + ImageDirectoryTree(str(self.current_path), id="directory-tree"), + Horizontal( + Button("Up", id="up-button", classes="FilePickerModal-Button"), + Button("Cancel", id="cancel-button", classes="FilePickerModal-Button"), + classes="FilePickerModal-Buttons" + ), + classes="FilePickerModal-Dialog" + ) + + def on_directory_tree_file_selected(self, event: DirectoryTree.FileSelected) -> None: + """Handle file selection""" + file_path = event.path + # Check if it's an image file + image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} + if file_path.suffix.lower() in image_extensions: + # Return the file path as result + self.dismiss(str(file_path)) + else: + self.notify("Please select an image file", severity="warning") + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses""" + if event.button.id == "up-button": + # Navigate to parent directory + parent = self.current_path.parent + if parent != self.current_path: + self.current_path = parent + self.query_one("#title", Static).update(f"Current: {self.current_path}") + # Reload the directory tree + tree = self.query_one("#directory-tree", ImageDirectoryTree) + tree.path = str(self.current_path) + tree.reload() + elif event.button.id == "cancel-button": + # Return None to indicate cancellation + self.dismiss(None) \ No newline at end of file diff --git a/src/pilgrim/ui/styles/pilgrim.css b/src/pilgrim/ui/styles/pilgrim.css index e170af2..5b52192 100644 --- a/src/pilgrim/ui/styles/pilgrim.css +++ b/src/pilgrim/ui/styles/pilgrim.css @@ -387,4 +387,243 @@ Screen.-modal { .RenameEntryModal-cancel-button { margin: 0 1; width: 1fr; +} + +.EditEntryScreen-sidebar { + width: 40; + min-height: 10; + border-left: solid green; + padding: 1; + background: $surface-darken-2; + color: $primary; + text-style: bold; + content-align: left top; +} + +.EditEntryScreen-content-container { + layout: horizontal; + height: 1fr; +} + +.EditEntryScreen-sidebar-title { + text-align: center; + text-style: bold; + color: $accent; + padding: 1; + border-bottom: solid $accent; + margin-bottom: 1; +} + +.EditEntryScreen-sidebar-content { + height: 1fr; + layout: vertical; +} + +.EditEntryScreen-sidebar-photo-list { + height: 1fr; + border: solid $accent; + margin-bottom: 1; +} + +.EditEntryScreen-sidebar-photo-info { + height: auto; + min-height: 3; + border: solid $warning; + padding: 1; + margin-bottom: 1; + background: $surface-darken-1; +} + +.EditEntryScreen-sidebar-help { + height: auto; + min-height: 8; + border: solid $success; + padding: 1; + background: $surface-darken-1; + text-style: italic; +} + +/* Photo Modal Styles */ +.modal-dialog { + layout: vertical; + width: 60%; + height: auto; + background: $surface; + border: thick $accent; + padding: 2 4; + align: center middle; +} + +.modal-title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; +} + +.modal-label { + margin-bottom: 1; + color: $text; +} + +.modal-input { + width: 1fr; + margin-bottom: 2; +} + +.modal-buttons { + width: 1fr; + height: auto; + align: center middle; + padding-top: 1; +} + +.modal-button { + margin: 0 1; + width: 1fr; +} + +/* AddPhotoModal styles */ +.AddPhotoModal-Dialog { + layout: vertical; + width: 60%; + height: auto; + background: $surface; + border: thick $accent; + padding: 2 4; + align: center middle; +} +.AddPhotoModal-Title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; +} +.AddPhotoModal-Label { + margin-bottom: 1; + color: $text; +} +.AddPhotoModal-Input { + width: 1fr; + margin-bottom: 2; +} +.AddPhotoModal-Buttons { + width: 1fr; + height: auto; + align: center middle; + padding-top: 1; +} +.AddPhotoModal-Button { + margin: 0 1; + width: 1fr; +} + +/* EditPhotoModal styles */ +.EditPhotoModal-Dialog { + layout: vertical; + width: 60%; + height: auto; + background: $surface; + border: thick $accent; + padding: 2 4; + align: center middle; +} +.EditPhotoModal-Title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; +} +.EditPhotoModal-Label { + margin-bottom: 1; + color: $text; +} +.EditPhotoModal-Input { + width: 1fr; + margin-bottom: 2; +} +.EditPhotoModal-Buttons { + width: 1fr; + height: auto; + align: center middle; + padding-top: 1; +} +.EditPhotoModal-Button { + margin: 0 1; + width: 1fr; +} + +/* FilePickerModal styles */ +.FilePickerModal-Dialog { + layout: vertical; + width: 80%; + height: 80%; + background: $surface; + border: thick $accent; + padding: 2 4; + align: center middle; +} + +.FilePickerModal-Title { + text-align: center; + text-style: bold; + color: $primary; + margin-bottom: 1; +} + +.FilePickerModal-Buttons { + width: 1fr; + height: auto; + align: center middle; + padding-top: 1; +} + +.FilePickerModal-Button { + margin: 0 1; + width: 1fr; +} + +/* DirectoryTree specific styles */ +#directory-tree { + height: 1fr; + border: solid $accent; + margin: 1; +} + +/* ConfirmDeleteModal styles */ +.ConfirmDeleteModal-Dialog { + layout: vertical; + width: 60%; + height: auto; + background: $surface; + border: thick $error; + padding: 2 4; + align: center middle; +} +.ConfirmDeleteModal-Title { + text-align: center; + text-style: bold; + color: $error; + margin-bottom: 1; +} +.ConfirmDeleteModal-Message { + text-align: center; + color: $text; + margin-bottom: 1; +} +.ConfirmDeleteModal-Warning { + text-align: center; + color: $warning; + text-style: italic; + margin-bottom: 2; +} +.ConfirmDeleteModal-Buttons { + width: 1fr; + height: auto; + align: center middle; + padding-top: 1; +} +.ConfirmDeleteModal-Button { + margin: 0 1; + width: 1fr; } \ No newline at end of file