diff --git a/gallery_app.py b/gallery_app.py
index 03ed9b8..332dcc0 100644
--- a/gallery_app.py
+++ b/gallery_app.py
@@ -1,619 +1,894 @@
import os
-import shutil
-import sqlite3
-from PIL import Image
-from io import BytesIO
-from contextlib import contextmanager
-from functools import lru_cache
-import hashlib
+import math
+import asyncio
+from typing import Optional, List, Dict, Set
+from nicegui import ui, app, run
+from fastapi import Response
+from engine import SorterEngine
-class SorterEngine:
- DB_PATH = "/app/sorter_database.db"
+# ==========================================
+# CUSTOM CSS FOR REFINED AESTHETICS
+# ==========================================
+CUSTOM_CSS = """
+
+"""
+
+# ==========================================
+# STATE MANAGEMENT
+# ==========================================
+class AppState:
+ """Centralized application state with lazy loading and caching."""
- # Connection pool for better performance
- _connection_pool = {}
+ __slots__ = [
+ 'profiles', 'profile_name', 'source_dir', 'output_dir',
+ 'page', 'page_size', 'grid_cols', 'preview_quality',
+ 'active_cat', 'next_index', 'batch_mode', 'cleanup_mode',
+ 'all_images', 'staged_data', 'green_dots', 'index_map',
+ 'sidebar_container', 'grid_container', 'pagination_container',
+ 'stats_container', '_image_cache'
+ ]
- # --- CONNECTION MANAGEMENT (Performance Optimization) ---
- @staticmethod
- @contextmanager
- def get_connection():
- """Context manager for efficient DB connections with WAL mode."""
- conn = sqlite3.connect(SorterEngine.DB_PATH, check_same_thread=False)
- conn.execute("PRAGMA journal_mode=WAL") # Write-Ahead Logging for speed
- conn.execute("PRAGMA synchronous=NORMAL") # Faster writes
- conn.execute("PRAGMA cache_size=2000") # ~2MB cache
- conn.row_factory = sqlite3.Row # Efficient row access
- try:
- yield conn
- finally:
- conn.close()
-
- # --- 1. DATABASE INITIALIZATION ---
- @staticmethod
- def init_db():
- """Initializes tables, including folder-based tag persistence."""
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
-
- # Existing tables...
- cursor.execute('''CREATE TABLE IF NOT EXISTS profiles
- (name TEXT PRIMARY KEY, tab1_target TEXT, tab2_target TEXT, tab2_control TEXT,
- tab4_source TEXT, tab4_out TEXT, mode TEXT, tab5_source TEXT, tab5_out TEXT)''')
- cursor.execute('''CREATE TABLE IF NOT EXISTS folder_ids (path TEXT PRIMARY KEY, folder_id INTEGER)''')
- cursor.execute('''CREATE TABLE IF NOT EXISTS categories (name TEXT PRIMARY KEY)''')
- cursor.execute('''CREATE TABLE IF NOT EXISTS staging_area
- (original_path TEXT PRIMARY KEY, target_category TEXT, new_name TEXT, is_marked INTEGER DEFAULT 0)''')
-
- # --- HISTORY TABLE ---
- cursor.execute('''CREATE TABLE IF NOT EXISTS processed_log
- (source_path TEXT PRIMARY KEY, category TEXT, action_type TEXT)''')
-
- # --- NEW: FOLDER-BASED TAG PERSISTENCE ---
- # Maps folder_hash -> original staging data for restoration
- cursor.execute('''CREATE TABLE IF NOT EXISTS folder_tags
- (folder_hash TEXT NOT NULL,
- relative_path TEXT NOT NULL,
- target_category TEXT,
- new_name TEXT,
- is_marked INTEGER DEFAULT 0,
- PRIMARY KEY (folder_hash, relative_path))''')
-
- # Index for faster folder lookups
- cursor.execute('''CREATE INDEX IF NOT EXISTS idx_folder_tags_hash
- ON folder_tags(folder_hash)''')
-
- # Seed categories if empty
- cursor.execute("SELECT COUNT(*) FROM categories")
- if cursor.fetchone()[0] == 0:
- cursor.executemany("INSERT OR IGNORE INTO categories VALUES (?)",
- [("_TRASH",), ("Default",), ("Action",), ("Solo",)])
-
- conn.commit()
-
- # --- FOLDER HASH UTILITY ---
- @staticmethod
- def _get_folder_hash(folder_path: str) -> str:
- """Generate consistent hash for a folder path."""
- # Use folder name + parent for uniqueness but allow same-named folders
- normalized = os.path.normpath(folder_path).lower()
- return hashlib.md5(normalized.encode()).hexdigest()[:16]
-
- # --- 2. PROFILE & PATH MANAGEMENT ---
- @staticmethod
- def save_tab_paths(profile_name, t1_t=None, t2_t=None, t2_c=None, t4_s=None, t4_o=None, mode=None, t5_s=None, t5_o=None):
- """Updates specific tab paths in the database while preserving others."""
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("SELECT * FROM profiles WHERE name = ?", (profile_name,))
- row = cursor.fetchone()
-
- if not row:
- row = (profile_name, "/storage", "/storage", "/storage", "/storage", "/storage", "id", "/storage", "/storage")
-
- new_values = (
- profile_name,
- t1_t if t1_t is not None else row[1],
- t2_t if t2_t is not None else row[2],
- t2_c if t2_c is not None else row[3],
- t4_s if t4_s is not None else row[4],
- t4_o if t4_o is not None else row[5],
- mode if mode is not None else row[6],
- t5_s if t5_s is not None else row[7],
- t5_o if t5_o is not None else row[8]
- )
- cursor.execute("INSERT OR REPLACE INTO profiles VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", new_values)
- conn.commit()
-
- @staticmethod
- def load_batch_parallel(image_paths, quality):
- """Multithreaded loader with optimized thread count."""
- import concurrent.futures
+ def __init__(self):
+ # Profile Data
+ self.profiles = SorterEngine.load_profiles()
+ self.profile_name = "Default"
+ if not self.profiles:
+ self.profiles = {"Default": {"tab5_source": "/storage", "tab5_out": "/storage"}}
- results = {}
+ self.load_active_profile()
- def process_one(path):
- return path, SorterEngine.compress_for_web(path, quality)
-
- # Optimal workers: min of CPU cores or paths count
- max_workers = min(8, len(image_paths)) if image_paths else 1
+ # View Settings
+ self.page = 0
+ self.page_size = 24
+ self.grid_cols = 4
+ self.preview_quality = 50
- with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
- future_to_path = {executor.submit(process_one, p): p for p in image_paths}
- for future in concurrent.futures.as_completed(future_to_path):
- try:
- path, data = future.result()
- results[path] = data
- except Exception:
- pass
-
- return results
+ # Tagging State
+ self.active_cat = "Default"
+ self.next_index = 1
+
+ # Batch Settings
+ self.batch_mode = "Copy"
+ self.cleanup_mode = "Keep"
+
+ # Data Caches (optimized with sets for O(1) lookup)
+ self.all_images: List[str] = []
+ self.staged_data: Dict = {}
+ self.green_dots: Set[int] = set()
+ self.index_map: Dict[int, str] = {}
+ self._image_cache: Set[str] = set() # Fast existence check
+
+ # UI Containers
+ self.sidebar_container = None
+ self.grid_container = None
+ self.pagination_container = None
+ self.stats_container = None
- @staticmethod
- def load_profiles():
- """Loads all workspace presets."""
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("SELECT * FROM profiles")
- rows = cursor.fetchall()
- return {r[0]: {
- "tab1_target": r[1], "tab2_target": r[2], "tab2_control": r[3],
- "tab4_source": r[4], "tab4_out": r[5], "mode": r[6],
- "tab5_source": r[7], "tab5_out": r[8]
- } for r in rows}
+ def load_active_profile(self):
+ """Load paths from active profile."""
+ p_data = self.profiles.get(self.profile_name, {})
+ self.source_dir = p_data.get("tab5_source", "/storage")
+ self.output_dir = p_data.get("tab5_out", "/storage")
- # --- 3. CATEGORY MANAGEMENT (Sorted A-Z) ---
- @staticmethod
- def get_categories():
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("SELECT name FROM categories ORDER BY name COLLATE NOCASE ASC")
- return [r[0] for r in cursor.fetchall()]
+ def save_current_profile(self):
+ """Save current paths to active profile."""
+ if self.profile_name not in self.profiles:
+ self.profiles[self.profile_name] = {}
+ self.profiles[self.profile_name]["tab5_source"] = self.source_dir
+ self.profiles[self.profile_name]["tab5_out"] = self.output_dir
+ SorterEngine.save_tab_paths(self.profile_name, t5_s=self.source_dir, t5_o=self.output_dir)
+ ui.notify(f"Profile '{self.profile_name}' saved!", type='positive')
- @staticmethod
- def add_category(name):
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("INSERT OR IGNORE INTO categories VALUES (?)", (name,))
- conn.commit()
+ def get_categories(self) -> List[str]:
+ """Get list of categories, ensuring active_cat exists."""
+ cats = SorterEngine.get_categories() or ["Default"]
+ if self.active_cat not in cats:
+ self.active_cat = cats[0]
+ return cats
- @staticmethod
- def rename_category(old_name, new_name, output_base_path=None):
- """Renames category in DB and optionally renames physical folder."""
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- try:
- cursor.execute("UPDATE categories SET name = ? WHERE name = ?", (new_name, old_name))
- cursor.execute("UPDATE staging_area SET target_category = ? WHERE target_category = ?", (new_name, old_name))
- cursor.execute("UPDATE folder_tags SET target_category = ? WHERE target_category = ?", (new_name, old_name))
-
- if output_base_path:
- old_path = os.path.join(output_base_path, old_name)
- new_path = os.path.join(output_base_path, new_name)
- if os.path.exists(old_path) and not os.path.exists(new_path):
- os.rename(old_path, new_path)
-
- conn.commit()
- except sqlite3.IntegrityError:
- pass
-
- @staticmethod
- def sync_categories_from_disk(output_path):
- """Scans output directory and adds subfolders as DB categories."""
- if not output_path or not os.path.exists(output_path):
+ @property
+ def total_pages(self) -> int:
+ """Calculate total pages."""
+ if not self.all_images:
return 0
- existing_folders = [d for d in os.listdir(output_path)
- if os.path.isdir(os.path.join(output_path, d)) and not d.startswith(".")]
-
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- added = 0
- for folder in existing_folders:
- cursor.execute("INSERT OR IGNORE INTO categories VALUES (?)", (folder,))
- if cursor.rowcount > 0:
- added += 1
- conn.commit()
- return added
+ return (len(self.all_images) + self.page_size - 1) // self.page_size
- # --- 4. IMAGE & ID OPERATIONS ---
- @staticmethod
- def get_images(path, recursive=False):
- """Optimized image scanner with generator-based sorting."""
- exts = {'.jpg', '.jpeg', '.png', '.webp', '.bmp', '.tiff'} # Set for O(1) lookup
- if not path or not os.path.exists(path):
+ def get_current_batch(self) -> List[str]:
+ """Get images for current page with bounds checking."""
+ if not self.all_images:
return []
-
- image_list = []
- if recursive:
- for root, dirs, files in os.walk(path):
- # Skip trash folders - modify dirs in-place for efficiency
- dirs[:] = [d for d in dirs if "_DELETED" not in d]
- for f in files:
- if os.path.splitext(f.lower())[1] in exts:
- image_list.append(os.path.join(root, f))
- else:
- try:
- with os.scandir(path) as entries:
- for entry in entries:
- if entry.is_file() and os.path.splitext(entry.name.lower())[1] in exts:
- image_list.append(entry.path)
- except PermissionError:
- pass
-
- return sorted(image_list)
+ start = self.page * self.page_size
+ end = min(start + self.page_size, len(self.all_images))
+ return self.all_images[start:end]
+
+ @property
+ def tagged_count(self) -> int:
+ """Count of currently tagged images."""
+ return len(self.staged_data)
+
+ @property
+ def total_count(self) -> int:
+ """Total images loaded."""
+ return len(self.all_images)
- @staticmethod
- def get_id_mapping(path):
- """Maps idXXX prefixes for Tab 2 collision handling."""
- mapping = {}
- images = SorterEngine.get_images(path, recursive=False)
- for f in images:
- fname = os.path.basename(f)
- if fname.startswith("id") and "_" in fname:
- prefix = fname.split('_')[0]
- if prefix not in mapping:
- mapping[prefix] = []
- mapping[prefix].append(fname)
- return mapping
+state = AppState()
- @staticmethod
- def get_max_id_number(target_path):
- max_id = 0
- if not target_path or not os.path.exists(target_path):
- return 0
+# ==========================================
+# IMAGE SERVING API (Optimized)
+# ==========================================
+
+@app.get('/thumbnail')
+async def get_thumbnail(path: str, size: int = 400, q: int = 50):
+ """Serve WebP thumbnail with streaming response."""
+ if not os.path.exists(path):
+ return Response(status_code=404)
+ img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, q, size)
+ if img_bytes:
+ return Response(
+ content=img_bytes,
+ media_type="image/webp",
+ headers={"Cache-Control": "max-age=3600"} # Browser caching
+ )
+ return Response(status_code=500)
+
+@app.get('/full_res')
+async def get_full_res(path: str):
+ """Serve full resolution image with caching headers."""
+ if not os.path.exists(path):
+ return Response(status_code=404)
+ img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None)
+ if img_bytes:
+ return Response(
+ content=img_bytes,
+ media_type="image/webp",
+ headers={"Cache-Control": "max-age=7200"}
+ )
+ return Response(status_code=500)
+
+# ==========================================
+# CORE LOGIC
+# ==========================================
+
+def load_images():
+ """Load images from source directory with tag restoration."""
+ if not os.path.exists(state.source_dir):
+ ui.notify(f"Source not found: {state.source_dir}", type='warning')
+ return
+
+ # Load images
+ state.all_images = SorterEngine.get_images(state.source_dir, recursive=True)
+ state._image_cache = set(state.all_images) # Fast lookup cache
+
+ # --- RESTORE SAVED TAGS FOR THIS FOLDER ---
+ restored = SorterEngine.restore_folder_tags(state.source_dir)
+ if restored > 0:
+ ui.notify(f"Restored {restored} saved tags for this folder", type='positive')
+
+ # Reset page if out of bounds
+ if state.page >= state.total_pages:
+ state.page = 0
+
+ refresh_staged_info()
+ refresh_ui()
+
+def refresh_staged_info():
+ """Update staged data and index maps with optimized lookups."""
+ state.staged_data = SorterEngine.get_staged_data()
+
+ # Update green dots using set operations
+ state.green_dots.clear()
+ staged_keys = set(state.staged_data.keys())
+
+ # Batch process for efficiency
+ for idx, img_path in enumerate(state.all_images):
+ if img_path in staged_keys:
+ state.green_dots.add(idx // state.page_size)
+
+ # Build index map for active category
+ state.index_map.clear()
+
+ # Add staged images
+ for orig_path, info in state.staged_data.items():
+ if info['cat'] == state.active_cat:
+ idx = _extract_index(info['name'])
+ if idx is not None:
+ state.index_map[idx] = orig_path
+
+ # Add committed images from disk
+ cat_path = os.path.join(state.output_dir, state.active_cat)
+ if os.path.exists(cat_path):
try:
- with os.scandir(target_path) as entries:
+ with os.scandir(cat_path) as entries:
for entry in entries:
- if entry.is_file() and entry.name.startswith("id") and "_" in entry.name:
- try:
- num = int(entry.name[2:].split('_')[0])
- if num > max_id:
- max_id = num
- except:
- continue
+ if entry.is_file() and entry.name.startswith(state.active_cat):
+ idx = _extract_index(entry.name)
+ if idx is not None and idx not in state.index_map:
+ state.index_map[idx] = entry.path
except PermissionError:
pass
- return max_id
- @staticmethod
- def get_folder_id(source_path):
- """Retrieves or generates a persistent ID for a specific folder."""
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("SELECT folder_id FROM folder_ids WHERE path = ?", (source_path,))
- result = cursor.fetchone()
- if result:
- return result[0]
+def _extract_index(filename: str) -> Optional[int]:
+ """Extract numeric index from filename with error handling."""
+ try:
+ return int(filename.rsplit('_', 1)[1].split('.')[0])
+ except (ValueError, IndexError):
+ return None
+
+# ==========================================
+# ACTIONS
+# ==========================================
+
+def action_tag(img_path: str, manual_idx: Optional[int] = None):
+ """Tag an image with category and index, with folder persistence."""
+ idx = manual_idx if manual_idx is not None else state.next_index
+ ext = os.path.splitext(img_path)[1]
+ name = f"{state.active_cat}_{idx:03d}{ext}"
+
+ # Check for conflicts
+ final_path = os.path.join(state.output_dir, state.active_cat, name)
+ staged_names = {v['name'] for v in state.staged_data.values() if v['cat'] == state.active_cat}
+
+ if name in staged_names or os.path.exists(final_path):
+ ui.notify(f"Conflict: {name} exists. Using suffix.", type='warning')
+ name = f"{state.active_cat}_{idx:03d}_{len(staged_names)+1}{ext}"
+
+ # Stage with folder persistence
+ SorterEngine.stage_image(img_path, state.active_cat, name, source_root=state.source_dir)
+
+ if manual_idx is None:
+ state.next_index = idx + 1
+
+ refresh_staged_info()
+ refresh_ui()
+
+def action_untag(img_path: str):
+ """Remove staging from an image, including folder cache."""
+ SorterEngine.clear_staged_item(img_path, source_root=state.source_dir)
+ refresh_staged_info()
+ refresh_ui()
+
+def action_delete(img_path: str):
+ """Delete image to trash."""
+ SorterEngine.delete_to_trash(img_path)
+ load_images()
+
+def action_apply_page():
+ """Apply staged changes for current page only."""
+ batch = state.get_current_batch()
+ if not batch:
+ ui.notify("No images on current page", type='warning')
+ return
+
+ SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode)
+ ui.notify(f"Page processed ({state.batch_mode})", type='positive')
+ load_images()
+
+async def action_apply_global():
+ """Apply all staged changes globally and clear folder cache."""
+ ui.notify("Starting global apply... This may take a while.", type='info')
+ await run.io_bound(
+ SorterEngine.commit_global,
+ state.output_dir,
+ state.cleanup_mode,
+ state.batch_mode,
+ state.source_dir
+ )
+ load_images()
+ ui.notify("Global apply complete!", type='positive')
+
+# ==========================================
+# UI COMPONENTS
+# ==========================================
+
+def open_zoom_dialog(path: str, title: Optional[str] = None, show_untag: bool = False, show_jump: bool = False):
+ """Open full-resolution image dialog with refined styling."""
+ with ui.dialog() as dialog, ui.card().classes('max-w-screen-xl p-0 gap-0 zoom-dialog'):
+ with ui.row().classes('w-full justify-between items-center px-4 py-3').style('background: var(--bg-elevated)'):
+ ui.label(title or os.path.basename(path)).classes('font-semibold text-white truncate').style('font-family: "JetBrains Mono", monospace')
+
+ with ui.row().classes('gap-2'):
+ if show_jump and path in state._image_cache:
+ def jump_to_image():
+ img_idx = state.all_images.index(path)
+ target_page = img_idx // state.page_size
+ dialog.close()
+ set_page(target_page)
+ ui.notify(f"Jumped to page {target_page + 1}", type='info')
+
+ ui.button(icon='location_searching', on_click=jump_to_image) \
+ .props('flat round dense').classes('text-blue-400') \
+ .tooltip('Jump to image location')
+
+ if show_untag:
+ def untag_and_close():
+ action_untag(path)
+ dialog.close()
+ ui.notify("Tag removed", type='positive')
+
+ ui.button(icon='label_off', on_click=untag_and_close) \
+ .props('flat round dense').classes('text-red-400') \
+ .tooltip('Remove tag')
+
+ ui.button(icon='close', on_click=dialog.close).props('flat round dense color=white')
+
+ ui.image(f"/full_res?path={path}").classes('w-full h-auto object-contain').style('max-height: 85vh; background: var(--bg-deep)')
+ dialog.open()
+
+def render_sidebar():
+ """Render category management sidebar with refined design."""
+ state.sidebar_container.clear()
+
+ with state.sidebar_container:
+ # Header
+ with ui.row().classes('items-center gap-3 mb-6'):
+ ui.icon('sell', size='28px').classes('text-green-400')
+ ui.label("Tag Manager").classes('text-xl font-semibold text-white')
+
+ # Category selector with visual enhancement
+ categories = state.get_categories()
+
+ def on_category_change(e):
+ state.active_cat = e.value
+ refresh_staged_info()
+ render_sidebar()
+
+ ui.select(
+ categories,
+ value=state.active_cat,
+ label="Active Category",
+ on_change=on_category_change
+ ).classes('w-full mb-4').props('dark outlined color=green')
+
+ # Number grid (1-25) with refined styling
+ ui.label("Quick Index").classes('text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2')
+ with ui.grid(columns=5).classes('gap-1 mb-6 w-full'):
+ for i in range(1, 26):
+ is_used = i in state.index_map
+
+ def make_click_handler(num: int):
+ def handler():
+ if num in state.index_map:
+ img_path = state.index_map[num]
+ is_staged = img_path in state.staged_data
+ open_zoom_dialog(
+ img_path,
+ f"{state.active_cat} #{num}",
+ show_untag=is_staged,
+ show_jump=True
+ )
+ else:
+ state.next_index = num
+ render_sidebar()
+ return handler
+
+ btn_classes = 'w-full num-btn'
+ if is_used:
+ btn_classes += ' used'
+
+ ui.button(str(i), on_click=make_click_handler(i)) \
+ .props(f'flat size=sm {"color=green" if is_used else "color=grey-8"}') \
+ .classes(btn_classes)
+
+ # Next index input
+ with ui.row().classes('w-full items-end no-wrap gap-2 mb-4'):
+ ui.number(label="Next Index", min=1, precision=0) \
+ .bind_value(state, 'next_index') \
+ .classes('flex-grow').props('dark outlined color=green')
+
+ def reset_index():
+ state.next_index = (max(state.index_map.keys()) + 1) if state.index_map else 1
+ render_sidebar()
+
+ ui.button(icon='restart_alt', on_click=reset_index).props('flat round color=grey-5').tooltip('Auto-set next available')
+
+ ui.separator().classes('my-4 opacity-20')
+
+ # Add new category
+ ui.label("New Category").classes('text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2')
+ with ui.row().classes('w-full items-center no-wrap gap-2'):
+ new_cat_input = ui.input(placeholder='Category name...') \
+ .props('dense outlined dark').classes('flex-grow')
+
+ def add_category():
+ if new_cat_input.value:
+ SorterEngine.add_category(new_cat_input.value)
+ state.active_cat = new_cat_input.value
+ refresh_staged_info()
+ render_sidebar()
+
+ ui.button(icon='add', on_click=add_category).props('flat round color=green')
+
+ # Danger zone
+ with ui.expansion('Danger Zone', icon='warning').classes('w-full mt-6').props('dense header-class="text-red-400"'):
+ def delete_category():
+ SorterEngine.delete_category(state.active_cat)
+ refresh_staged_info()
+ render_sidebar()
+
+ ui.button('Delete Category', color='red', on_click=delete_category) \
+ .classes('w-full btn-danger').props('flat')
+
+def render_stats():
+ """Render statistics bar."""
+ if state.stats_container:
+ state.stats_container.clear()
+ with state.stats_container:
+ with ui.row().classes('gap-4'):
+ # Total images
+ with ui.row().classes('stat-pill items-center gap-2'):
+ ui.icon('photo_library', size='16px').classes('text-gray-400')
+ ui.label('Total:').classes('text-gray-400')
+ ui.label(str(state.total_count)).classes('value')
+
+ # Tagged count
+ with ui.row().classes('stat-pill items-center gap-2'):
+ ui.icon('sell', size='16px').classes('text-green-400')
+ ui.label('Tagged:').classes('text-gray-400')
+ ui.label(str(state.tagged_count)).classes('value')
+
+ # Current category
+ with ui.row().classes('stat-pill items-center gap-2'):
+ ui.icon('folder', size='16px').classes('text-blue-400')
+ ui.label('Category:').classes('text-gray-400')
+ ui.label(state.active_cat).classes('value text-blue-400')
+
+def render_gallery():
+ """Render image gallery grid with optimized rendering."""
+ state.grid_container.clear()
+ batch = state.get_current_batch()
+
+ with state.grid_container:
+ if not batch:
+ with ui.column().classes('w-full items-center py-20'):
+ ui.icon('photo_library', size='80px').classes('text-gray-700 mb-4')
+ ui.label('No images loaded').classes('text-xl text-gray-500')
+ ui.label('Select a source folder and click LOAD').classes('text-gray-600')
+ return
+
+ with ui.grid(columns=state.grid_cols).classes('w-full gap-4'):
+ for idx, img_path in enumerate(batch):
+ render_image_card(img_path, idx)
+
+def render_image_card(img_path: str, batch_idx: int):
+ """Render individual image card with enhanced styling."""
+ is_staged = img_path in state.staged_data
+ thumb_size = 600
+
+ card_class = 'image-card rounded-xl overflow-hidden'
+ if is_staged:
+ card_class += ' tagged'
+
+ with ui.card().classes(card_class).style('animation-delay: {}ms'.format(batch_idx * 30)):
+ # Thumbnail with hover effects
+ with ui.column().classes('relative'):
+ ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \
+ .classes('w-full thumb-container cursor-pointer') \
+ .style('height: 220px; object-fit: cover; background: var(--bg-deep)') \
+ .on('click', lambda p=img_path: open_zoom_dialog(p))
+
+ # Overlay buttons
+ with ui.row().classes('absolute top-2 right-2 gap-1'):
+ ui.button(icon='zoom_in', on_click=lambda p=img_path: open_zoom_dialog(p)) \
+ .props('flat round dense size=sm').classes('bg-black/50 text-white')
+ ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)) \
+ .props('flat round dense size=sm').classes('bg-black/50 text-red-400')
+
+ # Info section
+ with ui.column().classes('p-3 gap-2'):
+ # Filename
+ ui.label(os.path.basename(img_path)[:20]).classes('text-xs text-gray-400 truncate mono')
+
+ # Tagging UI
+ if is_staged:
+ info = state.staged_data[img_path]
+ idx = _extract_index(info['name'])
+ idx_str = str(idx) if idx else "?"
+
+ with ui.row().classes('w-full justify-between items-center'):
+ ui.html(f'{info["cat"]} #{idx_str}')
+ ui.button('Untag', on_click=lambda p=img_path: action_untag(p)) \
+ .props('flat dense size=sm color=grey')
else:
- cursor.execute("SELECT MAX(folder_id) FROM folder_ids")
- row = cursor.fetchone()
- fid = (row[0] + 1) if row and row[0] else 1
- cursor.execute("INSERT INTO folder_ids VALUES (?, ?)", (source_path, fid))
- conn.commit()
- return fid
+ with ui.row().classes('w-full no-wrap gap-2 items-center'):
+ local_idx = ui.number(value=state.next_index, precision=0) \
+ .props('dense dark outlined borderless').classes('w-16').style('font-family: "JetBrains Mono"')
+ ui.button('Tag', on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value))) \
+ .classes('flex-grow btn-primary').props('dense unelevated')
- # --- 5. GALLERY STAGING & DELETION (TAB 5) ---
- @staticmethod
- def delete_to_trash(file_path):
- """Moves a file to a local _DELETED subfolder for undo support."""
- if not os.path.exists(file_path):
- return None
- trash_dir = os.path.join(os.path.dirname(file_path), "_DELETED")
- os.makedirs(trash_dir, exist_ok=True)
- dest = os.path.join(trash_dir, os.path.basename(file_path))
- shutil.move(file_path, dest)
- return dest
-
- @staticmethod
- def stage_image(original_path, category, new_name, source_root=None):
- """Records a pending rename/move in the database and folder cache."""
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("INSERT OR REPLACE INTO staging_area VALUES (?, ?, ?, 1)",
- (original_path, category, new_name))
+def render_pagination():
+ """Render pagination controls with refined design."""
+ state.pagination_container.clear()
+
+ if state.total_pages <= 1:
+ return
+
+ with state.pagination_container:
+ with ui.row().classes('w-full items-center justify-center gap-4 mb-4'):
+ # Previous button
+ ui.button(icon='chevron_left', on_click=lambda: set_page(state.page - 1)) \
+ .props('flat round').classes('text-white' if state.page > 0 else 'text-gray-700') \
+ .set_enabled(state.page > 0)
- # Also save to folder-based persistence if source_root provided
- if source_root:
- folder_hash = SorterEngine._get_folder_hash(source_root)
- relative_path = os.path.relpath(original_path, source_root)
- cursor.execute("""INSERT OR REPLACE INTO folder_tags
- VALUES (?, ?, ?, ?, 1)""",
- (folder_hash, relative_path, category, new_name))
-
- conn.commit()
-
- @staticmethod
- def clear_staged_item(original_path, source_root=None):
- """Removes an item from the pending staging area."""
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("DELETE FROM staging_area WHERE original_path = ?", (original_path,))
-
- # Also remove from folder cache
- if source_root:
- folder_hash = SorterEngine._get_folder_hash(source_root)
- relative_path = os.path.relpath(original_path, source_root)
- cursor.execute("DELETE FROM folder_tags WHERE folder_hash = ? AND relative_path = ?",
- (folder_hash, relative_path))
-
- conn.commit()
-
- @staticmethod
- def get_staged_data():
- """Retrieves current tagged/staged images."""
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("SELECT * FROM staging_area")
- rows = cursor.fetchall()
- return {r[0]: {"cat": r[1], "name": r[2], "marked": r[3]} for r in rows}
-
- # --- NEW: FOLDER-BASED TAG RESTORATION ---
- @staticmethod
- def restore_folder_tags(source_root):
- """Restores tags from folder cache when reloading a directory."""
- folder_hash = SorterEngine._get_folder_hash(source_root)
-
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
-
- # Get saved tags for this folder
- cursor.execute("""SELECT relative_path, target_category, new_name, is_marked
- FROM folder_tags WHERE folder_hash = ?""", (folder_hash,))
- saved_tags = cursor.fetchall()
-
- restored_count = 0
- for rel_path, category, new_name, is_marked in saved_tags:
- # Reconstruct absolute path
- abs_path = os.path.normpath(os.path.join(source_root, rel_path))
+ # Page info
+ with ui.row().classes('items-center gap-3'):
+ ui.label(f'Page {state.page + 1} of {state.total_pages}') \
+ .classes('text-gray-400 mono text-sm')
- # Only restore if file still exists
- if os.path.exists(abs_path):
- cursor.execute("""INSERT OR REPLACE INTO staging_area
- VALUES (?, ?, ?, ?)""", (abs_path, category, new_name, is_marked))
- restored_count += 1
+ # Quick jump
+ ui.number(value=state.page + 1, min=1, max=state.total_pages, precision=0) \
+ .props('dense dark outlined borderless').classes('w-16') \
+ .on('change', lambda e: set_page(int(e.value) - 1))
- conn.commit()
- return restored_count
-
- @staticmethod
- def save_all_tags_to_folder_cache(source_root):
- """Saves all current staging data to folder-based cache."""
- folder_hash = SorterEngine._get_folder_hash(source_root)
- staged = SorterEngine.get_staged_data()
+ # Next button
+ ui.button(icon='chevron_right', on_click=lambda: set_page(state.page + 1)) \
+ .props('flat round').classes('text-white' if state.page < state.total_pages - 1 else 'text-gray-700') \
+ .set_enabled(state.page < state.total_pages - 1)
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
+ # Page dots/buttons
+ with ui.row().classes('items-center justify-center gap-1 flex-wrap'):
+ start = max(0, state.page - 3)
+ end = min(state.total_pages, state.page + 4)
- # Only save tags for files within this source_root
- for abs_path, info in staged.items():
- if abs_path.startswith(source_root):
- relative_path = os.path.relpath(abs_path, source_root)
- cursor.execute("""INSERT OR REPLACE INTO folder_tags
- VALUES (?, ?, ?, ?, ?)""",
- (folder_hash, relative_path, info['cat'], info['name'], info['marked']))
+ if start > 0:
+ ui.button('1', on_click=lambda: set_page(0)).props('flat dense size=sm color=grey')
+ if start > 1:
+ ui.label('...').classes('text-gray-600 px-1')
- conn.commit()
-
- @staticmethod
- def clear_folder_cache(source_root):
- """Clears saved tags for a specific folder."""
- folder_hash = SorterEngine._get_folder_hash(source_root)
-
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("DELETE FROM folder_tags WHERE folder_hash = ?", (folder_hash,))
- conn.commit()
-
- @staticmethod
- def commit_global(output_root, cleanup_mode, operation="Copy", source_root=None):
- """Commits ALL staged files and fixes permissions."""
- data = SorterEngine.get_staged_data()
-
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
-
- if not os.path.exists(output_root):
- os.makedirs(output_root, exist_ok=True)
-
- # 1. Process all Staged Items
- for old_p, info in data.items():
- if os.path.exists(old_p):
- final_dst = os.path.join(output_root, info['name'])
-
- if os.path.exists(final_dst):
- root, ext = os.path.splitext(info['name'])
- c = 1
- while os.path.exists(final_dst):
- final_dst = os.path.join(output_root, f"{root}_{c}{ext}")
- c += 1
-
- if operation == "Copy":
- shutil.copy2(old_p, final_dst)
- else:
- shutil.move(old_p, final_dst)
-
- SorterEngine.fix_permissions(final_dst)
- cursor.execute("INSERT OR REPLACE INTO processed_log VALUES (?, ?, ?)",
- (old_p, info['cat'], operation))
-
- # 2. Global Cleanup
- if cleanup_mode != "Keep" and source_root:
- all_imgs = SorterEngine.get_images(source_root, recursive=True)
- for img_p in all_imgs:
- if img_p not in data:
- if cleanup_mode == "Move to Unused":
- unused_dir = os.path.join(source_root, "unused")
- os.makedirs(unused_dir, exist_ok=True)
- dest_unused = os.path.join(unused_dir, os.path.basename(img_p))
- shutil.move(img_p, dest_unused)
- SorterEngine.fix_permissions(dest_unused)
- elif cleanup_mode == "Delete":
- os.remove(img_p)
-
- # 3. Clear staging area AND folder cache after successful commit
- cursor.execute("DELETE FROM staging_area")
-
- if source_root:
- folder_hash = SorterEngine._get_folder_hash(source_root)
- cursor.execute("DELETE FROM folder_tags WHERE folder_hash = ?", (folder_hash,))
-
- conn.commit()
-
- # --- 6. CORE UTILITIES ---
- @staticmethod
- def harmonize_names(t_p, c_p):
- """Forces the 'control' file to match the 'target' file's name."""
- if not os.path.exists(t_p) or not os.path.exists(c_p):
- return c_p
-
- t_name = os.path.basename(t_p)
- t_root, t_ext = os.path.splitext(t_name)
- c_ext = os.path.splitext(c_p)[1]
-
- new_c_name = f"{t_root}{c_ext}"
- new_c_p = os.path.join(os.path.dirname(c_p), new_c_name)
-
- if os.path.exists(new_c_p) and c_p != new_c_p:
- new_c_p = os.path.join(os.path.dirname(c_p), f"{t_root}_alt{c_ext}")
-
- os.rename(c_p, new_c_p)
- return new_c_p
-
- @staticmethod
- def re_id_file(old_path, new_id_prefix):
- """Changes the idXXX_ prefix to resolve collisions."""
- dir_name = os.path.dirname(old_path)
- old_name = os.path.basename(old_path)
- name_no_id = old_name.split('_', 1)[1] if '_' in old_name else old_name
- new_name = f"{new_id_prefix}{name_no_id}"
- new_path = os.path.join(dir_name, new_name)
- os.rename(old_path, new_path)
- return new_path
-
- @staticmethod
- def move_to_unused_synced(t_p, c_p, t_root, c_root):
- """Moves a pair to 'unused' subfolders."""
- t_name = os.path.basename(t_p)
- t_un = os.path.join(t_root, "unused", t_name)
- c_un = os.path.join(c_root, "unused", t_name)
- os.makedirs(os.path.dirname(t_un), exist_ok=True)
- os.makedirs(os.path.dirname(c_un), exist_ok=True)
- shutil.move(t_p, t_un)
- shutil.move(c_p, c_un)
- return t_un, c_un
-
- @staticmethod
- def restore_from_unused(t_p, c_p, t_root, c_root):
- """Moves files back from 'unused' to main folders."""
- t_name = os.path.basename(t_p)
- t_dst = os.path.join(t_root, "selected_target", t_name)
- c_dst = os.path.join(c_root, "selected_control", t_name)
- os.makedirs(os.path.dirname(t_dst), exist_ok=True)
- os.makedirs(os.path.dirname(c_dst), exist_ok=True)
- shutil.move(t_p, t_dst)
- shutil.move(c_p, c_dst)
- return t_dst, c_dst
-
- @staticmethod
- def compress_for_web(path, quality, target_size=None):
- """Optimized image compression with SIMD hints."""
- try:
- with Image.open(path) as img:
- # Fast mode conversion
- if img.mode not in ('RGB', 'RGBA'):
- img = img.convert("RGB")
+ for p in range(start, end):
+ has_tags = p in state.green_dots
+ is_current = p == state.page
- # Smart Resize with LANCZOS (high quality, reasonable speed)
- if target_size:
- if img.width > target_size or img.height > target_size:
- # Use BILINEAR for speed on large downscales, LANCZOS for quality
- resampling = Image.Resampling.BILINEAR if max(img.width, img.height) > target_size * 3 else Image.Resampling.LANCZOS
- img.thumbnail((target_size, target_size), resampling)
+ btn_color = 'green' if is_current else ('light-green-8' if has_tags else 'grey-8')
+ btn = ui.button(str(p + 1), on_click=lambda page=p: set_page(page)) \
+ .props(f'flat dense size=sm color={btn_color}')
- buf = BytesIO()
- # WebP with speed optimization
- img.save(buf, format="WEBP", quality=quality, method=4) # method=4 is faster than default 6
- return buf.getvalue()
- except Exception:
- return None
-
- @staticmethod
- def revert_action(action):
- """Undoes move operations."""
- if action['type'] == 'move' and os.path.exists(action['t_dst']):
- shutil.move(action['t_dst'], action['t_src'])
- elif action['type'] in ['unused', 'cat_move']:
- if os.path.exists(action['t_dst']):
- shutil.move(action['t_dst'], action['t_src'])
- if 'c_dst' in action and os.path.exists(action['c_dst']):
- shutil.move(action['c_dst'], action['c_src'])
+ if is_current:
+ btn.classes('font-bold')
- @staticmethod
- def get_processed_log():
- """Retrieves history of processed files."""
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("SELECT * FROM processed_log")
- rows = cursor.fetchall()
- return {r[0]: {"cat": r[1], "action": r[2]} for r in rows}
+ if end < state.total_pages:
+ if end < state.total_pages - 1:
+ ui.label('...').classes('text-gray-600 px-1')
+ ui.button(str(state.total_pages), on_click=lambda: set_page(state.total_pages - 1)) \
+ .props('flat dense size=sm color=grey')
- @staticmethod
- def fix_permissions(path):
- """Forces file to be fully accessible."""
- try:
- os.chmod(path, 0o777)
- except Exception:
- pass
+def set_page(p: int):
+ """Navigate to specific page."""
+ state.page = max(0, min(p, state.total_pages - 1))
+ refresh_ui()
- @staticmethod
- def commit_batch(file_list, output_root, cleanup_mode, operation="Copy"):
- """Commits files and fixes permissions."""
- data = SorterEngine.get_staged_data()
+def refresh_ui():
+ """Refresh all UI components."""
+ render_sidebar()
+ render_pagination()
+ render_gallery()
+ render_stats()
+
+def handle_keyboard(e):
+ """Handle keyboard navigation."""
+ if not e.action.keydown:
+ return
+
+ if e.key.arrow_left and state.page > 0:
+ set_page(state.page - 1)
+ elif e.key.arrow_right and state.page < state.total_pages - 1:
+ set_page(state.page + 1)
+
+# ==========================================
+# MAIN LAYOUT
+# ==========================================
+
+def build_header():
+ """Build application header with refined design."""
+ with ui.header().classes('app-header items-center text-white').style('height: 72px; padding: 0 24px'):
+ with ui.row().classes('w-full items-center gap-6 no-wrap'):
+ # Logo
+ with ui.row().classes('items-center gap-2 shrink-0'):
+ ui.icon('photo_library', size='28px').classes('text-green-400')
+ ui.label('NiceSorter').classes('text-xl font-bold text-white')
+
+ # Profile selector
+ profile_names = list(state.profiles.keys())
+
+ def change_profile(e):
+ state.profile_name = e.value
+ state.load_active_profile()
+ load_images()
+
+ with ui.row().classes('items-center gap-2'):
+ ui.icon('person', size='18px').classes('text-gray-500')
+ ui.select(profile_names, value=state.profile_name, on_change=change_profile) \
+ .props('dark dense borderless').classes('w-28')
+
+ # Source and output paths
+ with ui.row().classes('flex-grow gap-3'):
+ with ui.row().classes('flex-grow items-center gap-2'):
+ ui.icon('folder_open', size='18px').classes('text-blue-400')
+ ui.input('Source').bind_value(state, 'source_dir') \
+ .classes('flex-grow').props('dark dense outlined')
+
+ with ui.row().classes('flex-grow items-center gap-2'):
+ ui.icon('save', size='18px').classes('text-green-400')
+ ui.input('Output').bind_value(state, 'output_dir') \
+ .classes('flex-grow').props('dark dense outlined')
+
+ # Action buttons
+ ui.button(icon='save', on_click=state.save_current_profile) \
+ .props('flat round').classes('text-white').tooltip('Save Profile')
+
+ ui.button('LOAD', on_click=load_images) \
+ .props('unelevated color=green').classes('font-semibold px-6')
+
+ # Settings menu
+ with ui.button(icon='tune').props('flat round').classes('text-white'):
+ with ui.menu().classes('p-4').style('background: var(--bg-elevated); min-width: 280px'):
+ ui.label('Display Settings').classes('text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4')
+
+ ui.label('Grid Columns').classes('text-gray-400 text-sm mb-1')
+ ui.slider(
+ min=2, max=8, step=1,
+ value=state.grid_cols,
+ on_change=lambda e: (setattr(state, 'grid_cols', int(e.value)), refresh_ui())
+ ).props('color=green label-always')
+
+ ui.label('Preview Quality').classes('text-gray-400 text-sm mb-1 mt-4')
+ ui.slider(
+ min=10, max=100, step=10,
+ value=state.preview_quality,
+ on_change=lambda e: (setattr(state, 'preview_quality', int(e.value)), refresh_ui())
+ ).props('color=green label-always')
+
+def build_sidebar():
+ """Build left sidebar with refined styling."""
+ with ui.left_drawer(value=True).classes('app-sidebar p-5').props('width=300'):
+ state.sidebar_container = ui.column().classes('w-full')
+
+def build_main_content():
+ """Build main content area."""
+ with ui.column().classes('w-full p-6 min-h-screen text-white').style('background: var(--bg-deep)'):
+ # Stats bar
+ state.stats_container = ui.row().classes('w-full mb-4')
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
-
- if not os.path.exists(output_root):
- os.makedirs(output_root, exist_ok=True)
-
- for file_path in file_list:
- if not os.path.exists(file_path):
- continue
+ # Pagination
+ state.pagination_container = ui.column().classes('w-full items-center mb-6')
+
+ # Gallery grid
+ state.grid_container = ui.column().classes('w-full')
+
+ # Footer with batch controls
+ ui.separator().classes('my-10 opacity-10')
+
+ with ui.card().classes('w-full p-6 rounded-2xl').style('background: var(--bg-card); border: 1px solid var(--border-subtle)'):
+ with ui.row().classes('w-full justify-between items-center flex-wrap gap-6'):
+ # Tagged files mode
+ with ui.column().classes('gap-2'):
+ ui.label('Tagged Files').classes('text-xs font-semibold text-gray-400 uppercase tracking-wider')
+ ui.radio(['Copy', 'Move'], value=state.batch_mode) \
+ .bind_value(state, 'batch_mode') \
+ .props('inline dark color=green')
- # --- CASE A: Tagged ---
- if file_path in data and data[file_path]['marked']:
- info = data[file_path]
- final_dst = os.path.join(output_root, info['name'])
+ # Untagged files mode
+ with ui.column().classes('gap-2'):
+ ui.label('Untagged Files').classes('text-xs font-semibold text-gray-400 uppercase tracking-wider')
+ ui.radio(['Keep', 'Move to Unused', 'Delete'], value=state.cleanup_mode) \
+ .bind_value(state, 'cleanup_mode') \
+ .props('inline dark color=green')
+
+ # Action buttons
+ with ui.row().classes('items-center gap-4'):
+ ui.button('Apply Page', on_click=action_apply_page) \
+ .props('outline color=white').classes('px-6')
- # Collision Check
- if os.path.exists(final_dst):
- root, ext = os.path.splitext(info['name'])
- c = 1
- while os.path.exists(final_dst):
- final_dst = os.path.join(output_root, f"{root}_{c}{ext}")
- c += 1
-
- if operation == "Copy":
- shutil.copy2(file_path, final_dst)
- else:
- shutil.move(file_path, final_dst)
+ ui.button('Apply Global', on_click=action_apply_global) \
+ .props('unelevated').classes('btn-danger px-6')
- SorterEngine.fix_permissions(final_dst)
+# ==========================================
+# INITIALIZATION
+# ==========================================
- cursor.execute("DELETE FROM staging_area WHERE original_path = ?", (file_path,))
- cursor.execute("INSERT OR REPLACE INTO processed_log VALUES (?, ?, ?)",
- (file_path, info['cat'], operation))
-
- # --- CASE B: Cleanup ---
- elif cleanup_mode != "Keep":
- if cleanup_mode == "Move to Unused":
- unused_dir = os.path.join(os.path.dirname(file_path), "unused")
- os.makedirs(unused_dir, exist_ok=True)
- dest_unused = os.path.join(unused_dir, os.path.basename(file_path))
- shutil.move(file_path, dest_unused)
- SorterEngine.fix_permissions(dest_unused)
- elif cleanup_mode == "Delete":
- os.remove(file_path)
-
- conn.commit()
+# Inject custom CSS
+ui.add_head_html(CUSTOM_CSS)
- @staticmethod
- def delete_category(name):
- """Deletes a category and clears any staged tags associated with it."""
- with SorterEngine.get_connection() as conn:
- cursor = conn.cursor()
- cursor.execute("DELETE FROM categories WHERE name = ?", (name,))
- cursor.execute("DELETE FROM staging_area WHERE target_category = ?", (name,))
- cursor.execute("DELETE FROM folder_tags WHERE target_category = ?", (name,))
- conn.commit()
+# Initialize database
+SorterEngine.init_db()
- @staticmethod
- def get_tagged_page_indices(all_images, page_size):
- staged = SorterEngine.get_staged_data()
- if not staged:
- return set()
- tagged_pages = set()
- staged_keys = set(staged.keys())
- for idx, img_path in enumerate(all_images):
- if img_path in staged_keys:
- tagged_pages.add(idx // page_size)
- return tagged_pages
\ No newline at end of file
+# Build UI
+build_header()
+build_sidebar()
+build_main_content()
+
+# Setup keyboard navigation
+ui.keyboard(on_key=handle_keyboard)
+
+# Enable dark mode
+ui.dark_mode().enable()
+
+# Initial load
+load_images()
+
+# Run server
+ui.run(title="NiceSorter", host="0.0.0.0", port=8080, reload=False)
\ No newline at end of file