diff --git a/gallery_app.py b/gallery_app.py index d3bacd1..d2c062c 100644 --- a/gallery_app.py +++ b/gallery_app.py @@ -10,12 +10,12 @@ from engine import SorterEngine # ========================================== class AppState: def __init__(self): - # Load Defaults + # Load Defaults from engine profiles = SorterEngine.load_profiles() p_data = profiles.get("Default", {}) - self.source_dir = p_data.get("tab5_source", ".") - self.output_dir = p_data.get("tab5_out", ".") + self.source_dir = p_data.get("tab5_source", "/storage") + self.output_dir = p_data.get("tab5_out", "/storage") self.page = 0 self.page_size = 24 @@ -23,114 +23,95 @@ class AppState: self.active_cat = "Default" self.next_index = 1 + # Processing Settings + self.batch_mode = "Copy" + self.cleanup_mode = "Keep" + # Caches self.all_images = [] self.staged_data = {} self.green_dots = set() - self.index_map = {} # For 5x5 sidebar grid + self.index_map = {} # {number: source_path} for sidebar previews state = AppState() # ========================================== -# 2. FAST THUMBNAIL SERVER (The Speed Secret) +# 2. IMAGE SERVING API # ========================================== -# Instead of embedding bytes in HTML (slow), we create a dedicated API -# that serves WebP thumbnails. The browser loads these in parallel. @app.get('/thumbnail') async def get_thumbnail(path: str, size: int = 400): - """ - Serves a resized WebP thumbnail. - Uses run.cpu_bound to prevent blocking the UI. - """ - if not os.path.exists(path): - return Response(status_code=404) - - # We use a lower quality (q=70) for grid speed - img_bytes = await run.cpu_bound( - SorterEngine.compress_for_web, path, quality=70, target_size=size - ) - - if img_bytes: - return Response(content=img_bytes, media_type="image/webp") - return Response(status_code=500) + if not os.path.exists(path): return Response(status_code=404) + 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): - """Serves high-quality image for Zoom/Preview.""" - img_bytes = await run.cpu_bound( - SorterEngine.compress_for_web, path, quality=90, target_size=None - ) + 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 # ========================================== def load_images(): - """Scans folder and updates state.""" if os.path.exists(state.source_dir): state.all_images = SorterEngine.get_images(state.source_dir, recursive=True) refresh_staged_info() + refresh_ui() else: ui.notify(f"Source not found: {state.source_dir}", type='warning') def refresh_staged_info(): - """Refreshes DB data and Green Dots cache.""" state.staged_data = SorterEngine.get_staged_data() - # Calculate Green Dots (Pages with tags) + # 1. 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 Grid Map (Numbers used in current cat) + # 2. Sidebar Index Map (Used numbers for current category) state.index_map.clear() - # 1. Staging - for info in state.staged_data.values(): + # Check Staging + for orig_path, info in state.staged_data.items(): if info['cat'] == state.active_cat: try: num = int(info['name'].rsplit('_', 1)[1].split('.')[0]) - state.index_map[num] = True + state.index_map[num] = orig_path except: pass - # 2. Disk + # Check Disk 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]) - state.index_map[num] = True + if num not in state.index_map: + state.index_map[num] = os.path.join(cat_path, f) except: pass def get_current_batch(): start = state.page * state.page_size return state.all_images[start : start + state.page_size] -async def action_tag(img_path, manual_idx=None): - """Tags an image.""" +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}" - # Check 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} already exists.", type='negative') - name = f"{state.active_cat}_{idx:03d}_copy{ext}" # Simple fallback + ui.notify(f"Conflict! Using suffix for {name}", type='warning') + name = f"{state.active_cat}_{idx:03d}_{idx}{ext}" SorterEngine.stage_image(img_path, state.active_cat, name) - - # Auto-increment global if we used the global counter if manual_idx is None or manual_idx == state.next_index: state.next_index = idx + 1 - ui.notify(f"Tagged: {name}", type='positive') refresh_staged_info() refresh_ui() @@ -141,237 +122,174 @@ def action_untag(img_path): def action_delete(img_path): SorterEngine.delete_to_trash(img_path) - load_images() # File list changed, must rescan - refresh_ui() - -def action_apply_page(mode="Copy", cleanup="Keep"): - batch = get_current_batch() - SorterEngine.commit_batch(batch, state.output_dir, cleanup, mode) - ui.notify("Page Applied!") load_images() - refresh_ui() +def action_apply_page(): + batch = get_current_batch() + 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...") + SorterEngine.commit_global(state.output_dir, state.cleanup_mode, state.batch_mode, state.source_dir) + load_images() + ui.notify("Global Apply Complete!", type='positive') # ========================================== -# 4. UI COMPONENTS +# 4. UI RENDERERS # ========================================== -def open_zoom_dialog(path): - # 'w-full max-w-screen-xl' makes it nearly full width on large monitors +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'): - - # Header Bar inside the dialog with ui.row().classes('w-full justify-between items-center p-2 bg-gray-900 text-white'): - ui.label(os.path.basename(path)).classes('text-lg font-bold truncate') + ui.label(title or os.path.basename(path)).classes('font-bold truncate') ui.button(icon='close', on_click=dialog.close).props('flat round dense color=white') - - # Image area - remove restrictions so it fills the card - # We use a high-res request ui.image(f"/full_res?path={path}").classes('w-full h-auto object-contain') - dialog.open() def render_sidebar(): sidebar_container.clear() with sidebar_container: - ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2') + ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white') - # 1. FETCH & VALIDATE CATEGORIES - categories = SorterEngine.get_categories() - - # Safety: Ensure list isn't empty - if not categories: - categories = ["Default"] - - # Safety: Ensure active_cat exists in the list - if state.active_cat not in categories: - state.active_cat = categories[0] - # We must refresh the staged info if the category changed drastically - refresh_staged_info() - - # 2. 5x5 Visual Grid - with ui.grid(columns=5).classes('gap-1 mb-4'): + # 1. 5x5 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 - # NiceGUI allows dynamic coloring easily - color = 'green' if is_used else 'grey-8' + color = 'green' if is_used else 'grey-9' - # We use a closure for the callback - ui.button(str(i), on_click=lambda i=i: set_index(i)) \ - .props(f'color={color} size=sm flat') - - def set_index(i): - state.next_index = i - idx_input.set_value(i) + def click_grid(num=i): + state.next_index = num + if num in state.index_map: + open_zoom_dialog(state.index_map[num], f"Index #{num}") + else: + ui.notify(f"Next index set to #{num}") + render_sidebar() - # 3. Controls - def update_cat(e): - state.active_cat = e.value - refresh_staged_info() # Updates the grid map - render_sidebar() # Redraw sidebar to show new map - - ui.select(categories, value=state.active_cat, on_change=update_cat, label="Active Tag").classes('w-full') + ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-700') - with ui.row().classes('w-full items-end'): - idx_input = ui.number(label="Next #", value=state.next_index, min=1, precision=0).bind_value(state, 'next_index').classes('w-2/3') - ui.button('🔄', on_click=lambda: detect_next()).classes('w-1/4') + # 2. Category Select + categories = SorterEngine.get_categories() or ["Default"] + if state.active_cat not in categories: state.active_cat = categories[0] + + ui.select(categories, value=state.active_cat, label="Active Category", + on_change=lambda e: (setattr(state, 'active_cat', e.value), refresh_staged_info(), render_sidebar())) \ + .classes('w-full').props('dark outlined') + + # 3. Add Category + 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') + 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') + + # 4. Danger Zone + with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'): + ui.button('DELETE CURRENT CATEGORY', 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-700') + + # 5. Index & Counter + with ui.row().classes('w-full items-end no-wrap'): + ui.number(label="Next #", min=1, precision=0).bind_value(state, 'next_index').classes('flex-grow').props('dark') + 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') - def detect_next(): - used = state.index_map.keys() - state.next_index = max(used) + 1 if used else 1 - idx_input.set_value(state.next_index) - def render_gallery(): grid_container.clear() batch = get_current_batch() - - # Calculate optimal thumbnail size based on columns - # 4 cols -> ~400px, 8 cols -> ~200px thumb_size = int(1600 / state.grid_cols) with grid_container: - # Use Tailwind grid - with ui.grid(columns=state.grid_cols).classes('w-full gap-2'): + 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 - staged_info = state.staged_data.get(img_path) - - # CARD - with ui.card().classes('p-2 no-shadow border border-gray-300'): - - # Header: Name | Zoom | Del - with ui.row().classes('w-full justify-between items-center no-wrap'): - ui.label(os.path.basename(img_path)[:10]).classes('text-xs text-gray-500') + 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'): + ui.label(os.path.basename(img_path)[:12]).classes('text-xs text-gray-400 truncate') with ui.row().classes('gap-1'): - ui.button(icon='zoom_in', on_click=lambda p=img_path: open_zoom_dialog(p)).props('flat size=sm dense') - ui.button(icon='close', color='red', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense') + 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 (WebP from Server) - # We encode the path to be URL safe - url = f"/thumbnail?path={img_path}&size={thumb_size}" - ui.image(url).classes('w-full h-auto rounded').props('no-spinner') + # Image + ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-48 object-cover rounded shadow-lg').props('no-spinner') - # Status / Action + # Tagging Area if is_staged: - ui.label(f"🏷️ {staged_info['cat']}").classes('bg-green-100 text-green-800 text-xs p-1 rounded w-full text-center my-1') - # Extract number for "Untag (#5)" - try: - num = int(staged_info['name'].rsplit('_', 1)[1].split('.')[0]) - label = f"Untag (#{num})" - except: - label = "Untag" - ui.button(label, color='grey', on_click=lambda p=img_path: action_untag(p)).classes('w-full') + info = state.staged_data[img_path] + num = info['name'].rsplit('_', 1)[1].split('.')[0] + ui.label(f"🏷️ {info['cat']} (#{num})").classes('text-center text-green-400 text-xs py-1 bg-green-900/30 rounded mt-2') + ui.button('Untag', on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5').classes('w-full') else: - # Index Input + Tag Button - with ui.row().classes('w-full no-wrap gap-1 mt-1'): - # Local input for this card - local_idx = ui.number(value=state.next_index, min=1, precision=0).props('dense outlined').classes('w-1/3') - ui.button('Tag', color='primary', - on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value)) - ).classes('w-2/3') + 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') def render_pagination(): pagination_container.clear() total_pages = math.ceil(len(state.all_images) / state.page_size) if total_pages <= 1: return - with pagination_container: - with ui.row().classes('w-full justify-center items-center gap-4'): - # Slider - ui.slider(min=0, max=total_pages-1, value=state.page, on_change=lambda e: set_page(e.value)).classes('w-1/3') - - # Buttons (Window) - start = max(0, state.page - 2) - end = min(total_pages, state.page + 3) - - ui.button('◀', on_click=lambda: set_page(state.page - 1)).props('flat') - - for p in range(start, end): - color = 'primary' if p == state.page else 'grey' - label = str(p + 1) - if p in state.green_dots: label += " 🟢" - ui.button(label, color=color, on_click=lambda p=p: set_page(p)) - - ui.button('▶', on_click=lambda: set_page(state.page + 1)).props('flat') + with ui.row().classes('items-center gap-2'): + ui.button('◀', on_click=lambda: set_page(state.page - 1)).props('flat color=white') + for p in range(max(0, state.page-2), min(total_pages, state.page+3)): + dot = " 🟢" if p in state.green_dots else "" + ui.button(f"{p+1}{dot}", on_click=lambda p=p: set_page(p)).props(f'flat color={"white" if p==state.page else "grey-6"}') + ui.button('▶', on_click=lambda: set_page(state.page + 1)).props('flat color=white') -def set_page(idx): - if 0 <= idx < math.ceil(len(state.all_images) / state.page_size): - state.page = idx - refresh_ui() +def set_page(p): + state.page = p; refresh_ui() def refresh_ui(): - render_sidebar() - render_pagination() - render_gallery() + render_sidebar(); render_pagination(); render_gallery() -# ========================================== -# 5. KEYBOARD SHORTCUTS -# ========================================== def handle_key(e): if not e.action.keydown: return if e.key.arrow_left: set_page(state.page - 1) if e.key.arrow_right: set_page(state.page + 1) - # Add number keys 1-9 to set category or tag quickly? - -## ========================================== -# 6. MAIN LAYOUT + +# ========================================== +# 5. MAIN LAYOUT # ========================================== -# 1. HEADER (Adaptive & Stretchy Inputs) -with ui.header().classes('items-center justify-between bg-slate-900 text-white border-b border-gray-700').style('height: 70px'): - - # We use a single row with 'no-wrap' to keep everything on one line - with ui.row().classes('w-full items-center gap-4 no-wrap'): - - # Logo - ui.label('🖼️ Sorter').classes('text-xl font-bold shrink-0') - - # INPUTS: 'flex-grow' makes them stretch to fill ALL available space - # We wrap them in a flexible row so they share the space 50/50 - with ui.row().classes('flex-grow gap-4'): - ui.input('Source').bind_value(state, 'source_dir') \ - .classes('flex-grow').props('dark dense outlined input-class="text-green-300"') - - ui.input('Output').bind_value(state, 'output_dir') \ - .classes('flex-grow').props('dark dense outlined input-class="text-blue-300"') - - # Buttons (shrink-0 prevents them from getting squished) - ui.button('Load', on_click=load_images).props('dense flat color=white').classes('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') + 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') + ui.button('LOAD', on_click=load_images).props('color=white flat').classes('font-bold') + ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green') - # Dark Mode Toggle - dark = ui.dark_mode() - dark.enable() - ui.switch('Dark', value=True, on_change=lambda e: dark.set_value(e.value)) \ - .props('color=green').classes('shrink-0') - -# 2. LEFT SIDEBAR -with ui.left_drawer(value=True).classes('bg-gray-900 p-4 border-r border-gray-700').props('width=320') as drawer: +# 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') -# 3. MAIN CONTENT -# bg-gray-800 ensures the main background is dark -with ui.column().classes('w-full p-4 bg-gray-800 min-h-screen text-white'): - - # Top Pagination - pagination_container = ui.column().classes('w-full items-center mb-4') - - # Gallery Grid +# 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-6') grid_container = ui.column().classes('w-full') - # Batch Actions Footer - ui.separator().classes('my-8 bg-gray-600') - with ui.row().classes('w-full justify-center gap-4'): - ui.button('APPLY PAGE', on_click=lambda: action_apply_page()).props('outline color=white') - ui.button('APPLY GLOBAL', color='red', on_click=lambda: ui.notify("Global Apply not implemented in demo")).classes('font-bold') + # Batch Settings + 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'): + with ui.column(): + ui.label('Tagged:').classes('text-gray-500 text-xs uppercase') + ui.radio(['Copy', 'Move'], value=state.batch_mode).bind_value(state, 'batch_mode').props('inline dark color=green') + with ui.column(): + ui.label('Untagged:').classes('text-gray-500 text-xs uppercase') + 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-4'): + ui.button('APPLY PAGE', on_click=action_apply_page).props('outline color=white lg') + ui.button('APPLY GLOBAL', on_click=action_apply_global).props('lg').classes('bg-red-700 font-bold') -# Initialize Data -load_images() - -# Bind Keys +# Setup ui.keyboard(on_key=handle_key) - -# Initial Render -refresh_ui() - -# Start App -ui.run(title="Gallery Sorter", host="0.0.0.0", port=8080, reload=False) \ No newline at end of file +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