Update gallery_app.py

This commit is contained in:
2026-01-19 20:21:46 +01:00
parent 3e9ff43bc9
commit 690aaafacf

View File

@@ -1,6 +1,7 @@
import os import os
import math import math
import asyncio import asyncio
import json
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
@@ -9,27 +10,50 @@ from engine import SorterEngine
# 1. STATE MANAGEMENT # 1. STATE MANAGEMENT
# ========================================== # ==========================================
class AppState: class AppState:
def __init__(self, profile_name="Default"): def __init__(self):
self.profile_name = profile_name # Profile Data
profiles = SorterEngine.load_profiles() self.profiles = SorterEngine.load_profiles()
p_data = profiles.get(profile_name, {}) self.profile_name = "Default"
if not self.profiles:
self.profiles = {"Default": {"tab5_source": "/storage", "tab5_out": "/storage"}}
self.source_dir = p_data.get("tab5_source", "/storage") # Load initial paths
self.output_dir = p_data.get("tab5_out", "/storage") self.load_active_profile()
# Navigation State
self.page = 0 self.page = 0
self.page_size = 24 self.page_size = 24
self.grid_cols = 4 self.grid_cols = 4
# Tagging State
self.active_cat = "Default" self.active_cat = "Default"
self.next_index = 1 self.next_index = 1
# Batch Settings
self.batch_mode = "Copy" self.batch_mode = "Copy"
self.cleanup_mode = "Keep" self.cleanup_mode = "Keep"
# Data Caches
self.all_images = [] self.all_images = []
self.staged_data = {} self.staged_data = {}
self.green_dots = set() self.green_dots = set()
self.index_map = {} # {number: path} for previews self.index_map = {} # {number: path_to_image}
def load_active_profile(self):
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")
def save_current_profile(self):
# Update local dict
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
# Persist to disk via Engine
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')
state = AppState() state = AppState()
@@ -40,26 +64,27 @@ state = AppState()
@app.get('/thumbnail') @app.get('/thumbnail')
async def get_thumbnail(path: str, size: int = 400): async def get_thumbnail(path: str, size: int = 400):
if not os.path.exists(path): return Response(status_code=404) if not os.path.exists(path): return Response(status_code=404)
# CPU bound to prevent UI freeze
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 70, size) img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 70, 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):
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 95, None) if not os.path.exists(path): return Response(status_code=404)
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")
# ========================================== # ==========================================
# 3. LOGIC & ACTIONS # 3. CORE LOGIC
# ========================================== # ==========================================
def save_profile_settings():
"""Feature 5: Saves current paths to profiles.json"""
SorterEngine.save_tab_paths(state.profile_name, t5_s=state.source_dir, t5_o=state.output_dir)
ui.notify("Profile Saved!", type='positive')
def load_images(): def load_images():
if os.path.exists(state.source_dir): if os.path.exists(state.source_dir):
state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) state.all_images = SorterEngine.get_images(state.source_dir, recursive=True)
# Reset page if out of bounds
total_pages = math.ceil(len(state.all_images) / state.page_size)
if state.page >= total_pages: state.page = 0
refresh_staged_info() refresh_staged_info()
refresh_ui() refresh_ui()
else: else:
@@ -68,45 +93,60 @@ def load_images():
def refresh_staged_info(): def refresh_staged_info():
state.staged_data = SorterEngine.get_staged_data() state.staged_data = SorterEngine.get_staged_data()
# Calculate Pagination Dots # 1. Update Green Dots (Pagination)
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)
# Calculate Sidebar Index Map # 2. Update Sidebar Index Map
state.index_map.clear() state.index_map.clear()
# A. Check Staging (Memory)
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: try:
# Parse "CatName_005.jpg"
num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) num = int(info['name'].rsplit('_', 1)[1].split('.')[0])
state.index_map[num] = orig_path state.index_map[num] = orig_path # Point to original for preview
except: pass except: pass
# B. Check Disk (Output Folder)
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 f in os.listdir(cat_path):
if f.startswith(state.active_cat) and "_" in f: if f.startswith(state.active_cat) and "_" in f:
try: try:
num = int(f.rsplit('_', 1)[1].split('.')[0]) num = int(f.rsplit('_', 1)[1].split('.')[0])
# Staging takes precedence, otherwise add disk file
if num not in state.index_map: if num not in state.index_map:
state.index_map[num] = os.path.join(cat_path, f) state.index_map[num] = os.path.join(cat_path, f)
except: pass except: pass
def get_current_batch(): def get_current_batch():
if not state.all_images: return []
start = state.page * state.page_size start = state.page * state.page_size
return state.all_images[start : start + state.page_size] return state.all_images[start : start + state.page_size]
# --- ACTIONS ---
def action_tag(img_path, manual_idx=None): def action_tag(img_path, manual_idx=None):
idx = manual_idx if manual_idx else state.next_index idx = manual_idx if manual_idx 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}"
if os.path.exists(os.path.join(state.output_dir, state.active_cat, name)): # Conflict Check
name = f"{state.active_cat}_{idx:03d}_{idx}{ext}" 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}"
SorterEngine.stage_image(img_path, state.active_cat, name) SorterEngine.stage_image(img_path, state.active_cat, name)
# Auto-increment if we used the global counter
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
@@ -120,18 +160,37 @@ def action_untag(img_path):
def action_delete(img_path): def action_delete(img_path):
SorterEngine.delete_to_trash(img_path) SorterEngine.delete_to_trash(img_path)
load_images() # Rescan needed
def action_apply_page():
batch = get_current_batch()
if not batch: 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() load_images()
def action_apply_global():
ui.notify("Starting Global Apply... This may take a moment.")
# Run in background to keep UI responsive
run.io_bound(SorterEngine.commit_global, state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir)
# We reload after a short delay or assume user will click reload
load_images()
ui.notify("Global Apply Complete!", type='positive')
# ========================================== # ==========================================
# 4. UI COMPONENTS # 4. UI COMPONENTS
# ========================================== # ==========================================
def open_zoom_dialog(path, title=None): def open_zoom_dialog(path, title=None):
with ui.dialog() as dialog, ui.card().classes('w-full max-w-screen-xl p-0 gap-0 bg-black overflow-hidden'): """Shows full screen image modal"""
with ui.row().classes('w-full justify-between items-center p-3 bg-gray-900 text-white'): with ui.dialog() as dialog, ui.card().classes('w-full max-w-screen-xl p-0 gap-0 bg-black'):
ui.label(title or os.path.basename(path)).classes('font-bold text-lg') 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.button(icon='close', on_click=dialog.close).props('flat round dense color=white') 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 max-h-[85vh] object-contain')
# Full Res Image
ui.image(f"/full_res?path={path}").classes('w-full h-auto object-contain max-h-[85vh]')
dialog.open() dialog.open()
def render_sidebar(): def render_sidebar():
@@ -139,125 +198,209 @@ def render_sidebar():
with sidebar_container: with 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')
# Feature 4: Grid Previews # --- FEATURE 4: SIDEBAR PREVIEW GRID ---
with ui.grid(columns=5).classes('gap-1 mb-4 w-full'): with ui.grid(columns=5).classes('gap-1 mb-4 w-full'):
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'
def click_grid(num=i): # Closure to capture 'i' and 'is_used'
def click_grid(num=i, used=is_used):
state.next_index = num state.next_index = num
if num in state.index_map: if used:
# Opens high-res preview of what's already tagged # Feature 4: Open Preview
open_zoom_dialog(state.index_map[num], f"Preview Index #{num}") open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}")
render_sidebar() render_sidebar() # Update Next # input
ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-800') ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-800')
cats = SorterEngine.get_categories() or ["Default"] # --- CATEGORY SELECTOR ---
ui.select(cats, value=state.active_cat, on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ categories = SorterEngine.get_categories() or ["Default"]
.classes('w-full').props('dark outlined label="Active Category"') if state.active_cat not in categories: state.active_cat = categories[0]
def change_cat(e):
state.active_cat = e.value
refresh_staged_info()
render_sidebar()
ui.select(categories, value=state.active_cat, label="Active Category", on_change=change_cat) \
.classes('w-full').props('dark outlined')
# --- ADD 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 = ui.input(placeholder='Add...').props('dense outlined dark').classes('flex-grow') new_cat_input = ui.input(placeholder='New...').props('dense outlined dark').classes('flex-grow')
ui.button(icon='add', on_click=lambda: (SorterEngine.add_category(new_cat.value), render_sidebar())).props('flat color=green') def add_it():
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_it).props('flat color=green')
with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400'): # --- DELETE CATEGORY ---
ui.button('DELETE CAT', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), render_sidebar())).classes('w-full') 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')
ui.separator().classes('my-4 bg-gray-800') ui.separator().classes('my-4 bg-gray-700')
with ui.row().classes('w-full items-center no-wrap'):
ui.number(label="Next #").bind_value(state, 'next_index').classes('flex-grow').props('dark dense') # --- INDEX COUNTER ---
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') 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')
def auto_detect():
used = state.index_map.keys()
state.next_index = max(used) + 1 if used else 1
ui.button('🔄', on_click=lambda: (auto_detect(), render_sidebar())).props('flat color=white')
def render_gallery(): def render_gallery():
grid_container.clear() grid_container.clear()
batch = get_current_batch() batch = get_current_batch()
# Dynamic thumbnail sizing
thumb_size = int(1600 / state.grid_cols) thumb_size = int(1600 / state.grid_cols)
with grid_container: with grid_container:
with ui.grid(columns=state.grid_cols).classes('w-full gap-4'): 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 is_staged = img_path in state.staged_data
with ui.card().classes('p-2 bg-slate-900 border border-slate-700 no-shadow'):
with ui.row().classes('w-full justify-between items-center no-wrap mb-1'): with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'):
ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-slate-400 truncate') # Header
with ui.row().classes('w-full justify-between no-wrap mb-1'):
ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-gray-400 truncate')
with ui.row().classes('gap-0'): 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 color=white') 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 color=red') ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense color=red')
ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-56 object-cover rounded shadow-md').props('no-spinner') # Image
ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-48 object-cover rounded').props('no-spinner')
# Actions
if is_staged: if is_staged:
info = state.staged_data[img_path] info = state.staged_data[img_path]
ui.label(f"🏷️ {info['name']}").classes('text-center text-green-400 text-xs py-1 mt-2 bg-green-950/40 rounded') # Extract number for "Untag (#5)"
ui.button('Untag', on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-4 w-full') try:
num = info['name'].rsplit('_', 1)[1].split('.')[0]
label = f"Untag (#{num})"
except:
label = "Untag"
ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full')
ui.button(label, on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5 dense').classes('w-full')
else: else:
with ui.row().classes('w-full no-wrap mt-2 gap-1'): with ui.row().classes('w-full no-wrap mt-2 gap-1'):
l_idx = ui.number(value=state.next_index, precision=0).props('dense dark outlined').classes('w-1/3') # Local index input
ui.button('Tag', on_click=lambda p=img_path, i=l_idx: action_tag(p, int(i.value))).classes('flex-grow').props('color=green-7') 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() pagination_container.clear()
if not state.all_images: return
total_pages = math.ceil(len(state.all_images) / state.page_size) total_pages = math.ceil(len(state.all_images) / state.page_size)
if total_pages <= 1: return if total_pages <= 1: return
with pagination_container: with pagination_container:
# Debounced Slider for rapid navigation # --- 1. SLIDER (Restored) ---
ui.slider(min=0, max=total_pages-1, value=state.page, def on_slide(e):
on_change=lambda e: set_page(e.value)).classes('w-96 mb-2').props('dark') state.page = int(e.value)
refresh_ui()
with ui.row().classes('items-center gap-1'): ui.slider(min=0, max=total_pages-1, value=state.page, on_change=on_slide).classes('w-1/2 mb-2').props('color=green')
ui.button('', on_click=lambda: set_page(state.page - 1)).props('flat color=white')
# Responsive page window # --- 2. CAROUSEL BUTTONS ---
for p in range(max(0, state.page-2), min(total_pages, state.page+3)): with ui.row().classes('items-center gap-2'):
label = f"{p+1}{' 🟢' if p in state.green_dots else ''}" ui.button('', on_click=lambda: set_page(state.page - 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x > 0)
ui.button(label, on_click=lambda p=p: set_page(p)).props(f'flat color={"green" if p==state.page else "white"}')
ui.button('', on_click=lambda: set_page(state.page + 1)).props('flat color=white') # Show window of 5 pages
start = max(0, state.page - 2)
end = min(total_pages, state.page + 3)
for p in range(start, end):
dot = " 🟢" if p in state.green_dots else ""
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('', on_click=lambda: set_page(state.page + 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x < total_pages - 1)
def set_page(p): def set_page(p):
state.page = max(0, min(p, math.ceil(len(state.all_images)/state.page_size)-1)) state.page = p
refresh_ui() refresh_ui()
def refresh_ui(): def refresh_ui():
render_sidebar(); render_pagination(); render_gallery() render_sidebar()
render_pagination()
render_gallery()
def handle_key(e):
if not e.action.keydown: return
if e.key.arrow_left: set_page(max(0, state.page - 1))
if e.key.arrow_right:
total = math.ceil(len(state.all_images) / state.page_size)
set_page(min(total - 1, state.page + 1))
# ========================================== # ==========================================
# 5. MAIN LAYOUT # 5. MAIN LAYOUT
# ========================================== # ==========================================
with ui.header().classes('items-center bg-slate-950 text-white border-b border-slate-800 px-4').style('height: 75px'): # HEADER
with ui.row().classes('w-full items-center gap-4 no-wrap'): with ui.header().classes('items-center bg-slate-900 text-white border-b border-gray-700').style('height: 70px'):
ui.label('🖼️ NiceSorter').classes('text-2xl font-black text-green-500 italic shrink-0') with ui.row().classes('w-full items-center gap-4 no-wrap px-4'):
with ui.row().classes('flex-grow gap-2'): ui.label('🖼️ NiceSorter').classes('text-xl font-bold shrink-0 text-green-400')
ui.input('Source Path').bind_value(state, 'source_dir').classes('flex-grow').props('dark dense outlined')
ui.input('Output Path').bind_value(state, 'output_dir').classes('flex-grow').props('dark dense outlined')
ui.button('LOAD', on_click=load_images).props('color=white flat').classes('font-bold px-4')
ui.button(icon='save', on_click=save_profile_settings).props('flat color=white').tooltip('Save Paths to Profile')
ui.switch(value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green')
with ui.left_drawer(value=True).classes('bg-slate-950 p-4 border-r border-slate-900').props('width=320'): # --- FEATURE 5: PROFILE MANAGER ---
# Profile Select
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 (Flex Grow)
with ui.row().classes('flex-grow gap-2'):
ui.input('Source').bind_value(state, 'source_dir').classes('flex-grow').props('dark dense outlined')
ui.input('Output').bind_value(state, 'output_dir').classes('flex-grow').props('dark dense outlined')
# Save Profile Button
ui.button(icon='save', on_click=state.save_current_profile).props('flat round color=white').tooltip('Save Paths to Profile')
# Load Button
ui.button('LOAD FILES', on_click=load_images).props('color=green flat').classes('font-bold border border-green-700')
# Dark Toggle
ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green')
# SIDEBAR
with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'):
sidebar_container = ui.column().classes('w-full') sidebar_container = ui.column().classes('w-full')
with ui.column().classes('w-full p-6 bg-slate-900 min-h-screen text-white'): # CONTENT
pagination_container = ui.column().classes('w-full items-center mb-8') with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'):
pagination_container = ui.column().classes('w-full items-center mb-4')
grid_container = ui.column().classes('w-full') grid_container = ui.column().classes('w-full')
# Feature 3: Global Apply Fully Integrated # --- FOOTER: BATCH SETTINGS & GLOBAL APPLY ---
ui.separator().classes('my-12 bg-slate-800') ui.separator().classes('my-10 bg-gray-800')
with ui.row().classes('w-full justify-around p-8 bg-slate-950 rounded-2xl border border-slate-800 shadow-2xl'): with ui.row().classes('w-full justify-around p-6 bg-gray-950 rounded-xl border border-gray-800'):
# Settings
with ui.column(): with ui.column():
ui.label('Tagged Action:').classes('text-slate-500 text-xs uppercase font-bold mb-2') 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') ui.radio(['Copy', 'Move'], value=state.batch_mode).bind_value(state, 'batch_mode').props('inline dark color=green')
with ui.column():
ui.label('Cleanup Strategy:').classes('text-slate-500 text-xs uppercase font-bold mb-2')
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=lambda: (SorterEngine.commit_batch(get_current_batch(), state.output_dir, state.cleanup_mode, state.batch_mode), load_images())).props('outline color=white lg').classes('px-8')
ui.button('APPLY GLOBAL', on_click=lambda: (SorterEngine.commit_global(state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir), load_images())).props('lg color=red-7').classes('font-black px-12')
# Init & Hotkeys with ui.column():
ui.keyboard(on_key=lambda e: (set_page(state.page-1) if e.key.arrow_left and e.action.keydown else set_page(state.page+1) if e.key.arrow_right and e.action.keydown else None)) 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')
# Feature 3: Global Apply
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')
# STARTUP
ui.keyboard(on_key=handle_key)
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="Nice Sorter", host="0.0.0.0", port=8080, reload=False)