clause sonet

This commit is contained in:
2026-01-20 01:27:24 +01:00
parent 0e6de4ae0b
commit 54ba10d4e5

View File

@@ -1,15 +1,17 @@
import os import os
import math import math
import asyncio import asyncio
import json from typing import Optional, List, Dict, Set
from nicegui import ui, app, run from nicegui import ui, app, run
from fastapi import Response from fastapi import Response
from engine import SorterEngine from engine import SorterEngine
# ========================================== # ==========================================
# 1. STATE MANAGEMENT # STATE MANAGEMENT
# ========================================== # ==========================================
class AppState: class AppState:
"""Centralized application state with lazy loading."""
def __init__(self): def __init__(self):
# Profile Data # Profile Data
self.profiles = SorterEngine.load_profiles() self.profiles = SorterEngine.load_profiles()
@@ -17,7 +19,6 @@ class AppState:
if not self.profiles: if not self.profiles:
self.profiles = {"Default": {"tab5_source": "/storage", "tab5_out": "/storage"}} self.profiles = {"Default": {"tab5_source": "/storage", "tab5_out": "/storage"}}
# Load initial paths
self.load_active_profile() self.load_active_profile()
# View Settings # View Settings
@@ -35,99 +36,139 @@ class AppState:
self.cleanup_mode = "Keep" self.cleanup_mode = "Keep"
# Data Caches # Data Caches
self.all_images = [] self.all_images: List[str] = []
self.staged_data = {} self.staged_data: Dict = {}
self.green_dots = set() self.green_dots: Set[int] = set()
self.index_map = {} self.index_map: Dict[int, str] = {}
# UI Containers (populated later)
self.sidebar_container = None
self.grid_container = None
self.pagination_container = None
def load_active_profile(self): def load_active_profile(self):
"""Load paths from active profile."""
p_data = self.profiles.get(self.profile_name, {}) p_data = self.profiles.get(self.profile_name, {})
self.source_dir = p_data.get("tab5_source", "/storage") self.source_dir = p_data.get("tab5_source", "/storage")
self.output_dir = p_data.get("tab5_out", "/storage") self.output_dir = p_data.get("tab5_out", "/storage")
def save_current_profile(self): def save_current_profile(self):
"""Save current paths to active profile."""
if self.profile_name not in self.profiles: if self.profile_name not in self.profiles:
self.profiles[self.profile_name] = {} self.profiles[self.profile_name] = {}
self.profiles[self.profile_name]["tab5_source"] = self.source_dir self.profiles[self.profile_name]["tab5_source"] = self.source_dir
self.profiles[self.profile_name]["tab5_out"] = self.output_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) 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') ui.notify(f"Profile '{self.profile_name}' saved!", type='positive')
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
@property
def total_pages(self) -> int:
"""Calculate total pages."""
return math.ceil(len(self.all_images) / self.page_size) if self.all_images else 0
def get_current_batch(self) -> List[str]:
"""Get images for current page."""
if not self.all_images:
return []
start = self.page * self.page_size
return self.all_images[start : start + self.page_size]
state = AppState() state = AppState()
# ========================================== # ==========================================
# 2. IMAGE SERVING API # IMAGE SERVING API
# ========================================== # ==========================================
@app.get('/thumbnail') @app.get('/thumbnail')
async def get_thumbnail(path: str, size: int = 400, q: int = 50): async def get_thumbnail(path: str, size: int = 400, q: int = 50):
"""Serves WebP thumbnail with dynamic quality.""" """Serve WebP thumbnail with dynamic quality."""
if not os.path.exists(path): return Response(status_code=404) if not os.path.exists(path):
return Response(status_code=404)
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, q, size) img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, q, size)
return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500) return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500)
@app.get('/full_res') @app.get('/full_res')
async def get_full_res(path: str): async def get_full_res(path: str):
if not os.path.exists(path): return Response(status_code=404) """Serve full resolution image."""
if not os.path.exists(path):
return Response(status_code=404)
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None) img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None)
return Response(content=img_bytes, media_type="image/webp") return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500)
# ========================================== # ==========================================
# 3. CORE LOGIC # CORE LOGIC
# ========================================== # ==========================================
def load_images(): def load_images():
if os.path.exists(state.source_dir): """Load images from source directory."""
state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) if not os.path.exists(state.source_dir):
total_pages = math.ceil(len(state.all_images) / state.page_size)
if state.page >= total_pages: state.page = 0
refresh_staged_info()
refresh_ui()
else:
ui.notify(f"Source not found: {state.source_dir}", type='warning') ui.notify(f"Source not found: {state.source_dir}", type='warning')
return
state.all_images = SorterEngine.get_images(state.source_dir, recursive=True)
# Reset page if out of bounds
if state.page >= state.total_pages:
state.page = 0
refresh_staged_info()
refresh_ui()
def refresh_staged_info(): def refresh_staged_info():
"""Update staged data and index maps."""
state.staged_data = SorterEngine.get_staged_data() state.staged_data = SorterEngine.get_staged_data()
# 1. Update Green Dots # Update green dots (pages with staged images)
state.green_dots.clear() state.green_dots.clear()
staged_keys = set(state.staged_data.keys()) staged_keys = set(state.staged_data.keys())
for idx, img_path in enumerate(state.all_images): for idx, img_path in enumerate(state.all_images):
if img_path in staged_keys: if img_path in staged_keys:
state.green_dots.add(idx // state.page_size) state.green_dots.add(idx // state.page_size)
# 2. Update Sidebar Index Map # Build index map for active category
state.index_map.clear() state.index_map.clear()
# Staging
# Add staged images
for orig_path, info in state.staged_data.items(): for orig_path, info in state.staged_data.items():
if info['cat'] == state.active_cat: if info['cat'] == state.active_cat:
try: idx = _extract_index(info['name'])
num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) if idx is not None:
state.index_map[num] = orig_path state.index_map[idx] = orig_path
except: pass
# Disk # Add committed images from disk
cat_path = os.path.join(state.output_dir, state.active_cat) cat_path = os.path.join(state.output_dir, state.active_cat)
if os.path.exists(cat_path): if os.path.exists(cat_path):
for f in os.listdir(cat_path): for filename in os.listdir(cat_path):
if f.startswith(state.active_cat) and "_" in f: if filename.startswith(state.active_cat):
try: idx = _extract_index(filename)
num = int(f.rsplit('_', 1)[1].split('.')[0]) if idx is not None and idx not in state.index_map:
if num not in state.index_map: state.index_map[idx] = os.path.join(cat_path, filename)
state.index_map[num] = os.path.join(cat_path, f)
except: pass
def get_current_batch(): def _extract_index(filename: str) -> Optional[int]:
if not state.all_images: return [] """Extract numeric index from filename (e.g., 'Cat_042.jpg' -> 42)."""
start = state.page * state.page_size try:
return state.all_images[start : start + state.page_size] return int(filename.rsplit('_', 1)[1].split('.')[0])
except (ValueError, IndexError):
return None
# --- ACTIONS --- # ==========================================
# ACTIONS
# ==========================================
def action_tag(img_path, manual_idx=None): def action_tag(img_path: str, manual_idx: Optional[int] = None):
idx = manual_idx if manual_idx else state.next_index """Tag an image with category and index."""
idx = manual_idx if manual_idx is not None else state.next_index
ext = os.path.splitext(img_path)[1] ext = os.path.splitext(img_path)[1]
name = f"{state.active_cat}_{idx:03d}{ext}" name = f"{state.active_cat}_{idx:03d}{ext}"
# Check for conflicts
final_path = os.path.join(state.output_dir, state.active_cat, name) 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} staged_names = {v['name'] for v in state.staged_data.values() if v['cat'] == state.active_cat}
@@ -136,39 +177,55 @@ def action_tag(img_path, manual_idx=None):
name = f"{state.active_cat}_{idx:03d}_{len(staged_names)+1}{ext}" name = f"{state.active_cat}_{idx:03d}_{len(staged_names)+1}{ext}"
SorterEngine.stage_image(img_path, state.active_cat, name) SorterEngine.stage_image(img_path, state.active_cat, name)
# Auto-increment only if using next_index
if manual_idx is None or manual_idx == state.next_index: if manual_idx is None or manual_idx == state.next_index:
state.next_index = idx + 1 state.next_index = idx + 1
refresh_staged_info(); refresh_ui()
def action_untag(img_path): refresh_staged_info()
refresh_ui()
def action_untag(img_path: str):
"""Remove staging from an image."""
SorterEngine.clear_staged_item(img_path) SorterEngine.clear_staged_item(img_path)
refresh_staged_info(); refresh_ui() refresh_staged_info()
refresh_ui()
def action_delete(img_path): def action_delete(img_path: str):
"""Delete image to trash."""
SorterEngine.delete_to_trash(img_path) SorterEngine.delete_to_trash(img_path)
load_images() load_images()
def action_apply_page(): def action_apply_page():
batch = get_current_batch() """Apply staged changes for current page only."""
if not batch: return 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) SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode)
ui.notify(f"Page Processed ({state.batch_mode})", type='positive') ui.notify(f"Page processed ({state.batch_mode})", type='positive')
load_images() load_images()
# --- FIX 2: ASYNC GLOBAL APPLY ---
async def action_apply_global(): async def action_apply_global():
ui.notify("Starting Global Apply... This may take a while.") """Apply all staged changes globally."""
# Must use 'await' with io_bound to actually wait for completion 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) await run.io_bound(
SorterEngine.commit_global,
state.output_dir,
state.cleanup_mode,
state.batch_mode,
state.source_dir
)
load_images() load_images()
ui.notify("Global Apply Complete!", type='positive') ui.notify("Global apply complete!", type='positive')
# ========================================== # ==========================================
# 4. UI RENDERERS # UI COMPONENTS
# ========================================== # ==========================================
def open_zoom_dialog(path, title=None): def open_zoom_dialog(path: str, title: Optional[str] = None):
"""Open full-resolution image dialog."""
with ui.dialog() as dialog, ui.card().classes('w-full max-w-screen-xl p-0 gap-0 bg-black'): with ui.dialog() as dialog, ui.card().classes('w-full max-w-screen-xl p-0 gap-0 bg-black'):
with ui.row().classes('w-full justify-between items-center p-2 bg-gray-900 text-white'): with ui.row().classes('w-full justify-between items-center p-2 bg-gray-900 text-white'):
ui.label(title or os.path.basename(path)).classes('font-bold truncate px-2') ui.label(title or os.path.basename(path)).classes('font-bold truncate px-2')
@@ -177,187 +234,297 @@ def open_zoom_dialog(path, title=None):
dialog.open() dialog.open()
def render_sidebar(): def render_sidebar():
sidebar_container.clear() """Render category management sidebar."""
with sidebar_container: state.sidebar_container.clear()
with state.sidebar_container:
ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white')
# --- FIX 1: SIDEBAR GRID CLICK --- # Number grid (1-25)
with ui.grid(columns=5).classes('gap-1 mb-4 w-full'): with ui.grid(columns=5).classes('gap-1 mb-4 w-full'):
# Explicit function to handle the logic
def click_grid(num, used):
state.next_index = num
# Safety check: ensure number exists in map before opening
if used and num in state.index_map:
open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}")
render_sidebar()
for i in range(1, 26): for i in range(1, 26):
is_used = i in state.index_map is_used = i in state.index_map
color = 'green' if is_used else 'grey-9' color = 'green' if is_used else 'grey-9'
# LAMBDA FIX: Capture 'i' (n) and 'is_used' (u) properly. def make_click_handler(num: int):
# Ignore the first arg 'e' (ClickEvent) def handler():
ui.button(str(i), on_click=lambda e, n=i, u=is_used: click_grid(n, u)) \ state.next_index = num
if num in state.index_map:
open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}")
return handler
ui.button(str(i), on_click=make_click_handler(i)) \
.props(f'color={color} size=sm flat') \ .props(f'color={color} size=sm flat') \
.classes('w-full border border-gray-800') .classes('w-full border border-gray-800')
# Category Select # Category selector
categories = SorterEngine.get_categories() or ["Default"] categories = state.get_categories()
if state.active_cat not in categories: state.active_cat = categories[0]
ui.select(categories, value=state.active_cat, label="Active Category", def on_category_change(e):
on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ state.active_cat = e.value
.classes('w-full').props('dark outlined') refresh_staged_info()
render_sidebar()
# Add / Delete ui.select(
categories,
value=state.active_cat,
label="Active Category",
on_change=on_category_change
).classes('w-full').props('dark outlined')
# Add new category
with ui.row().classes('w-full items-center no-wrap mt-2'): with ui.row().classes('w-full items-center no-wrap mt-2'):
new_cat_input = ui.input(placeholder='New...').props('dense outlined dark').classes('flex-grow') new_cat_input = ui.input(placeholder='New category...') \
def add_it(): .props('dense outlined dark').classes('flex-grow')
def add_category():
if new_cat_input.value: if new_cat_input.value:
SorterEngine.add_category(new_cat_input.value) SorterEngine.add_category(new_cat_input.value)
state.active_cat = new_cat_input.value state.active_cat = new_cat_input.value
refresh_staged_info(); render_sidebar() refresh_staged_info()
ui.button(icon='add', on_click=add_it).props('flat color=green') render_sidebar()
ui.button(icon='add', on_click=add_category).props('flat color=green')
# Delete category
with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'): with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'):
ui.button('DELETE CAT', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), refresh_staged_info(), render_sidebar())).classes('w-full') 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')
ui.separator().classes('my-4 bg-gray-700') ui.separator().classes('my-4 bg-gray-700')
# Counter # Index counter
with ui.row().classes('w-full items-end no-wrap'): with ui.row().classes('w-full items-end no-wrap'):
ui.number(label="Next Index", min=1, precision=0).bind_value(state, 'next_index').classes('flex-grow').props('dark outlined') ui.number(label="Next Index", min=1, precision=0) \
ui.button('🔄', on_click=lambda: (setattr(state, 'next_index', (max(state.index_map.keys())+1 if state.index_map else 1)), render_sidebar())).props('flat color=white') .bind_value(state, 'next_index') \
.classes('flex-grow').props('dark outlined')
def reset_index():
state.next_index = (max(state.index_map.keys()) + 1) if state.index_map else 1
render_sidebar()
ui.button('🔄', on_click=reset_index).props('flat color=white')
def render_gallery(): def render_gallery():
grid_container.clear() """Render image gallery grid."""
batch = get_current_batch() state.grid_container.clear()
thumb_size = 800 batch = state.get_current_batch()
with grid_container: with state.grid_container:
with ui.grid(columns=state.grid_cols).classes('w-full gap-3'): with ui.grid(columns=state.grid_cols).classes('w-full gap-3'):
for img_path in batch: for img_path in batch:
is_staged = img_path in state.staged_data render_image_card(img_path)
with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): def render_image_card(img_path: str):
# Header """Render individual image card."""
with ui.row().classes('w-full justify-between no-wrap mb-1'): is_staged = img_path in state.staged_data
ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-gray-400 truncate') thumb_size = 800
with ui.row().classes('gap-0'):
ui.button(icon='zoom_in', on_click=lambda p=img_path: open_zoom_dialog(p)).props('flat size=sm dense color=white')
ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense color=red')
# Image with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'):
ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \ # Header with filename and actions
.classes('w-full h-64 bg-black rounded') \ with ui.row().classes('w-full justify-between no-wrap mb-1'):
.props('fit=contain no-spinner') ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-gray-400 truncate')
with ui.row().classes('gap-0'):
ui.button(
icon='zoom_in',
on_click=lambda p=img_path: open_zoom_dialog(p)
).props('flat size=sm dense color=white')
ui.button(
icon='delete',
on_click=lambda p=img_path: action_delete(p)
).props('flat size=sm dense color=red')
# Actions # Thumbnail
if is_staged: ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \
info = state.staged_data[img_path] .classes('w-full h-64 bg-black rounded') \
try: num = info['name'].rsplit('_', 1)[1].split('.')[0] .props('fit=contain no-spinner')
except: num = "?"
ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full') # Tagging UI
ui.button(f"Untag (#{num})", on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5 dense').classes('w-full') if is_staged:
else: info = state.staged_data[img_path]
with ui.row().classes('w-full no-wrap mt-2 gap-1'): idx = _extract_index(info['name'])
local_idx = ui.number(value=state.next_index, precision=0).props('dense dark outlined').classes('w-1/3') idx_str = str(idx) if idx else "?"
ui.button('Tag', on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value))).classes('w-2/3').props('color=green dense') ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full')
ui.button(
f"Untag (#{idx_str})",
on_click=lambda p=img_path: action_untag(p)
).props('flat color=grey-5 dense').classes('w-full')
else:
with ui.row().classes('w-full no-wrap mt-2 gap-1'):
local_idx = ui.number(value=state.next_index, precision=0) \
.props('dense dark outlined').classes('w-1/3')
ui.button(
'Tag',
on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value))
).classes('w-2/3').props('color=green dense')
def render_pagination(): def render_pagination():
pagination_container.clear() """Render pagination controls."""
if not state.all_images: return state.pagination_container.clear()
total_pages = math.ceil(len(state.all_images) / state.page_size) if state.total_pages <= 1:
if total_pages <= 1: return return
with pagination_container: with state.pagination_container:
# Slider # Page slider
ui.slider(min=0, max=total_pages-1, value=state.page, on_change=lambda e: set_page(int(e.value))).classes('w-1/2 mb-2').props('color=green') ui.slider(
min=0,
max=state.total_pages - 1,
value=state.page,
on_change=lambda e: set_page(int(e.value))
).classes('w-1/2 mb-2').props('color=green')
# Buttons # Page buttons
with ui.row().classes('items-center gap-2'): with ui.row().classes('items-center gap-2'):
ui.button('', on_click=lambda: set_page(state.page - 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x > 0) # Previous button
if state.page > 0:
ui.button('', on_click=lambda: set_page(state.page - 1)).props('flat color=white')
# Page numbers (show current ±2)
start = max(0, state.page - 2) start = max(0, state.page - 2)
end = min(total_pages, state.page + 3) end = min(state.total_pages, state.page + 3)
for p in range(start, end): for p in range(start, end):
dot = " 🟢" if p in state.green_dots else "" dot = " 🟢" if p in state.green_dots else ""
color = "white" if p == state.page else "grey-6" color = "white" if p == state.page else "grey-6"
ui.button(f"{p+1}{dot}", on_click=lambda p=p: set_page(p)).props(f'flat color={color}') ui.button(
f"{p+1}{dot}",
on_click=lambda page=p: set_page(page)
).props(f'flat color={color}')
ui.button('', on_click=lambda: set_page(state.page + 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x < total_pages - 1) # Next button
if state.page < state.total_pages - 1:
ui.button('', on_click=lambda: set_page(state.page + 1)).props('flat color=white')
def set_page(p): def set_page(p: int):
state.page = p; refresh_ui() """Navigate to specific page."""
state.page = max(0, min(p, state.total_pages - 1))
refresh_ui()
def refresh_ui(): def refresh_ui():
render_sidebar(); render_pagination(); render_gallery() """Refresh all UI components."""
render_sidebar()
render_pagination()
render_gallery()
def handle_key(e): def handle_keyboard(e):
if not e.action.keydown: return """Handle keyboard navigation."""
if e.key.arrow_left: set_page(max(0, state.page - 1)) if not e.action.keydown:
if e.key.arrow_right: return
total = math.ceil(len(state.all_images) / state.page_size)
set_page(min(total - 1, state.page + 1)) 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)
# ========================================== # ==========================================
# 5. MAIN LAYOUT # MAIN LAYOUT
# ========================================== # ==========================================
with ui.header().classes('items-center bg-slate-900 text-white border-b border-gray-700').style('height: 70px'): def build_header():
with ui.row().classes('w-full items-center gap-4 no-wrap px-4'): """Build application header."""
ui.label('🖼️ NiceSorter').classes('text-xl font-bold shrink-0 text-green-400') with ui.header().classes('items-center bg-slate-900 text-white border-b border-gray-700').style('height: 70px'):
with ui.row().classes('w-full items-center gap-4 no-wrap px-4'):
ui.label('🖼️ NiceSorter').classes('text-xl font-bold shrink-0 text-green-400')
# Profile Select # Profile selector
profile_names = list(state.profiles.keys()) profile_names = list(state.profiles.keys())
def change_profile(e):
state.profile_name = e.value; state.load_active_profile(); load_images()
ui.select(profile_names, value=state.profile_name, on_change=change_profile).props('dark dense options-dense borderless').classes('w-32')
# Paths def change_profile(e):
with ui.row().classes('flex-grow gap-2'): state.profile_name = e.value
ui.input('Source').bind_value(state, 'source_dir').classes('flex-grow').props('dark dense outlined') state.load_active_profile()
ui.input('Output').bind_value(state, 'output_dir').classes('flex-grow').props('dark dense outlined') load_images()
ui.button(icon='save', on_click=state.save_current_profile).props('flat round color=white') ui.select(profile_names, value=state.profile_name, on_change=change_profile) \
ui.button('LOAD', on_click=load_images).props('color=green flat').classes('font-bold border border-green-700') .props('dark dense options-dense borderless').classes('w-32')
# View Menu # Source and output paths
with ui.button(icon='tune', color='white').props('flat round'): with ui.row().classes('flex-grow gap-2'):
with ui.menu().classes('bg-gray-800 text-white p-4'): ui.input('Source').bind_value(state, 'source_dir') \
ui.label('VIEW SETTINGS').classes('text-xs font-bold mb-2') .classes('flex-grow').props('dark dense outlined')
ui.label('Grid Columns:') ui.input('Output').bind_value(state, 'output_dir') \
ui.slider(min=2, max=8, step=1, value=state.grid_cols, on_change=lambda e: (setattr(state, 'grid_cols', e.value), refresh_ui())).props('color=green') .classes('flex-grow').props('dark dense outlined')
ui.label('Preview Quality:')
ui.slider(min=10, max=100, step=10, value=state.preview_quality, on_change=lambda e: (setattr(state, 'preview_quality', e.value), refresh_ui())).props('color=green label-always')
ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green') ui.button(icon='save', on_click=state.save_current_profile) \
.props('flat round color=white')
ui.button('LOAD', on_click=load_images) \
.props('color=green flat').classes('font-bold border border-green-700')
with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'): # View settings menu
sidebar_container = ui.column().classes('w-full') with ui.button(icon='tune', color='white').props('flat round'):
with ui.menu().classes('bg-gray-800 text-white p-4'):
ui.label('VIEW SETTINGS').classes('text-xs font-bold mb-2')
with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'): ui.label('Grid Columns:')
pagination_container = ui.column().classes('w-full items-center mb-4') ui.slider(
grid_container = ui.column().classes('w-full') min=2, max=8, step=1,
value=state.grid_cols,
on_change=lambda e: (setattr(state, 'grid_cols', e.value), refresh_ui())
).props('color=green')
# Footer ui.label('Preview Quality:')
ui.separator().classes('my-10 bg-gray-800') ui.slider(
with ui.row().classes('w-full justify-around p-6 bg-gray-950 rounded-xl border border-gray-800'): min=10, max=100, step=10,
with ui.column(): value=state.preview_quality,
ui.label('TAGGED FILES:').classes('text-gray-500 text-xs font-bold') on_change=lambda e: (setattr(state, 'preview_quality', e.value), refresh_ui())
ui.radio(['Copy', 'Move'], value=state.batch_mode).bind_value(state, 'batch_mode').props('inline dark color=green') ).props('color=green label-always')
with ui.column():
ui.label('UNTAGGED FILES:').classes('text-gray-500 text-xs font-bold')
ui.radio(['Keep', 'Move to Unused', 'Delete'], value=state.cleanup_mode).bind_value(state, 'cleanup_mode').props('inline dark color=green')
with ui.row().classes('items-center gap-6'):
ui.button('APPLY PAGE', on_click=action_apply_page).props('outline color=white lg')
with ui.column().classes('items-center'):
ui.button('APPLY GLOBAL', on_click=action_apply_global).props('lg color=red-900')
ui.label('(Process All)').classes('text-xs text-gray-500')
ui.keyboard(on_key=handle_key) ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)) \
.props('color=green')
def build_sidebar():
"""Build left sidebar."""
with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'):
state.sidebar_container = ui.column().classes('w-full')
def build_main_content():
"""Build main content area."""
with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'):
state.pagination_container = ui.column().classes('w-full items-center mb-4')
state.grid_container = ui.column().classes('w-full')
# Footer with batch controls
ui.separator().classes('my-10 bg-gray-800')
with ui.row().classes('w-full justify-around p-6 bg-gray-950 rounded-xl border border-gray-800'):
# Tagged files mode
with ui.column():
ui.label('TAGGED FILES:').classes('text-gray-500 text-xs font-bold')
ui.radio(['Copy', 'Move'], value=state.batch_mode) \
.bind_value(state, 'batch_mode') \
.props('inline dark color=green')
# Untagged files mode
with ui.column():
ui.label('UNTAGGED FILES:').classes('text-gray-500 text-xs font-bold')
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-6'):
ui.button('APPLY PAGE', on_click=action_apply_page) \
.props('outline color=white lg')
with ui.column().classes('items-center'):
ui.button('APPLY GLOBAL', on_click=action_apply_global) \
.props('lg color=red-900')
ui.label('(Process All)').classes('text-xs text-gray-500')
# ==========================================
# INITIALIZATION
# ==========================================
build_header()
build_sidebar()
build_main_content()
ui.keyboard(on_key=handle_keyboard)
ui.dark_mode().enable() ui.dark_mode().enable()
load_images() load_images()
ui.run(title="Nice Sorter", host="0.0.0.0", port=8080, reload=False)
ui.run(title="NiceSorter", host="0.0.0.0", port=8080, reload=False)