From 8a7a07d064d383bc8a64325dee898a767939bed6 Mon Sep 17 00:00:00 2001 From: cocorang Date: Tue, 13 Oct 2026 13:20:37 +0900 Subject: [PATCH] Gallery extension selection feature --- ARCHITECTURE.md | 2 +- CHANGELOG.md | 6 ++ api/gallery_routes.py | 54 ++++++++-- api/media_routes.py | 5 +- web/modules/gallery_filter.js | 89 ++++++++++++++++ web/modules/locales.js | 16 +++ web/modules/ui_gallery.js | 77 ++++++++++---- web/modules/ui_gallery_card.js | 14 ++- web/modules/ui_gallery_filter.js | 163 ++++++++++++++++++++++++++++++ web/modules/ui_settings_page.js | 8 ++ web/styles/03-gallery-recipes.css | 101 +++++++++++++++++- 11 files changed, 502 insertions(+), 33 deletions(-) create mode 100644 web/modules/gallery_filter.js create mode 100644 web/modules/ui_gallery_filter.js diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2ec0d7c..1c6f1e1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -378,7 +378,7 @@ covered by the `styles.css` manifest. safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js` coordinates detail display, `ui_model_editor.js` owns metadata editing, and `ui_model_selector.js` owns advanced selection. `ui_gallery.js` (cards in - `ui_gallery_card.js`) and `ui_gallery_detail.js` own generated-image browsing and workbench lifecycle, + `ui_gallery_card.js`, format filtering state in `gallery_filter.js`, and quick filter and settings UI in `ui_gallery_filter.js`) and `ui_gallery_detail.js` own generated-image browsing and workbench lifecycle, with stage interaction in `ui_image_stage.js` and metadata tabs in `ui_image_inspector.js`. - `ui_recipes.js` / `ui_recipe_detail.js` and `ui_notebooks.js` diff --git a/CHANGELOG.md b/CHANGELOG.md index a23edf3..af4e029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 📈 Anomalous Model Browser Changelog +## Unreleased (Gallery file extensions & video display) + +### 🖼️ Gallery file extension filtering & video playback (画廊扩展名过滤与视频支持 / 갤러리 확장자 필터 및 비디오 지원) +- **File format configuration (파일 확장자 표시/숨김 설정)**: In the Gallery toolbar and Settings page, users can configure which output file extensions to display and hide. Easily toggle individual formats (PNG, JPG, WEBP, GIF, MP4, WEBM, MOV, MKV) or use quick group presets (All, Images, Videos). +- **Video outputs support (비디오 출력 지원)**: Gallery backend now indexes video outputs (.mp4, .webm, .mov, .avi, .mkv), generates video posters for thumbnails, displays a video format badge on the card, and enables video playback in the full-size gallery viewer. + ## v1.58.1 Beta (Security: only this computer can change things) — 2026-10-12 ### 🔒 Other devices can look, not change (别的设备只能看,不能改) diff --git a/api/gallery_routes.py b/api/gallery_routes.py index 1fd1298..6a55445 100644 --- a/api/gallery_routes.py +++ b/api/gallery_routes.py @@ -9,13 +9,22 @@ from aiohttp import web import folder_paths from .image_search import filter_images -from .media_routes import CARD_THUMBNAIL_STATIC_EXTENSIONS, _build_card_thumbnail +from .media_routes import ( + CARD_POSTER_VIDEO_EXTENSIONS, + CARD_THUMBNAIL_STATIC_EXTENSIONS, + _build_card_thumbnail, + _build_video_poster, +) from .path_utils import require_filename, resolve_within from .trash import move_to_trash, trash_failure GALLERY_SNAPSHOT_SECONDS = 10 MAX_CACHED_GALLERY_IMAGES = 50_000 +SUPPORTED_GALLERY_EXTENSIONS = { + '.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif', + '.mp4', '.webm', '.mov', '.avi', '.mkv', +} _gallery_snapshot_lock = threading.Lock() _gallery_snapshot = {"root": None, "expires": 0, "images": []} @@ -24,7 +33,8 @@ def _collect_gallery_images(output_dir): images = [] for root, _, files in os.walk(output_dir): for filename in files: - if os.path.splitext(filename)[1].lower() not in {'.png', '.jpg', '.jpeg', '.webp', '.gif'}: + ext = os.path.splitext(filename)[1].lower() + if ext not in SUPPORTED_GALLERY_EXTENSIONS: continue try: mtime = os.path.getmtime(os.path.join(root, filename)) @@ -34,7 +44,10 @@ def _collect_gallery_images(output_dir): images.append({ "filename": filename, "subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'), - "type": "output", "mtime": mtime, + "type": "output", + "mtime": mtime, + "ext": ext.lstrip('.'), + "media_type": "video" if ext in CARD_POSTER_VIDEO_EXTENSIONS else "image", }) images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True) return images @@ -58,20 +71,40 @@ def _invalidate_gallery_snapshot(): _gallery_snapshot.update(root=None, expires=0, images=[]) -def _gallery_listing(output_dir, refresh, query): +def _gallery_listing(output_dir, refresh, query, allowed_exts=None): images = _gallery_images(output_dir, refresh) + if allowed_exts is not None: + images = [img for img in images if os.path.splitext(img['filename'])[1].lower().lstrip('.') in allowed_exts] return filter_images(os.path.realpath(output_dir), images, query) if query else images async def api_get_gallery_images(request): - """GET /anomalous/gallery_images?page&limit&refresh&term=..&term=.. (or q) - Output images, optionally searched by embedded generation info.""" + """GET /anomalous/gallery_images?page&limit&refresh&term=..&ext=.. - Output images/videos, optionally filtered by extension.""" try: page = max(1, int(request.query.get('page', 1))) limit = min(200, max(1, int(request.query.get('limit', 50)))) # Repeated `term` params keep phrases whole; `q` is split on whitespace. terms = [term for term in request.query.getall('term', []) if term.strip()] query = terms or request.query.get('q', '').strip() - images = await asyncio.to_thread(_gallery_listing, folder_paths.get_output_directory(), request.query.get('refresh') == '1', query) + + # Optional extension filter (?ext=png&ext=mp4 or ?ext=png,mp4) + ext_filter = None + if 'ext' in request.query: + allowed_exts = set() + for raw in request.query.getall('ext', []): + for part in raw.split(','): + cleaned = part.strip().lower().lstrip('.') + if cleaned: + allowed_exts.add(cleaned) + ext_filter = allowed_exts + + images = await asyncio.to_thread( + _gallery_listing, + folder_paths.get_output_directory(), + request.query.get('refresh') == '1', + query, + ext_filter, + ) total = len(images) start = (page - 1) * limit return web.json_response({"images": images[start:start + limit], "total": total, @@ -84,7 +117,7 @@ async def api_get_gallery_images(request): async def api_output_thumbnail(request): """GET /anomalous/output_thumbnail?filename&subfolder - the gallery grid's 512 px WebP of an - output image (cached like model cards); the original stays at ComfyUI's /view.""" + output image/video (cached like model cards); the original stays at ComfyUI's /view.""" output_dir = folder_paths.get_output_directory() try: filename = require_filename(request.query.get('filename')) @@ -94,8 +127,13 @@ async def api_output_thumbnail(request): if not os.path.isfile(file_path): return web.json_response({"status": "error", "message": "File not found"}, status=404) served = file_path - if os.path.splitext(file_path)[1].lower() in CARD_THUMBNAIL_STATIC_EXTENSIONS: + ext = os.path.splitext(file_path)[1].lower() + if ext in CARD_THUMBNAIL_STATIC_EXTENSIONS: served = await asyncio.to_thread(_build_card_thumbnail, file_path) + elif ext in CARD_POSTER_VIDEO_EXTENSIONS: + poster = await asyncio.to_thread(_build_video_poster, file_path) + if poster: + served = poster # Revalidate (cheap 304s): an output file can be replaced under the same name. return web.FileResponse(served, headers={'Cache-Control': 'no-cache'}) diff --git a/api/media_routes.py b/api/media_routes.py index f808a0f..1849504 100644 --- a/api/media_routes.py +++ b/api/media_routes.py @@ -26,7 +26,7 @@ from .path_utils import require_filename, resolve_folder_subdir, resolve_within CARD_THUMBNAIL_EDGE = 512 CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024 CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'} -CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov'} +CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.avi', '.mkv'} CARD_WARM_QUEUE_LIMIT = 512 _thumbnail_cleanup_lock = threading.Lock() _thumbnail_generation_slots = threading.BoundedSemaphore(2) @@ -302,7 +302,8 @@ async def api_serve_image(request): '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', - '.avi': 'video/x-msvideo' + '.avi': 'video/x-msvideo', + '.mkv': 'video/x-matroska' } content_type = content_types.get(ext) if content_type is None: diff --git a/web/modules/gallery_filter.js b/web/modules/gallery_filter.js new file mode 100644 index 0000000..453f620 --- /dev/null +++ b/web/modules/gallery_filter.js @@ -0,0 +1,89 @@ +/** + * gallery_filter.js + * Source of truth for active/hidden output formats in the Gallery. + * Persists extension preferences to localStorage ('anomalous_gallery_extensions'). + */ + +export const GALLERY_IMAGE_EXTENSIONS = ['png', 'webp', 'jpg', 'jpeg', 'gif']; +export const GALLERY_VIDEO_EXTENSIONS = ['mp4', 'webm', 'mov', 'mkv']; +export const ALL_GALLERY_EXTENSIONS = [...GALLERY_IMAGE_EXTENSIONS, ...GALLERY_VIDEO_EXTENSIONS]; + +const STORAGE_KEY = 'anomalous_gallery_extensions'; + +function cleanExt(ext) { + return String(ext || '').trim().toLowerCase().replace(/^\.+/, ''); +} + +/** Returns the Set of currently active extensions (lowercase without dot). */ +export function getActiveGalleryExtensions() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + return new Set(parsed.map(cleanExt).filter(e => ALL_GALLERY_EXTENSIONS.includes(e))); + } + } + } catch (_) {} + return new Set(ALL_GALLERY_EXTENSIONS); +} + +/** Saves the active extensions to localStorage. */ +export function setActiveGalleryExtensions(extensions) { + const arr = Array.from(extensions || []).map(cleanExt).filter(e => ALL_GALLERY_EXTENSIONS.includes(e)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(arr)); + return new Set(arr); +} + +/** Toggles a single extension on/off. */ +export function toggleGalleryExtension(ext) { + const cleaned = cleanExt(ext); + const active = getActiveGalleryExtensions(); + if (active.has(cleaned)) { + active.delete(cleaned); + } else { + active.add(cleaned); + } + setActiveGalleryExtensions(active); + return active; +} + +/** Sets all extensions in a group (all, images, videos) to enabled or disabled. */ +export function setGalleryGroup(group, enabled) { + const active = getActiveGalleryExtensions(); + let targets = []; + if (group === 'all') { + targets = ALL_GALLERY_EXTENSIONS; + } else if (group === 'images') { + targets = GALLERY_IMAGE_EXTENSIONS; + } else if (group === 'videos') { + targets = GALLERY_VIDEO_EXTENSIONS; + } + + for (const ext of targets) { + if (enabled) active.add(ext); + else active.delete(ext); + } + setActiveGalleryExtensions(active); + return active; +} + +/** True if every supported extension is active. */ +export function isAllGalleryExtensionsActive() { + const active = getActiveGalleryExtensions(); + return ALL_GALLERY_EXTENSIONS.every(ext => active.has(ext)); +} + +/** Appends active extensions to URLSearchParams. If all are active, omits to query everything. */ +export function appendExtensionParams(params) { + const active = getActiveGalleryExtensions(); + // If not all are active, append each active extension + if (active.size < ALL_GALLERY_EXTENSIONS.length) { + if (active.size === 0) { + params.set('ext', 'none'); + } else { + active.forEach(ext => params.append('ext', ext)); + } + } +} + diff --git a/web/modules/locales.js b/web/modules/locales.js index dce6f13..095fa17 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -556,6 +556,14 @@ export const i18n = { gallerySearchCount: '找到 {count} 张', gallerySearchEmpty: '没有找到匹配的图片', gallerySearching: '正在搜索…', + galleryFilterAll: '全部', + galleryFilterImages: '图片', + galleryFilterVideos: '视频', + galleryExtensionToggle: '切换格式过滤', + galleryExtensionsTitle: '画廊显示格式', + galleryExtensionsHelp: '设置在画廊中显示或隐藏的文件格式(图片、视频)', + galleryViewVideo: '查看视频', + settingsGallery: '画廊设置', galleryClose: '关闭图库', galleryHistoryTitle: '历史生成图库: {name}', galleryScanning: '加载中,正在扫描图片元数据...', @@ -2454,6 +2462,14 @@ export const i18n = { gallerySearchCount: '{count} found', gallerySearchEmpty: 'No matching images', gallerySearching: 'Searching…', + galleryFilterAll: 'All', + galleryFilterImages: 'Images', + galleryFilterVideos: 'Videos', + galleryExtensionToggle: 'Toggle format filter', + galleryExtensionsTitle: 'Gallery File Formats', + galleryExtensionsHelp: 'Configure which output file formats (images, videos) to display or hide in Gallery', + galleryViewVideo: 'View video', + settingsGallery: 'Gallery', galleryClose: 'Close Gallery', galleryHistoryTitle: 'Generated History: {name}', galleryScanning: 'Loading, scanning metadata...', diff --git a/web/modules/ui_gallery.js b/web/modules/ui_gallery.js index 0f3c851..bd074d4 100644 --- a/web/modules/ui_gallery.js +++ b/web/modules/ui_gallery.js @@ -10,6 +10,8 @@ import { showImageWorkbench } from './ui_gallery_detail.js'; import { createGalleryCard } from './ui_gallery_card.js'; import { loadKeptImages } from './image_keep.js'; import { createSearchChips } from './ui_search_chips.js'; +import { createGalleryFilterBar } from './ui_gallery_filter.js'; +import { appendExtensionParams } from './gallery_filter.js'; const t = (key, params) => translate(key, params); @@ -29,8 +31,11 @@ export async function refreshGalleryImages() { -/** Search blocks above the output gallery; the terms live on the browser as `gallerySearchTerms`. */ +/** Search blocks and format filters above the output gallery. */ export function createGallerySearchBar(owner) { + const header = document.createElement('div'); + header.className = 'anomalous-gallery-header'; + const search = createSearchChips({ placeholder: t('gallerySearchPlaceholder'), help: t('gallerySearchHelp'), @@ -41,7 +46,14 @@ export function createGallerySearchBar(owner) { }); search.element.classList.add('anomalous-gallery-search'); owner.gallerySearchCount = search.countElement; - return search.element; + + const filterBar = createGalleryFilterBar(owner, () => { + owner.loadGalleryImages(1, true, { refresh: false }); + }); + owner.galleryFilterBar = filterBar; + + header.append(search.element, filterBar); + return header; } export async function loadGalleryImages(page = 1, reset = false, { refresh = reset } = {}) { @@ -60,6 +72,7 @@ export async function loadGalleryImages(page = 1, reset = false, { refresh = res const params = new URLSearchParams({ page: String(page), limit: '50' }); if (refresh) params.set('refresh', '1'); terms.forEach(term => params.append('term', term)); + appendExtensionParams(params); const res = await fetch(`/anomalous/gallery_images?${params}`); const data = await res.json(); if (termsKey !== JSON.stringify(this.gallerySearchTerms || [])) { @@ -433,7 +446,7 @@ export function showGallerySelectMode(model) { -export function showGalleryViewer(src) { +export function showGalleryViewer(src, isVideo = false) { let viewer = document.getElementById('anomalous-gallery-viewer'); if (!viewer) { viewer = document.createElement('dialog'); @@ -447,7 +460,15 @@ export function showGalleryViewer(src) { const img = document.createElement('img'); img.id = 'anomalous-gallery-viewer-img'; + const video = document.createElement('video'); + video.id = 'anomalous-gallery-viewer-video'; + video.controls = true; + video.autoplay = true; + video.loop = true; + video.style.display = 'none'; + viewer.appendChild(img); + viewer.appendChild(video); viewer.appendChild(closeBtn); let scale = 1; @@ -456,24 +477,28 @@ export function showGalleryViewer(src) { let isDragging = false; let startX = 0, startY = 0; - const resetImgTransform = () => { + const resetTransform = () => { scale = 1; translateX = 0; translateY = 0; img.style.transform = `translate(0px, 0px) scale(1)`; img.style.cursor = 'grab'; - }; - - closeBtn.onclick = () => { - viewer.close(); - resetImgTransform(); - }; - - viewer.onclick = (e) => { - if (e.target === viewer) { - viewer.close(); - resetImgTransform(); + const v = document.getElementById('anomalous-gallery-viewer-video'); + if (v) { + v.pause(); + v.style.display = 'none'; + v.removeAttribute('src'); } }; + const closeViewer = () => { + viewer.close(); + resetTransform(); + }; + + closeBtn.onclick = closeViewer; + viewer.onclick = (e) => { + if (e.target === viewer) closeViewer(); + }; + viewer.addEventListener('wheel', (e) => { e.preventDefault(); const zoomFactor = 0.1; @@ -506,11 +531,25 @@ export function showGalleryViewer(src) { } const img = document.getElementById('anomalous-gallery-viewer-img'); - img.src = src; + const video = document.getElementById('anomalous-gallery-viewer-video'); + const isVid = isVideo || /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(src); - // Reset scale and translation when opening a new image - img.style.transform = `translate(0px, 0px) scale(1)`; - img.style.cursor = 'grab'; + if (isVid && video) { + img.style.display = 'none'; + video.style.display = 'block'; + video.src = src; + video.play().catch(() => {}); + } else { + if (video) { + video.pause(); + video.style.display = 'none'; + video.removeAttribute('src'); + } + img.style.display = 'block'; + img.src = src; + img.style.transform = `translate(0px, 0px) scale(1)`; + img.style.cursor = 'grab'; + } if (!viewer.open) viewer.showModal(); } diff --git a/web/modules/ui_gallery_card.js b/web/modules/ui_gallery_card.js index 0c29105..3ce074b 100644 --- a/web/modules/ui_gallery_card.js +++ b/web/modules/ui_gallery_card.js @@ -122,10 +122,13 @@ export function createGalleryCard(owner, imgData, { showViewer }) { const card = document.createElement('div'); card.className = 'anomalous-gallery-card'; + const isVideo = /\.(mp4|webm|mov|avi|mkv)$/i.test(image.filename); + if (isVideo) card.classList.add('is-video'); + const img = document.createElement('img'); img.src = `/anomalous/output_thumbnail?${query}`; // drags and the viewer use the original img.loading = 'lazy'; - img.title = t('materialViewOriginal'); + img.title = isVideo ? t('galleryViewVideo') : t('materialViewOriginal'); // The card is dragged, not the : Chrome adds a dragged as a file, and ComfyUI // would load that thumbnail (no workflow in it) instead of fetching the original's link. img.draggable = false; @@ -137,7 +140,7 @@ export function createGalleryCard(owner, imgData, { showViewer }) { // Chromium may not start dragging very large (hires) images without a small drag image. if (window.anomalousDragGhostImg) event.dataTransfer.setDragImage(window.anomalousDragGhostImg, 40, 40); }); - img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url)); + img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url, isVideo)); const details = button('anomalous-gallery-details', t('materialViewDetails'), () => { if (!owner.gallerySelectModel) openWorkbench(owner, image, url); @@ -148,6 +151,13 @@ export function createGalleryCard(owner, imgData, { showViewer }) { remove.innerHTML = DELETE_ICON; // static markup only card.append(img, details, remove); + if (isVideo) { + const ext = image.filename.split('.').pop().toUpperCase(); + const badge = document.createElement('span'); + badge.className = 'anomalous-gallery-video-badge'; + badge.innerHTML = ` ${ext}`; + card.append(badge); + } // Only PNG outputs carry the workflow that can be kept. if (/\.png$/i.test(image.filename)) card.append(keepButton(owner, image)); return card; diff --git a/web/modules/ui_gallery_filter.js b/web/modules/ui_gallery_filter.js new file mode 100644 index 0000000..b2faafa --- /dev/null +++ b/web/modules/ui_gallery_filter.js @@ -0,0 +1,163 @@ +/** + * ui_gallery_filter.js + * UI components for gallery extension filtering: the quick filter bar + * in the Gallery header, and the configuration section in Settings. + */ + +import { translate } from './locales.js'; +import { + ALL_GALLERY_EXTENSIONS, + GALLERY_IMAGE_EXTENSIONS, + GALLERY_VIDEO_EXTENSIONS, + getActiveGalleryExtensions, + toggleGalleryExtension, + setGalleryGroup, + isAllGalleryExtensionsActive, +} from './gallery_filter.js'; + +const t = (key, params) => translate(key, params); + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; +} + +/** + * Creates the filter bar rendered inside the Gallery header. + * Displays quick group presets (All, Images, Videos) and format chips (PNG, WEBP, MP4, etc.). + */ +export function createGalleryFilterBar(owner, onFilterChange) { + const bar = el('div', 'anomalous-gallery-filter-bar'); + + const updateChips = () => { + const active = getActiveGalleryExtensions(); + const allActive = isAllGalleryExtensionsActive(); + + bar.querySelectorAll('[data-ext]').forEach(chip => { + const ext = chip.getAttribute('data-ext'); + chip.classList.toggle('is-active', active.has(ext)); + chip.setAttribute('aria-pressed', String(active.has(ext))); + }); + + const allChip = bar.querySelector('[data-group="all"]'); + if (allChip) { + allChip.classList.toggle('is-active', allActive); + allChip.setAttribute('aria-pressed', String(allActive)); + } + + const imagesActive = GALLERY_IMAGE_EXTENSIONS.some(e => active.has(e)); + const imagesAll = GALLERY_IMAGE_EXTENSIONS.every(e => active.has(e)); + const imgChip = bar.querySelector('[data-group="images"]'); + if (imgChip) { + imgChip.classList.toggle('is-active', imagesAll); + imgChip.classList.toggle('is-partial', imagesActive && !imagesAll); + } + + const videosActive = GALLERY_VIDEO_EXTENSIONS.some(e => active.has(e)); + const videosAll = GALLERY_VIDEO_EXTENSIONS.every(e => active.has(e)); + const vidChip = bar.querySelector('[data-group="videos"]'); + if (vidChip) { + vidChip.classList.toggle('is-active', videosAll); + vidChip.classList.toggle('is-partial', videosActive && !videosAll); + } + }; + + const addGroupChip = (groupKey, labelKey) => { + const chip = el('button', 'anomalous-gallery-filter-chip is-group', t(labelKey)); + chip.type = 'button'; + chip.setAttribute('data-group', groupKey); + chip.onclick = (e) => { + e.preventDefault(); + const active = getActiveGalleryExtensions(); + let targets = []; + if (groupKey === 'all') targets = ALL_GALLERY_EXTENSIONS; + else if (groupKey === 'images') targets = GALLERY_IMAGE_EXTENSIONS; + else if (groupKey === 'videos') targets = GALLERY_VIDEO_EXTENSIONS; + + const allInGroupActive = targets.every(ext => active.has(ext)); + setGalleryGroup(groupKey, !allInGroupActive); + updateChips(); + onFilterChange?.(); + }; + bar.appendChild(chip); + }; + + // Preset group buttons + addGroupChip('all', 'galleryFilterAll'); + addGroupChip('images', 'galleryFilterImages'); + addGroupChip('videos', 'galleryFilterVideos'); + + // Separator divider + bar.appendChild(el('span', 'anomalous-gallery-filter-sep')); + + // Format pills + ALL_GALLERY_EXTENSIONS.forEach(ext => { + const isVid = GALLERY_VIDEO_EXTENSIONS.includes(ext); + const chip = el('button', `anomalous-gallery-filter-chip is-ext ${isVid ? 'is-video' : ''}`, ext.toUpperCase()); + chip.type = 'button'; + chip.setAttribute('data-ext', ext); + chip.title = `${t('galleryExtensionToggle')}: .${ext}`; + chip.onclick = (e) => { + e.preventDefault(); + toggleGalleryExtension(ext); + updateChips(); + onFilterChange?.(); + }; + bar.appendChild(chip); + }); + + updateChips(); + bar.refresh = updateChips; + return bar; +} + +/** + * Creates the extension configuration control for the Settings page. + */ +export function createGalleryExtensionSettings(owner, redraw) { + const box = el('div', 'anomalous-gallery-settings-exts'); + const active = getActiveGalleryExtensions(); + + const chipsWrap = el('div', 'anomalous-gallery-settings-chips'); + ALL_GALLERY_EXTENSIONS.forEach(ext => { + const isVid = GALLERY_VIDEO_EXTENSIONS.includes(ext); + const btn = el('button', `anomalous-scan-segment-btn ${active.has(ext) ? 'is-active' : ''}`, `.${ext}`); + btn.type = 'button'; + btn.onclick = () => { + toggleGalleryExtension(ext); + redraw(); + }; + chipsWrap.appendChild(btn); + }); + + const actions = el('div', 'anomalous-gallery-settings-actions'); + const selectAllBtn = el('button', 'anomalous-scan-secondary anomalous-scan-small-btn', t('galleryFilterAll')); + selectAllBtn.type = 'button'; + selectAllBtn.onclick = () => { + setGalleryGroup('all', true); + redraw(); + }; + + const imagesOnlyBtn = el('button', 'anomalous-scan-secondary anomalous-scan-small-btn', t('galleryFilterImages')); + imagesOnlyBtn.type = 'button'; + imagesOnlyBtn.onclick = () => { + setGalleryGroup('images', true); + setGalleryGroup('videos', false); + redraw(); + }; + + const videosOnlyBtn = el('button', 'anomalous-scan-secondary anomalous-scan-small-btn', t('galleryFilterVideos')); + videosOnlyBtn.type = 'button'; + videosOnlyBtn.onclick = () => { + setGalleryGroup('videos', true); + setGalleryGroup('images', false); + redraw(); + }; + + actions.append(selectAllBtn, imagesOnlyBtn, videosOnlyBtn); + box.append(chipsWrap, actions); + return box; +} + diff --git a/web/modules/ui_settings_page.js b/web/modules/ui_settings_page.js index 9223067..27b380b 100644 --- a/web/modules/ui_settings_page.js +++ b/web/modules/ui_settings_page.js @@ -17,6 +17,7 @@ import { copyDiagnostics } from './feedback.js'; import { renderFolderPage } from './ui_folder_manager.js'; import { openFeedbackDialog } from './ui_feedback_dialog.js'; import { checkOnOpen, setCheckOnOpen } from './ui_doctor_banner.js'; +import { createGalleryExtensionSettings } from './ui_gallery_filter.js'; const t = (key, params) => translate(key, params); // The full written guide: the README, at its Chinese half for Chinese. @@ -178,6 +179,12 @@ function cardsGroup(owner, redraw) { ); } +function galleryGroup(owner, redraw) { + return group('settingsGallery', + row('galleryExtensionsTitle', 'galleryExtensionsHelp', createGalleryExtensionSettings(owner, redraw)), + ); +} + function foldersGroup(owner) { return group('settingsFolders', row('sidebarManageFolders', 'settingsFoldersHelp', @@ -253,6 +260,7 @@ function render(owner) { el('h1', 'anomalous-scan-title', t('sidebarSettings')), appearanceGroup(owner, redraw), cardsGroup(owner, redraw), + galleryGroup(owner, redraw), foldersGroup(owner), workflowGroup(owner), openingGroup(owner, redraw), diff --git a/web/styles/03-gallery-recipes.css b/web/styles/03-gallery-recipes.css index 0b75288..0244438 100644 --- a/web/styles/03-gallery-recipes.css +++ b/web/styles/03-gallery-recipes.css @@ -7,6 +7,104 @@ background: var(--bg-color, var(--amb-bg-card)); } +.anomalous-gallery-header { + display: flex; + flex-direction: column; + gap: 8px; + background: var(--bg-color, var(--amb-bg-card)); + border-bottom: 1px solid var(--amb-border-subtle); + padding-bottom: 10px; +} + +.anomalous-gallery-filter-bar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + padding: 0 16px; +} + +.anomalous-gallery-filter-sep { + width: 1px; + height: 14px; + background: var(--amb-border-subtle); + margin: 0 4px; +} + +.anomalous-gallery-filter-chip { + background: var(--amb-bg-page); + color: var(--amb-text-muted); + border: 1px solid var(--amb-border-subtle); + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + cursor: pointer; + line-height: 1.2; + transition: all 0.15s ease; +} + +.anomalous-gallery-filter-chip:hover { + color: var(--amb-text-main); + border-color: var(--amb-border-strong); +} + +.anomalous-gallery-filter-chip.is-active { + background: var(--amb-accent-soft, rgba(64, 128, 255, 0.15)); + border-color: var(--amb-accent, #3b82f6); + color: var(--amb-text-main); +} + +.anomalous-gallery-filter-chip.is-video.is-active { + border-color: #8b5cf6; + background: rgba(139, 92, 246, 0.18); +} + +.anomalous-gallery-filter-chip.is-group.is-partial { + border-color: var(--amb-border-strong); + color: var(--amb-text-soft); + background: var(--amb-bg-card-hover); +} + +.anomalous-gallery-video-badge { + position: absolute; + top: 8px; + left: 8px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + padding: 2px 6px; + border-radius: 4px; + display: inline-flex; + align-items: center; + gap: 4px; + pointer-events: none; + backdrop-filter: blur(4px); + z-index: 2; + border: 1px solid rgba(255, 255, 255, 0.15); +} + +.anomalous-gallery-settings-exts { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.anomalous-gallery-settings-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.anomalous-gallery-settings-actions { + display: flex; + gap: 8px; + margin-top: 4px; +} + .anomalous-gallery-grid { flex: 1; padding: 20px; @@ -331,7 +429,8 @@ dialog.anomalous-gallery-viewer[open] { display: flex; } -.anomalous-gallery-viewer img { +.anomalous-gallery-viewer img, +.anomalous-gallery-viewer video { max-width: 95%; max-height: 95%; object-fit: contain;