Update gallery_app.py
This commit is contained in:
358
gallery_app.py
358
gallery_app.py
@@ -10,12 +10,12 @@ from engine import SorterEngine
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
class AppState:
|
class AppState:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Load Defaults
|
# Load Defaults from engine
|
||||||
profiles = SorterEngine.load_profiles()
|
profiles = SorterEngine.load_profiles()
|
||||||
p_data = profiles.get("Default", {})
|
p_data = profiles.get("Default", {})
|
||||||
|
|
||||||
self.source_dir = p_data.get("tab5_source", ".")
|
self.source_dir = p_data.get("tab5_source", "/storage")
|
||||||
self.output_dir = p_data.get("tab5_out", ".")
|
self.output_dir = p_data.get("tab5_out", "/storage")
|
||||||
|
|
||||||
self.page = 0
|
self.page = 0
|
||||||
self.page_size = 24
|
self.page_size = 24
|
||||||
@@ -23,114 +23,95 @@ class AppState:
|
|||||||
self.active_cat = "Default"
|
self.active_cat = "Default"
|
||||||
self.next_index = 1
|
self.next_index = 1
|
||||||
|
|
||||||
|
# Processing Settings
|
||||||
|
self.batch_mode = "Copy"
|
||||||
|
self.cleanup_mode = "Keep"
|
||||||
|
|
||||||
# Caches
|
# Caches
|
||||||
self.all_images = []
|
self.all_images = []
|
||||||
self.staged_data = {}
|
self.staged_data = {}
|
||||||
self.green_dots = set()
|
self.green_dots = set()
|
||||||
self.index_map = {} # For 5x5 sidebar grid
|
self.index_map = {} # {number: source_path} for sidebar previews
|
||||||
|
|
||||||
state = AppState()
|
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')
|
@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)
|
||||||
Serves a resized WebP thumbnail.
|
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 70, size)
|
||||||
Uses run.cpu_bound to prevent blocking the UI.
|
return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500)
|
||||||
"""
|
|
||||||
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)
|
|
||||||
|
|
||||||
@app.get('/full_res')
|
@app.get('/full_res')
|
||||||
async def get_full_res(path: str):
|
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, 90, None)
|
||||||
img_bytes = await run.cpu_bound(
|
|
||||||
SorterEngine.compress_for_web, path, quality=90, target_size=None
|
|
||||||
)
|
|
||||||
return Response(content=img_bytes, media_type="image/webp")
|
return Response(content=img_bytes, media_type="image/webp")
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 3. LOGIC & ACTIONS
|
# 3. LOGIC & ACTIONS
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
def load_images():
|
def load_images():
|
||||||
"""Scans folder and updates state."""
|
|
||||||
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)
|
||||||
refresh_staged_info()
|
refresh_staged_info()
|
||||||
|
refresh_ui()
|
||||||
else:
|
else:
|
||||||
ui.notify(f"Source not found: {state.source_dir}", type='warning')
|
ui.notify(f"Source not found: {state.source_dir}", type='warning')
|
||||||
|
|
||||||
def refresh_staged_info():
|
def refresh_staged_info():
|
||||||
"""Refreshes DB data and Green Dots cache."""
|
|
||||||
state.staged_data = SorterEngine.get_staged_data()
|
state.staged_data = SorterEngine.get_staged_data()
|
||||||
|
|
||||||
# Calculate Green Dots (Pages with tags)
|
# 1. 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 Grid Map (Numbers used in current cat)
|
# 2. Sidebar Index Map (Used numbers for current category)
|
||||||
state.index_map.clear()
|
state.index_map.clear()
|
||||||
# 1. Staging
|
# Check Staging
|
||||||
for info in state.staged_data.values():
|
for orig_path, info in state.staged_data.items():
|
||||||
if info['cat'] == state.active_cat:
|
if info['cat'] == state.active_cat:
|
||||||
try:
|
try:
|
||||||
num = int(info['name'].rsplit('_', 1)[1].split('.')[0])
|
num = int(info['name'].rsplit('_', 1)[1].split('.')[0])
|
||||||
state.index_map[num] = True
|
state.index_map[num] = orig_path
|
||||||
except: pass
|
except: pass
|
||||||
# 2. Disk
|
# Check 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 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])
|
||||||
state.index_map[num] = True
|
if num not in state.index_map:
|
||||||
|
state.index_map[num] = os.path.join(cat_path, f)
|
||||||
except: pass
|
except: pass
|
||||||
|
|
||||||
def get_current_batch():
|
def get_current_batch():
|
||||||
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]
|
||||||
|
|
||||||
async def action_tag(img_path, manual_idx=None):
|
def action_tag(img_path, manual_idx=None):
|
||||||
"""Tags an image."""
|
|
||||||
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}"
|
||||||
|
|
||||||
# Check 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}
|
||||||
|
|
||||||
if name in staged_names or os.path.exists(final_path):
|
if name in staged_names or os.path.exists(final_path):
|
||||||
ui.notify(f"Conflict! {name} already exists.", type='negative')
|
ui.notify(f"Conflict! Using suffix for {name}", type='warning')
|
||||||
name = f"{state.active_cat}_{idx:03d}_copy{ext}" # Simple fallback
|
name = f"{state.active_cat}_{idx:03d}_{idx}{ext}"
|
||||||
|
|
||||||
SorterEngine.stage_image(img_path, state.active_cat, name)
|
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:
|
if manual_idx is None or manual_idx == state.next_index:
|
||||||
state.next_index = idx + 1
|
state.next_index = idx + 1
|
||||||
|
|
||||||
ui.notify(f"Tagged: {name}", type='positive')
|
|
||||||
refresh_staged_info()
|
refresh_staged_info()
|
||||||
refresh_ui()
|
refresh_ui()
|
||||||
|
|
||||||
@@ -141,237 +122,174 @@ 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() # 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()
|
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):
|
def open_zoom_dialog(path, title=None):
|
||||||
# 'w-full max-w-screen-xl' makes it nearly full width on large monitors
|
|
||||||
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'):
|
||||||
|
|
||||||
# Header Bar inside the dialog
|
|
||||||
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(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')
|
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')
|
ui.image(f"/full_res?path={path}").classes('w-full h-auto object-contain')
|
||||||
|
|
||||||
dialog.open()
|
dialog.open()
|
||||||
|
|
||||||
def render_sidebar():
|
def render_sidebar():
|
||||||
sidebar_container.clear()
|
sidebar_container.clear()
|
||||||
with sidebar_container:
|
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
|
# 1. 5x5 Grid
|
||||||
categories = SorterEngine.get_categories()
|
with ui.grid(columns=5).classes('gap-1 mb-4 w-full'):
|
||||||
|
|
||||||
# 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'):
|
|
||||||
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
|
||||||
# NiceGUI allows dynamic coloring easily
|
color = 'green' if is_used else 'grey-9'
|
||||||
color = 'green' if is_used else 'grey-8'
|
|
||||||
|
|
||||||
# We use a closure for the callback
|
def click_grid(num=i):
|
||||||
ui.button(str(i), on_click=lambda i=i: set_index(i)) \
|
state.next_index = num
|
||||||
.props(f'color={color} size=sm flat')
|
if num in state.index_map:
|
||||||
|
open_zoom_dialog(state.index_map[num], f"Index #{num}")
|
||||||
def set_index(i):
|
else:
|
||||||
state.next_index = i
|
ui.notify(f"Next index set to #{num}")
|
||||||
idx_input.set_value(i)
|
render_sidebar()
|
||||||
|
|
||||||
# 3. Controls
|
ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-700')
|
||||||
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')
|
|
||||||
|
|
||||||
with ui.row().classes('w-full items-end'):
|
# 2. Category Select
|
||||||
idx_input = ui.number(label="Next #", value=state.next_index, min=1, precision=0).bind_value(state, 'next_index').classes('w-2/3')
|
categories = SorterEngine.get_categories() or ["Default"]
|
||||||
ui.button('🔄', on_click=lambda: detect_next()).classes('w-1/4')
|
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():
|
def render_gallery():
|
||||||
grid_container.clear()
|
grid_container.clear()
|
||||||
batch = get_current_batch()
|
batch = get_current_batch()
|
||||||
|
|
||||||
# Calculate optimal thumbnail size based on columns
|
|
||||||
# 4 cols -> ~400px, 8 cols -> ~200px
|
|
||||||
thumb_size = int(1600 / state.grid_cols)
|
thumb_size = int(1600 / state.grid_cols)
|
||||||
|
|
||||||
with grid_container:
|
with grid_container:
|
||||||
# Use Tailwind grid
|
with ui.grid(columns=state.grid_cols).classes('w-full gap-3'):
|
||||||
with ui.grid(columns=state.grid_cols).classes('w-full gap-2'):
|
|
||||||
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
|
||||||
staged_info = state.staged_data.get(img_path)
|
with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'):
|
||||||
|
# Header
|
||||||
# CARD
|
with ui.row().classes('w-full justify-between no-wrap'):
|
||||||
with ui.card().classes('p-2 no-shadow border border-gray-300'):
|
ui.label(os.path.basename(img_path)[:12]).classes('text-xs text-gray-400 truncate')
|
||||||
|
|
||||||
# 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.row().classes('gap-1'):
|
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='zoom_in', on_click=lambda p=img_path: open_zoom_dialog(p)).props('flat size=sm dense color=white')
|
||||||
ui.button(icon='close', color='red', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense')
|
ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense color=red')
|
||||||
|
|
||||||
# Image (WebP from Server)
|
# Image
|
||||||
# We encode the path to be URL safe
|
ui.image(f"/thumbnail?path={img_path}&size={thumb_size}").classes('w-full h-48 object-cover rounded shadow-lg').props('no-spinner')
|
||||||
url = f"/thumbnail?path={img_path}&size={thumb_size}"
|
|
||||||
ui.image(url).classes('w-full h-auto rounded').props('no-spinner')
|
|
||||||
|
|
||||||
# Status / Action
|
# Tagging Area
|
||||||
if is_staged:
|
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')
|
info = state.staged_data[img_path]
|
||||||
# Extract number for "Untag (#5)"
|
num = info['name'].rsplit('_', 1)[1].split('.')[0]
|
||||||
try:
|
ui.label(f"🏷️ {info['cat']} (#{num})").classes('text-center text-green-400 text-xs py-1 bg-green-900/30 rounded mt-2')
|
||||||
num = int(staged_info['name'].rsplit('_', 1)[1].split('.')[0])
|
ui.button('Untag', on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5').classes('w-full')
|
||||||
label = f"Untag (#{num})"
|
|
||||||
except:
|
|
||||||
label = "Untag"
|
|
||||||
ui.button(label, color='grey', on_click=lambda p=img_path: action_untag(p)).classes('w-full')
|
|
||||||
else:
|
else:
|
||||||
# Index Input + Tag Button
|
with ui.row().classes('w-full no-wrap mt-2 gap-1'):
|
||||||
with ui.row().classes('w-full no-wrap gap-1 mt-1'):
|
local_idx = ui.number(value=state.next_index, precision=0).props('dense dark outlined').classes('w-1/3')
|
||||||
# Local input for this card
|
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')
|
||||||
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')
|
|
||||||
|
|
||||||
def render_pagination():
|
def render_pagination():
|
||||||
pagination_container.clear()
|
pagination_container.clear()
|
||||||
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:
|
||||||
with ui.row().classes('w-full justify-center items-center gap-4'):
|
with ui.row().classes('items-center gap-2'):
|
||||||
# Slider
|
ui.button('◀', on_click=lambda: set_page(state.page - 1)).props('flat color=white')
|
||||||
ui.slider(min=0, max=total_pages-1, value=state.page, on_change=lambda e: set_page(e.value)).classes('w-1/3')
|
for p in range(max(0, state.page-2), min(total_pages, state.page+3)):
|
||||||
|
dot = " 🟢" if p in state.green_dots else ""
|
||||||
# Buttons (Window)
|
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"}')
|
||||||
start = max(0, state.page - 2)
|
ui.button('▶', on_click=lambda: set_page(state.page + 1)).props('flat color=white')
|
||||||
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')
|
|
||||||
|
|
||||||
def set_page(idx):
|
def set_page(p):
|
||||||
if 0 <= idx < math.ceil(len(state.all_images) / state.page_size):
|
state.page = p; refresh_ui()
|
||||||
state.page = idx
|
|
||||||
refresh_ui()
|
|
||||||
|
|
||||||
def refresh_ui():
|
def refresh_ui():
|
||||||
render_sidebar()
|
render_sidebar(); render_pagination(); render_gallery()
|
||||||
render_pagination()
|
|
||||||
render_gallery()
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 5. KEYBOARD SHORTCUTS
|
|
||||||
# ==========================================
|
|
||||||
def handle_key(e):
|
def handle_key(e):
|
||||||
if not e.action.keydown: return
|
if not e.action.keydown: return
|
||||||
if e.key.arrow_left: set_page(state.page - 1)
|
if e.key.arrow_left: set_page(state.page - 1)
|
||||||
if e.key.arrow_right: 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?
|
|
||||||
|
# ==========================================
|
||||||
## ==========================================
|
# 5. MAIN LAYOUT
|
||||||
# 6. MAIN LAYOUT
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
# 1. HEADER (Adaptive & Stretchy Inputs)
|
# Header
|
||||||
with ui.header().classes('items-center justify-between bg-slate-900 text-white border-b border-gray-700').style('height: 70px'):
|
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'):
|
||||||
# We use a single row with 'no-wrap' to keep everything on one line
|
ui.label('🖼️ NiceSorter').classes('text-xl font-bold shrink-0 text-green-400')
|
||||||
with ui.row().classes('w-full items-center gap-4 no-wrap'):
|
with ui.row().classes('flex-grow gap-2'):
|
||||||
|
ui.input('Source').bind_value(state, 'source_dir').classes('flex-grow').props('dark dense outlined')
|
||||||
# Logo
|
ui.input('Output').bind_value(state, 'output_dir').classes('flex-grow').props('dark dense outlined')
|
||||||
ui.label('🖼️ Sorter').classes('text-xl font-bold shrink-0')
|
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')
|
||||||
# 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')
|
|
||||||
|
|
||||||
# Dark Mode Toggle
|
# Sidebar
|
||||||
dark = ui.dark_mode()
|
with ui.left_drawer(value=True).classes('bg-gray-950 p-4 border-r border-gray-800').props('width=320'):
|
||||||
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_container = ui.column().classes('w-full')
|
sidebar_container = ui.column().classes('w-full')
|
||||||
|
|
||||||
# 3. MAIN CONTENT
|
# Content
|
||||||
# bg-gray-800 ensures the main background is dark
|
with ui.column().classes('w-full p-6 bg-gray-900 min-h-screen text-white'):
|
||||||
with ui.column().classes('w-full p-4 bg-gray-800 min-h-screen text-white'):
|
pagination_container = ui.column().classes('w-full items-center mb-6')
|
||||||
|
|
||||||
# Top Pagination
|
|
||||||
pagination_container = ui.column().classes('w-full items-center mb-4')
|
|
||||||
|
|
||||||
# Gallery Grid
|
|
||||||
grid_container = ui.column().classes('w-full')
|
grid_container = ui.column().classes('w-full')
|
||||||
|
|
||||||
# Batch Actions Footer
|
# Batch Settings
|
||||||
ui.separator().classes('my-8 bg-gray-600')
|
ui.separator().classes('my-10 bg-gray-800')
|
||||||
with ui.row().classes('w-full justify-center gap-4'):
|
with ui.row().classes('w-full justify-around p-6 bg-gray-950 rounded-xl border border-gray-800'):
|
||||||
ui.button('APPLY PAGE', on_click=lambda: action_apply_page()).props('outline color=white')
|
with ui.column():
|
||||||
ui.button('APPLY GLOBAL', color='red', on_click=lambda: ui.notify("Global Apply not implemented in demo")).classes('font-bold')
|
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
|
# Setup
|
||||||
load_images()
|
|
||||||
|
|
||||||
# Bind Keys
|
|
||||||
ui.keyboard(on_key=handle_key)
|
ui.keyboard(on_key=handle_key)
|
||||||
|
ui.dark_mode().enable()
|
||||||
# Initial Render
|
load_images()
|
||||||
refresh_ui()
|
ui.run(title="Nice Sorter", host="0.0.0.0", port=8080, reload=False)
|
||||||
|
|
||||||
# Start App
|
|
||||||
ui.run(title="Gallery Sorter", host="0.0.0.0", port=8080, reload=False)
|
|
||||||
Reference in New Issue
Block a user