From 690aaafacf17d53e9a1cca615d6fc74dff65fff0 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 19 Jan 2026 20:21:46 +0100 Subject: [PATCH] Update gallery_app.py --- gallery_app.py | 313 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 228 insertions(+), 85 deletions(-) diff --git a/gallery_app.py b/gallery_app.py index 9d566cf..75e82a1 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -1,6 +1,7 @@ import os import math import asyncio +import json from nicegui import ui, app, run from fastapi import Response from engine import SorterEngine @@ -9,27 +10,50 @@ from engine import SorterEngine # 1. STATE MANAGEMENT # ========================================== class AppState: - def __init__(self, profile_name="Default"): - self.profile_name = profile_name - profiles = SorterEngine.load_profiles() - p_data = profiles.get(profile_name, {}) + 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"}} - self.source_dir = p_data.get("tab5_source", "/storage") - self.output_dir = p_data.get("tab5_out", "/storage") + # Load initial paths + self.load_active_profile() + # Navigation State self.page = 0 self.page_size = 24 self.grid_cols = 4 + + # Tagging State self.active_cat = "Default" self.next_index = 1 + # Batch Settings self.batch_mode = "Copy" self.cleanup_mode = "Keep" + # Data Caches self.all_images = [] self.staged_data = {} 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() @@ -40,26 +64,27 @@ state = AppState() @app.get('/thumbnail') async def get_thumbnail(path: str, size: int = 400): 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) return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500) @app.get('/full_res') 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") # ========================================== -# 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(): if os.path.exists(state.source_dir): 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_ui() else: @@ -68,45 +93,60 @@ def load_images(): def refresh_staged_info(): state.staged_data = SorterEngine.get_staged_data() - # Calculate Pagination Dots + # 1. Update Green Dots (Pagination) state.green_dots.clear() staged_keys = set(state.staged_data.keys()) for idx, img_path in enumerate(state.all_images): if img_path in staged_keys: state.green_dots.add(idx // state.page_size) - # Calculate Sidebar Index Map + # 2. Update Sidebar Index Map state.index_map.clear() + + # A. Check Staging (Memory) for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: try: + # Parse "CatName_005.jpg" 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 + # B. Check Disk (Output Folder) cat_path = os.path.join(state.output_dir, state.active_cat) if os.path.exists(cat_path): for f in os.listdir(cat_path): if f.startswith(state.active_cat) and "_" in f: try: num = int(f.rsplit('_', 1)[1].split('.')[0]) + # Staging takes precedence, otherwise add disk file if num not in state.index_map: state.index_map[num] = os.path.join(cat_path, f) except: pass def get_current_batch(): + if not state.all_images: return [] start = state.page * state.page_size return state.all_images[start : start + state.page_size] +# --- ACTIONS --- + def action_tag(img_path, manual_idx=None): idx = manual_idx if manual_idx else state.next_index ext = os.path.splitext(img_path)[1] name = f"{state.active_cat}_{idx:03d}{ext}" - if os.path.exists(os.path.join(state.output_dir, state.active_cat, name)): - name = f"{state.active_cat}_{idx:03d}_{idx}{ext}" + # Conflict Check + 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) + + # Auto-increment if we used the global counter if manual_idx is None or manual_idx == state.next_index: state.next_index = idx + 1 @@ -120,18 +160,37 @@ def action_untag(img_path): def action_delete(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() +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 # ========================================== 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'): - with ui.row().classes('w-full justify-between items-center p-3 bg-gray-900 text-white'): - ui.label(title or os.path.basename(path)).classes('font-bold text-lg') + """Shows full screen image modal""" + 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'): + 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.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() def render_sidebar(): @@ -139,125 +198,209 @@ def render_sidebar(): with sidebar_container: 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'): for i in range(1, 26): is_used = i in state.index_map 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 - if num in state.index_map: - # Opens high-res preview of what's already tagged - open_zoom_dialog(state.index_map[num], f"Preview Index #{num}") - render_sidebar() + if used: + # Feature 4: Open Preview + open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}") + 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') - cats = SorterEngine.get_categories() or ["Default"] - ui.select(cats, value=state.active_cat, on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ - .classes('w-full').props('dark outlined label="Active Category"') + # --- CATEGORY SELECTOR --- + categories = SorterEngine.get_categories() or ["Default"] + 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'): - new_cat = ui.input(placeholder='Add...').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') + new_cat_input = ui.input(placeholder='New...').props('dense outlined dark').classes('flex-grow') + 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'): - ui.button('DELETE CAT', color='red', on_click=lambda: (SorterEngine.delete_category(state.active_cat), render_sidebar())).classes('w-full') + # --- DELETE CATEGORY --- + 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') - 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') - 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') + ui.separator().classes('my-4 bg-gray-700') + + # --- INDEX COUNTER --- + 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(): grid_container.clear() batch = get_current_batch() + # Dynamic thumbnail sizing thumb_size = int(1600 / state.grid_cols) 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: 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'): - ui.label(os.path.basename(img_path)[:15]).classes('text-xs text-slate-400 truncate') + + with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'): + # 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'): - ui.button(icon='zoom_in', on_click=lambda p=img_path: open_zoom_dialog(p)).props('flat size=sm color=white') - ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm color=red') + 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') - 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: 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') - ui.button('Untag', on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-4 w-full') + # Extract number for "Untag (#5)" + 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: 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') - 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 index input + 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(): pagination_container.clear() + if not state.all_images: return + total_pages = math.ceil(len(state.all_images) / state.page_size) if total_pages <= 1: return - + with pagination_container: - # Debounced Slider for rapid navigation - ui.slider(min=0, max=total_pages-1, value=state.page, - on_change=lambda e: set_page(e.value)).classes('w-96 mb-2').props('dark') - - with ui.row().classes('items-center gap-1'): - ui.button('◀', on_click=lambda: set_page(state.page - 1)).props('flat color=white') - # Responsive page window - for p in range(max(0, state.page-2), min(total_pages, state.page+3)): - label = f"{p+1}{' 🟢' if p in state.green_dots else ''}" - 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') + # --- 1. SLIDER (Restored) --- + def on_slide(e): + state.page = int(e.value) + refresh_ui() + + ui.slider(min=0, max=total_pages-1, value=state.page, on_change=on_slide).classes('w-1/2 mb-2').props('color=green') + + # --- 2. CAROUSEL BUTTONS --- + 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) + + # 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): - state.page = max(0, min(p, math.ceil(len(state.all_images)/state.page_size)-1)) + state.page = p 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 # ========================================== -with ui.header().classes('items-center bg-slate-950 text-white border-b border-slate-800 px-4').style('height: 75px'): - with ui.row().classes('w-full items-center gap-4 no-wrap'): - ui.label('🖼️ NiceSorter').classes('text-2xl font-black text-green-500 italic shrink-0') +# HEADER +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') + + # --- 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 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') + 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') -with ui.left_drawer(value=True).classes('bg-slate-950 p-4 border-r border-slate-900').props('width=320'): +# 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') -with ui.column().classes('w-full p-6 bg-slate-900 min-h-screen text-white'): - pagination_container = ui.column().classes('w-full items-center mb-8') +# CONTENT +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') - # Feature 3: Global Apply Fully Integrated - ui.separator().classes('my-12 bg-slate-800') - with ui.row().classes('w-full justify-around p-8 bg-slate-950 rounded-2xl border border-slate-800 shadow-2xl'): + # --- FOOTER: BATCH SETTINGS & GLOBAL APPLY --- + 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'): + # Settings 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') + with ui.column(): - ui.label('Cleanup Strategy:').classes('text-slate-500 text-xs uppercase font-bold mb-2') + 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=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') + 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') -# Init & Hotkeys -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)) +# STARTUP +ui.keyboard(on_key=handle_key) ui.dark_mode().enable() load_images() ui.run(title="Nice Sorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file