894 lines
34 KiB
Python
894 lines
34 KiB
Python
import os
|
|
import math
|
|
import asyncio
|
|
from typing import Optional, List, Dict, Set
|
|
from nicegui import ui, app, run
|
|
from fastapi import Response
|
|
from engine import SorterEngine
|
|
|
|
# ==========================================
|
|
# CUSTOM CSS FOR REFINED AESTHETICS
|
|
# ==========================================
|
|
CUSTOM_CSS = """
|
|
<style>
|
|
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap');
|
|
|
|
:root {
|
|
--bg-deep: #0a0a0f;
|
|
--bg-card: #12121a;
|
|
--bg-elevated: #1a1a24;
|
|
--bg-hover: #242432;
|
|
--accent-primary: #22c55e;
|
|
--accent-glow: rgba(34, 197, 94, 0.15);
|
|
--accent-secondary: #3b82f6;
|
|
--text-primary: #f1f5f9;
|
|
--text-secondary: #94a3b8;
|
|
--text-muted: #64748b;
|
|
--border-subtle: rgba(255, 255, 255, 0.06);
|
|
--border-accent: rgba(34, 197, 94, 0.3);
|
|
--danger: #ef4444;
|
|
--warning: #f59e0b;
|
|
}
|
|
|
|
* {
|
|
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
}
|
|
|
|
code, .mono {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
}
|
|
|
|
/* Smooth scrollbar */
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
::-webkit-scrollbar-track {
|
|
background: var(--bg-deep);
|
|
}
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--bg-hover);
|
|
border-radius: 4px;
|
|
}
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: var(--text-muted);
|
|
}
|
|
|
|
/* Card hover effects */
|
|
.image-card {
|
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
border: 1px solid var(--border-subtle);
|
|
background: var(--bg-card);
|
|
}
|
|
.image-card:hover {
|
|
border-color: var(--border-accent);
|
|
box-shadow: 0 0 20px var(--accent-glow);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.image-card.tagged {
|
|
border-color: var(--accent-primary);
|
|
box-shadow: 0 0 15px var(--accent-glow);
|
|
}
|
|
|
|
/* Tag badge */
|
|
.tag-badge {
|
|
background: linear-gradient(135deg, var(--accent-primary), #16a34a);
|
|
padding: 4px 12px;
|
|
border-radius: 20px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
letter-spacing: 0.5px;
|
|
text-transform: uppercase;
|
|
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.3);
|
|
}
|
|
|
|
/* Number grid buttons */
|
|
.num-btn {
|
|
transition: all 0.15s ease;
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-weight: 500;
|
|
}
|
|
.num-btn:hover {
|
|
transform: scale(1.1);
|
|
}
|
|
.num-btn.used {
|
|
background: linear-gradient(135deg, var(--accent-primary), #16a34a) !important;
|
|
box-shadow: 0 2px 8px rgba(34, 197, 94, 0.4);
|
|
}
|
|
|
|
/* Pagination dots */
|
|
.page-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--text-muted);
|
|
transition: all 0.2s ease;
|
|
}
|
|
.page-dot.active {
|
|
background: var(--accent-primary);
|
|
box-shadow: 0 0 10px var(--accent-primary);
|
|
}
|
|
.page-dot.has-tags {
|
|
background: var(--accent-primary);
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Smooth image loading */
|
|
.thumb-container img {
|
|
transition: opacity 0.3s ease;
|
|
}
|
|
|
|
/* Header styling */
|
|
.app-header {
|
|
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-deep) 100%);
|
|
border-bottom: 1px solid var(--border-subtle);
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
|
|
/* Sidebar styling */
|
|
.app-sidebar {
|
|
background: linear-gradient(180deg, var(--bg-card) 0%, var(--bg-deep) 100%);
|
|
border-right: 1px solid var(--border-subtle);
|
|
}
|
|
|
|
/* Action buttons */
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, var(--accent-primary), #16a34a) !important;
|
|
font-weight: 600;
|
|
letter-spacing: 0.5px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.btn-primary:hover {
|
|
box-shadow: 0 4px 20px rgba(34, 197, 94, 0.4);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: linear-gradient(135deg, var(--danger), #dc2626) !important;
|
|
}
|
|
.btn-danger:hover {
|
|
box-shadow: 0 4px 20px rgba(239, 68, 68, 0.4);
|
|
}
|
|
|
|
/* Input styling */
|
|
.q-field--outlined .q-field__control {
|
|
border-radius: 8px !important;
|
|
}
|
|
|
|
/* Stats counter */
|
|
.stat-pill {
|
|
background: var(--bg-elevated);
|
|
border: 1px solid var(--border-subtle);
|
|
padding: 6px 14px;
|
|
border-radius: 20px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
}
|
|
.stat-pill .value {
|
|
color: var(--accent-primary);
|
|
font-family: 'JetBrains Mono', monospace;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Zoom dialog */
|
|
.zoom-dialog {
|
|
background: var(--bg-deep) !important;
|
|
border: 1px solid var(--border-subtle);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Animations */
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; transform: translateY(10px); }
|
|
to { opacity: 1; transform: translateY(0); }
|
|
}
|
|
.animate-in {
|
|
animation: fadeIn 0.3s ease forwards;
|
|
}
|
|
|
|
@keyframes pulse-glow {
|
|
0%, 100% { box-shadow: 0 0 10px var(--accent-glow); }
|
|
50% { box-shadow: 0 0 20px var(--accent-glow); }
|
|
}
|
|
.pulse-glow {
|
|
animation: pulse-glow 2s ease-in-out infinite;
|
|
}
|
|
</style>
|
|
"""
|
|
|
|
# ==========================================
|
|
# STATE MANAGEMENT
|
|
# ==========================================
|
|
class AppState:
|
|
"""Centralized application state with lazy loading and caching."""
|
|
|
|
__slots__ = [
|
|
'profiles', 'profile_name', 'source_dir', 'output_dir',
|
|
'page', 'page_size', 'grid_cols', 'preview_quality',
|
|
'active_cat', 'next_index', 'batch_mode', 'cleanup_mode',
|
|
'all_images', 'staged_data', 'green_dots', 'index_map',
|
|
'sidebar_container', 'grid_container', 'pagination_container',
|
|
'stats_container', '_image_cache'
|
|
]
|
|
|
|
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.load_active_profile()
|
|
|
|
# View Settings
|
|
self.page = 0
|
|
self.page_size = 24
|
|
self.grid_cols = 4
|
|
self.preview_quality = 50
|
|
|
|
# Tagging State
|
|
self.active_cat = "Default"
|
|
self.next_index = 1
|
|
|
|
# Batch Settings
|
|
self.batch_mode = "Copy"
|
|
self.cleanup_mode = "Keep"
|
|
|
|
# Data Caches (optimized with sets for O(1) lookup)
|
|
self.all_images: List[str] = []
|
|
self.staged_data: Dict = {}
|
|
self.green_dots: Set[int] = set()
|
|
self.index_map: Dict[int, str] = {}
|
|
self._image_cache: Set[str] = set() # Fast existence check
|
|
|
|
# UI Containers
|
|
self.sidebar_container = None
|
|
self.grid_container = None
|
|
self.pagination_container = None
|
|
self.stats_container = None
|
|
|
|
def load_active_profile(self):
|
|
"""Load paths from active profile."""
|
|
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):
|
|
"""Save current paths to active profile."""
|
|
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
|
|
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')
|
|
|
|
def get_categories(self) -> List[str]:
|
|
"""Get list of categories, ensuring active_cat exists."""
|
|
cats = SorterEngine.get_categories() or ["Default"]
|
|
if self.active_cat not in cats:
|
|
self.active_cat = cats[0]
|
|
return cats
|
|
|
|
@property
|
|
def total_pages(self) -> int:
|
|
"""Calculate total pages."""
|
|
if not self.all_images:
|
|
return 0
|
|
return (len(self.all_images) + self.page_size - 1) // self.page_size
|
|
|
|
def get_current_batch(self) -> List[str]:
|
|
"""Get images for current page with bounds checking."""
|
|
if not self.all_images:
|
|
return []
|
|
start = self.page * self.page_size
|
|
end = min(start + self.page_size, len(self.all_images))
|
|
return self.all_images[start:end]
|
|
|
|
@property
|
|
def tagged_count(self) -> int:
|
|
"""Count of currently tagged images."""
|
|
return len(self.staged_data)
|
|
|
|
@property
|
|
def total_count(self) -> int:
|
|
"""Total images loaded."""
|
|
return len(self.all_images)
|
|
|
|
state = AppState()
|
|
|
|
# ==========================================
|
|
# IMAGE SERVING API (Optimized)
|
|
# ==========================================
|
|
|
|
@app.get('/thumbnail')
|
|
async def get_thumbnail(path: str, size: int = 400, q: int = 50):
|
|
"""Serve WebP thumbnail with streaming response."""
|
|
if not os.path.exists(path):
|
|
return Response(status_code=404)
|
|
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, q, size)
|
|
if img_bytes:
|
|
return Response(
|
|
content=img_bytes,
|
|
media_type="image/webp",
|
|
headers={"Cache-Control": "max-age=3600"} # Browser caching
|
|
)
|
|
return Response(status_code=500)
|
|
|
|
@app.get('/full_res')
|
|
async def get_full_res(path: str):
|
|
"""Serve full resolution image with caching headers."""
|
|
if not os.path.exists(path):
|
|
return Response(status_code=404)
|
|
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None)
|
|
if img_bytes:
|
|
return Response(
|
|
content=img_bytes,
|
|
media_type="image/webp",
|
|
headers={"Cache-Control": "max-age=7200"}
|
|
)
|
|
return Response(status_code=500)
|
|
|
|
# ==========================================
|
|
# CORE LOGIC
|
|
# ==========================================
|
|
|
|
def load_images():
|
|
"""Load images from source directory with tag restoration."""
|
|
if not os.path.exists(state.source_dir):
|
|
ui.notify(f"Source not found: {state.source_dir}", type='warning')
|
|
return
|
|
|
|
# Load images
|
|
state.all_images = SorterEngine.get_images(state.source_dir, recursive=True)
|
|
state._image_cache = set(state.all_images) # Fast lookup cache
|
|
|
|
# --- RESTORE SAVED TAGS FOR THIS FOLDER ---
|
|
restored = SorterEngine.restore_folder_tags(state.source_dir)
|
|
if restored > 0:
|
|
ui.notify(f"Restored {restored} saved tags for this folder", type='positive')
|
|
|
|
# Reset page if out of bounds
|
|
if state.page >= state.total_pages:
|
|
state.page = 0
|
|
|
|
refresh_staged_info()
|
|
refresh_ui()
|
|
|
|
def refresh_staged_info():
|
|
"""Update staged data and index maps with optimized lookups."""
|
|
state.staged_data = SorterEngine.get_staged_data()
|
|
|
|
# Update green dots using set operations
|
|
state.green_dots.clear()
|
|
staged_keys = set(state.staged_data.keys())
|
|
|
|
# Batch process for efficiency
|
|
for idx, img_path in enumerate(state.all_images):
|
|
if img_path in staged_keys:
|
|
state.green_dots.add(idx // state.page_size)
|
|
|
|
# Build index map for active category
|
|
state.index_map.clear()
|
|
|
|
# Add staged images
|
|
for orig_path, info in state.staged_data.items():
|
|
if info['cat'] == state.active_cat:
|
|
idx = _extract_index(info['name'])
|
|
if idx is not None:
|
|
state.index_map[idx] = orig_path
|
|
|
|
# Add committed images from disk
|
|
cat_path = os.path.join(state.output_dir, state.active_cat)
|
|
if os.path.exists(cat_path):
|
|
try:
|
|
with os.scandir(cat_path) as entries:
|
|
for entry in entries:
|
|
if entry.is_file() and entry.name.startswith(state.active_cat):
|
|
idx = _extract_index(entry.name)
|
|
if idx is not None and idx not in state.index_map:
|
|
state.index_map[idx] = entry.path
|
|
except PermissionError:
|
|
pass
|
|
|
|
def _extract_index(filename: str) -> Optional[int]:
|
|
"""Extract numeric index from filename with error handling."""
|
|
try:
|
|
return int(filename.rsplit('_', 1)[1].split('.')[0])
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
# ==========================================
|
|
# ACTIONS
|
|
# ==========================================
|
|
|
|
def action_tag(img_path: str, manual_idx: Optional[int] = None):
|
|
"""Tag an image with category and index, with folder persistence."""
|
|
idx = manual_idx if manual_idx is not None else state.next_index
|
|
ext = os.path.splitext(img_path)[1]
|
|
name = f"{state.active_cat}_{idx:03d}{ext}"
|
|
|
|
# Check for 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} exists. Using suffix.", type='warning')
|
|
name = f"{state.active_cat}_{idx:03d}_{len(staged_names)+1}{ext}"
|
|
|
|
# Stage with folder persistence
|
|
SorterEngine.stage_image(img_path, state.active_cat, name, source_root=state.source_dir)
|
|
|
|
if manual_idx is None:
|
|
state.next_index = idx + 1
|
|
|
|
refresh_staged_info()
|
|
refresh_ui()
|
|
|
|
def action_untag(img_path: str):
|
|
"""Remove staging from an image, including folder cache."""
|
|
SorterEngine.clear_staged_item(img_path, source_root=state.source_dir)
|
|
refresh_staged_info()
|
|
refresh_ui()
|
|
|
|
def action_delete(img_path: str):
|
|
"""Delete image to trash."""
|
|
SorterEngine.delete_to_trash(img_path)
|
|
load_images()
|
|
|
|
def action_apply_page():
|
|
"""Apply staged changes for current page only."""
|
|
batch = state.get_current_batch()
|
|
if not batch:
|
|
ui.notify("No images on current page", type='warning')
|
|
return
|
|
|
|
SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode)
|
|
ui.notify(f"Page processed ({state.batch_mode})", type='positive')
|
|
load_images()
|
|
|
|
async def action_apply_global():
|
|
"""Apply all staged changes globally and clear folder cache."""
|
|
ui.notify("Starting global apply... This may take a while.", type='info')
|
|
await run.io_bound(
|
|
SorterEngine.commit_global,
|
|
state.output_dir,
|
|
state.cleanup_mode,
|
|
state.batch_mode,
|
|
state.source_dir
|
|
)
|
|
load_images()
|
|
ui.notify("Global apply complete!", type='positive')
|
|
|
|
# ==========================================
|
|
# UI COMPONENTS
|
|
# ==========================================
|
|
|
|
def open_zoom_dialog(path: str, title: Optional[str] = None, show_untag: bool = False, show_jump: bool = False):
|
|
"""Open full-resolution image dialog with refined styling."""
|
|
with ui.dialog() as dialog, ui.card().classes('max-w-screen-xl p-0 gap-0 zoom-dialog'):
|
|
with ui.row().classes('w-full justify-between items-center px-4 py-3').style('background: var(--bg-elevated)'):
|
|
ui.label(title or os.path.basename(path)).classes('font-semibold text-white truncate').style('font-family: "JetBrains Mono", monospace')
|
|
|
|
with ui.row().classes('gap-2'):
|
|
if show_jump and path in state._image_cache:
|
|
def jump_to_image():
|
|
img_idx = state.all_images.index(path)
|
|
target_page = img_idx // state.page_size
|
|
dialog.close()
|
|
set_page(target_page)
|
|
ui.notify(f"Jumped to page {target_page + 1}", type='info')
|
|
|
|
ui.button(icon='location_searching', on_click=jump_to_image) \
|
|
.props('flat round dense').classes('text-blue-400') \
|
|
.tooltip('Jump to image location')
|
|
|
|
if show_untag:
|
|
def untag_and_close():
|
|
action_untag(path)
|
|
dialog.close()
|
|
ui.notify("Tag removed", type='positive')
|
|
|
|
ui.button(icon='label_off', on_click=untag_and_close) \
|
|
.props('flat round dense').classes('text-red-400') \
|
|
.tooltip('Remove tag')
|
|
|
|
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 object-contain').style('max-height: 85vh; background: var(--bg-deep)')
|
|
dialog.open()
|
|
|
|
def render_sidebar():
|
|
"""Render category management sidebar with refined design."""
|
|
state.sidebar_container.clear()
|
|
|
|
with state.sidebar_container:
|
|
# Header
|
|
with ui.row().classes('items-center gap-3 mb-6'):
|
|
ui.icon('sell', size='28px').classes('text-green-400')
|
|
ui.label("Tag Manager").classes('text-xl font-semibold text-white')
|
|
|
|
# Category selector with visual enhancement
|
|
categories = state.get_categories()
|
|
|
|
def on_category_change(e):
|
|
state.active_cat = e.value
|
|
refresh_staged_info()
|
|
render_sidebar()
|
|
|
|
ui.select(
|
|
categories,
|
|
value=state.active_cat,
|
|
label="Active Category",
|
|
on_change=on_category_change
|
|
).classes('w-full mb-4').props('dark outlined color=green')
|
|
|
|
# Number grid (1-25) with refined styling
|
|
ui.label("Quick Index").classes('text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2')
|
|
with ui.grid(columns=5).classes('gap-1 mb-6 w-full'):
|
|
for i in range(1, 26):
|
|
is_used = i in state.index_map
|
|
|
|
def make_click_handler(num: int):
|
|
def handler():
|
|
if num in state.index_map:
|
|
img_path = state.index_map[num]
|
|
is_staged = img_path in state.staged_data
|
|
open_zoom_dialog(
|
|
img_path,
|
|
f"{state.active_cat} #{num}",
|
|
show_untag=is_staged,
|
|
show_jump=True
|
|
)
|
|
else:
|
|
state.next_index = num
|
|
render_sidebar()
|
|
return handler
|
|
|
|
btn_classes = 'w-full num-btn'
|
|
if is_used:
|
|
btn_classes += ' used'
|
|
|
|
ui.button(str(i), on_click=make_click_handler(i)) \
|
|
.props(f'flat size=sm {"color=green" if is_used else "color=grey-8"}') \
|
|
.classes(btn_classes)
|
|
|
|
# Next index input
|
|
with ui.row().classes('w-full items-end no-wrap gap-2 mb-4'):
|
|
ui.number(label="Next Index", min=1, precision=0) \
|
|
.bind_value(state, 'next_index') \
|
|
.classes('flex-grow').props('dark outlined color=green')
|
|
|
|
def reset_index():
|
|
state.next_index = (max(state.index_map.keys()) + 1) if state.index_map else 1
|
|
render_sidebar()
|
|
|
|
ui.button(icon='restart_alt', on_click=reset_index).props('flat round color=grey-5').tooltip('Auto-set next available')
|
|
|
|
ui.separator().classes('my-4 opacity-20')
|
|
|
|
# Add new category
|
|
ui.label("New Category").classes('text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2')
|
|
with ui.row().classes('w-full items-center no-wrap gap-2'):
|
|
new_cat_input = ui.input(placeholder='Category name...') \
|
|
.props('dense outlined dark').classes('flex-grow')
|
|
|
|
def add_category():
|
|
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_category).props('flat round color=green')
|
|
|
|
# Danger zone
|
|
with ui.expansion('Danger Zone', icon='warning').classes('w-full mt-6').props('dense header-class="text-red-400"'):
|
|
def delete_category():
|
|
SorterEngine.delete_category(state.active_cat)
|
|
refresh_staged_info()
|
|
render_sidebar()
|
|
|
|
ui.button('Delete Category', color='red', on_click=delete_category) \
|
|
.classes('w-full btn-danger').props('flat')
|
|
|
|
def render_stats():
|
|
"""Render statistics bar."""
|
|
if state.stats_container:
|
|
state.stats_container.clear()
|
|
with state.stats_container:
|
|
with ui.row().classes('gap-4'):
|
|
# Total images
|
|
with ui.row().classes('stat-pill items-center gap-2'):
|
|
ui.icon('photo_library', size='16px').classes('text-gray-400')
|
|
ui.label('Total:').classes('text-gray-400')
|
|
ui.label(str(state.total_count)).classes('value')
|
|
|
|
# Tagged count
|
|
with ui.row().classes('stat-pill items-center gap-2'):
|
|
ui.icon('sell', size='16px').classes('text-green-400')
|
|
ui.label('Tagged:').classes('text-gray-400')
|
|
ui.label(str(state.tagged_count)).classes('value')
|
|
|
|
# Current category
|
|
with ui.row().classes('stat-pill items-center gap-2'):
|
|
ui.icon('folder', size='16px').classes('text-blue-400')
|
|
ui.label('Category:').classes('text-gray-400')
|
|
ui.label(state.active_cat).classes('value text-blue-400')
|
|
|
|
def render_gallery():
|
|
"""Render image gallery grid with optimized rendering."""
|
|
state.grid_container.clear()
|
|
batch = state.get_current_batch()
|
|
|
|
with state.grid_container:
|
|
if not batch:
|
|
with ui.column().classes('w-full items-center py-20'):
|
|
ui.icon('photo_library', size='80px').classes('text-gray-700 mb-4')
|
|
ui.label('No images loaded').classes('text-xl text-gray-500')
|
|
ui.label('Select a source folder and click LOAD').classes('text-gray-600')
|
|
return
|
|
|
|
with ui.grid(columns=state.grid_cols).classes('w-full gap-4'):
|
|
for idx, img_path in enumerate(batch):
|
|
render_image_card(img_path, idx)
|
|
|
|
def render_image_card(img_path: str, batch_idx: int):
|
|
"""Render individual image card with enhanced styling."""
|
|
is_staged = img_path in state.staged_data
|
|
thumb_size = 600
|
|
|
|
card_class = 'image-card rounded-xl overflow-hidden'
|
|
if is_staged:
|
|
card_class += ' tagged'
|
|
|
|
with ui.card().classes(card_class).style('animation-delay: {}ms'.format(batch_idx * 30)):
|
|
# Thumbnail with hover effects
|
|
with ui.column().classes('relative'):
|
|
ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \
|
|
.classes('w-full thumb-container cursor-pointer') \
|
|
.style('height: 220px; object-fit: cover; background: var(--bg-deep)') \
|
|
.on('click', lambda p=img_path: open_zoom_dialog(p))
|
|
|
|
# Overlay buttons
|
|
with ui.row().classes('absolute top-2 right-2 gap-1'):
|
|
ui.button(icon='zoom_in', on_click=lambda p=img_path: open_zoom_dialog(p)) \
|
|
.props('flat round dense size=sm').classes('bg-black/50 text-white')
|
|
ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)) \
|
|
.props('flat round dense size=sm').classes('bg-black/50 text-red-400')
|
|
|
|
# Info section
|
|
with ui.column().classes('p-3 gap-2'):
|
|
# Filename
|
|
ui.label(os.path.basename(img_path)[:20]).classes('text-xs text-gray-400 truncate mono')
|
|
|
|
# Tagging UI
|
|
if is_staged:
|
|
info = state.staged_data[img_path]
|
|
idx = _extract_index(info['name'])
|
|
idx_str = str(idx) if idx else "?"
|
|
|
|
with ui.row().classes('w-full justify-between items-center'):
|
|
ui.html(f'<span class="tag-badge">{info["cat"]} #{idx_str}</span>')
|
|
ui.button('Untag', on_click=lambda p=img_path: action_untag(p)) \
|
|
.props('flat dense size=sm color=grey')
|
|
else:
|
|
with ui.row().classes('w-full no-wrap gap-2 items-center'):
|
|
local_idx = ui.number(value=state.next_index, precision=0) \
|
|
.props('dense dark outlined borderless').classes('w-16').style('font-family: "JetBrains Mono"')
|
|
ui.button('Tag', on_click=lambda p=img_path, i=local_idx: action_tag(p, int(i.value))) \
|
|
.classes('flex-grow btn-primary').props('dense unelevated')
|
|
|
|
def render_pagination():
|
|
"""Render pagination controls with refined design."""
|
|
state.pagination_container.clear()
|
|
|
|
if state.total_pages <= 1:
|
|
return
|
|
|
|
with state.pagination_container:
|
|
with ui.row().classes('w-full items-center justify-center gap-4 mb-4'):
|
|
# Previous button
|
|
ui.button(icon='chevron_left', on_click=lambda: set_page(state.page - 1)) \
|
|
.props('flat round').classes('text-white' if state.page > 0 else 'text-gray-700') \
|
|
.set_enabled(state.page > 0)
|
|
|
|
# Page info
|
|
with ui.row().classes('items-center gap-3'):
|
|
ui.label(f'Page {state.page + 1} of {state.total_pages}') \
|
|
.classes('text-gray-400 mono text-sm')
|
|
|
|
# Quick jump
|
|
ui.number(value=state.page + 1, min=1, max=state.total_pages, precision=0) \
|
|
.props('dense dark outlined borderless').classes('w-16') \
|
|
.on('change', lambda e: set_page(int(e.value) - 1))
|
|
|
|
# Next button
|
|
ui.button(icon='chevron_right', on_click=lambda: set_page(state.page + 1)) \
|
|
.props('flat round').classes('text-white' if state.page < state.total_pages - 1 else 'text-gray-700') \
|
|
.set_enabled(state.page < state.total_pages - 1)
|
|
|
|
# Page dots/buttons
|
|
with ui.row().classes('items-center justify-center gap-1 flex-wrap'):
|
|
start = max(0, state.page - 3)
|
|
end = min(state.total_pages, state.page + 4)
|
|
|
|
if start > 0:
|
|
ui.button('1', on_click=lambda: set_page(0)).props('flat dense size=sm color=grey')
|
|
if start > 1:
|
|
ui.label('...').classes('text-gray-600 px-1')
|
|
|
|
for p in range(start, end):
|
|
has_tags = p in state.green_dots
|
|
is_current = p == state.page
|
|
|
|
btn_color = 'green' if is_current else ('light-green-8' if has_tags else 'grey-8')
|
|
btn = ui.button(str(p + 1), on_click=lambda page=p: set_page(page)) \
|
|
.props(f'flat dense size=sm color={btn_color}')
|
|
|
|
if is_current:
|
|
btn.classes('font-bold')
|
|
|
|
if end < state.total_pages:
|
|
if end < state.total_pages - 1:
|
|
ui.label('...').classes('text-gray-600 px-1')
|
|
ui.button(str(state.total_pages), on_click=lambda: set_page(state.total_pages - 1)) \
|
|
.props('flat dense size=sm color=grey')
|
|
|
|
def set_page(p: int):
|
|
"""Navigate to specific page."""
|
|
state.page = max(0, min(p, state.total_pages - 1))
|
|
refresh_ui()
|
|
|
|
def refresh_ui():
|
|
"""Refresh all UI components."""
|
|
render_sidebar()
|
|
render_pagination()
|
|
render_gallery()
|
|
render_stats()
|
|
|
|
def handle_keyboard(e):
|
|
"""Handle keyboard navigation."""
|
|
if not e.action.keydown:
|
|
return
|
|
|
|
if e.key.arrow_left and state.page > 0:
|
|
set_page(state.page - 1)
|
|
elif e.key.arrow_right and state.page < state.total_pages - 1:
|
|
set_page(state.page + 1)
|
|
|
|
# ==========================================
|
|
# MAIN LAYOUT
|
|
# ==========================================
|
|
|
|
def build_header():
|
|
"""Build application header with refined design."""
|
|
with ui.header().classes('app-header items-center text-white').style('height: 72px; padding: 0 24px'):
|
|
with ui.row().classes('w-full items-center gap-6 no-wrap'):
|
|
# Logo
|
|
with ui.row().classes('items-center gap-2 shrink-0'):
|
|
ui.icon('photo_library', size='28px').classes('text-green-400')
|
|
ui.label('NiceSorter').classes('text-xl font-bold text-white')
|
|
|
|
# Profile selector
|
|
profile_names = list(state.profiles.keys())
|
|
|
|
def change_profile(e):
|
|
state.profile_name = e.value
|
|
state.load_active_profile()
|
|
load_images()
|
|
|
|
with ui.row().classes('items-center gap-2'):
|
|
ui.icon('person', size='18px').classes('text-gray-500')
|
|
ui.select(profile_names, value=state.profile_name, on_change=change_profile) \
|
|
.props('dark dense borderless').classes('w-28')
|
|
|
|
# Source and output paths
|
|
with ui.row().classes('flex-grow gap-3'):
|
|
with ui.row().classes('flex-grow items-center gap-2'):
|
|
ui.icon('folder_open', size='18px').classes('text-blue-400')
|
|
ui.input('Source').bind_value(state, 'source_dir') \
|
|
.classes('flex-grow').props('dark dense outlined')
|
|
|
|
with ui.row().classes('flex-grow items-center gap-2'):
|
|
ui.icon('save', size='18px').classes('text-green-400')
|
|
ui.input('Output').bind_value(state, 'output_dir') \
|
|
.classes('flex-grow').props('dark dense outlined')
|
|
|
|
# Action buttons
|
|
ui.button(icon='save', on_click=state.save_current_profile) \
|
|
.props('flat round').classes('text-white').tooltip('Save Profile')
|
|
|
|
ui.button('LOAD', on_click=load_images) \
|
|
.props('unelevated color=green').classes('font-semibold px-6')
|
|
|
|
# Settings menu
|
|
with ui.button(icon='tune').props('flat round').classes('text-white'):
|
|
with ui.menu().classes('p-4').style('background: var(--bg-elevated); min-width: 280px'):
|
|
ui.label('Display Settings').classes('text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4')
|
|
|
|
ui.label('Grid Columns').classes('text-gray-400 text-sm mb-1')
|
|
ui.slider(
|
|
min=2, max=8, step=1,
|
|
value=state.grid_cols,
|
|
on_change=lambda e: (setattr(state, 'grid_cols', int(e.value)), refresh_ui())
|
|
).props('color=green label-always')
|
|
|
|
ui.label('Preview Quality').classes('text-gray-400 text-sm mb-1 mt-4')
|
|
ui.slider(
|
|
min=10, max=100, step=10,
|
|
value=state.preview_quality,
|
|
on_change=lambda e: (setattr(state, 'preview_quality', int(e.value)), refresh_ui())
|
|
).props('color=green label-always')
|
|
|
|
def build_sidebar():
|
|
"""Build left sidebar with refined styling."""
|
|
with ui.left_drawer(value=True).classes('app-sidebar p-5').props('width=300'):
|
|
state.sidebar_container = ui.column().classes('w-full')
|
|
|
|
def build_main_content():
|
|
"""Build main content area."""
|
|
with ui.column().classes('w-full p-6 min-h-screen text-white').style('background: var(--bg-deep)'):
|
|
# Stats bar
|
|
state.stats_container = ui.row().classes('w-full mb-4')
|
|
|
|
# Pagination
|
|
state.pagination_container = ui.column().classes('w-full items-center mb-6')
|
|
|
|
# Gallery grid
|
|
state.grid_container = ui.column().classes('w-full')
|
|
|
|
# Footer with batch controls
|
|
ui.separator().classes('my-10 opacity-10')
|
|
|
|
with ui.card().classes('w-full p-6 rounded-2xl').style('background: var(--bg-card); border: 1px solid var(--border-subtle)'):
|
|
with ui.row().classes('w-full justify-between items-center flex-wrap gap-6'):
|
|
# Tagged files mode
|
|
with ui.column().classes('gap-2'):
|
|
ui.label('Tagged Files').classes('text-xs font-semibold text-gray-400 uppercase tracking-wider')
|
|
ui.radio(['Copy', 'Move'], value=state.batch_mode) \
|
|
.bind_value(state, 'batch_mode') \
|
|
.props('inline dark color=green')
|
|
|
|
# Untagged files mode
|
|
with ui.column().classes('gap-2'):
|
|
ui.label('Untagged Files').classes('text-xs font-semibold text-gray-400 uppercase tracking-wider')
|
|
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-4'):
|
|
ui.button('Apply Page', on_click=action_apply_page) \
|
|
.props('outline color=white').classes('px-6')
|
|
|
|
ui.button('Apply Global', on_click=action_apply_global) \
|
|
.props('unelevated').classes('btn-danger px-6')
|
|
|
|
# ==========================================
|
|
# INITIALIZATION
|
|
# ==========================================
|
|
|
|
# Inject custom CSS
|
|
ui.add_head_html(CUSTOM_CSS)
|
|
|
|
# Initialize database
|
|
SorterEngine.init_db()
|
|
|
|
# Build UI
|
|
build_header()
|
|
build_sidebar()
|
|
build_main_content()
|
|
|
|
# Setup keyboard navigation
|
|
ui.keyboard(on_key=handle_keyboard)
|
|
|
|
# Enable dark mode
|
|
ui.dark_mode().enable()
|
|
|
|
# Initial load
|
|
load_images()
|
|
|
|
# Run server
|
|
ui.run(title="NiceSorter", host="0.0.0.0", port=8080, reload=False) |