diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a7d3671..bbbadb1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -428,7 +428,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 (the file card with Show in folder in `model_location.js`), `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/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 b3108ca..e6fd575 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -629,6 +629,14 @@ export const i18n = { gallerySearchCount: '找到 {count} 张', gallerySearchEmpty: '没有找到匹配的图片', gallerySearching: '正在搜索…', + galleryFilterAll: '全部', + galleryFilterImages: '图片', + galleryFilterVideos: '视频', + galleryExtensionToggle: '切换格式过滤', + galleryExtensionsTitle: '画廊显示格式', + galleryExtensionsHelp: '设置在画廊中显示或隐藏的文件格式(图片、视频)', + galleryViewVideo: '查看视频', + settingsGallery: '画廊设置', galleryClose: '关闭图库', galleryHistoryTitle: '历史生成图库: {name}', galleryScanning: '加载中,正在扫描图片元数据...', @@ -2777,6 +2785,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 a815856..07623d8 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 962fc35..8734212 100644 --- a/web/modules/ui_gallery_card.js +++ b/web/modules/ui_gallery_card.js @@ -124,10 +124,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; @@ -143,7 +146,7 @@ export function createGalleryCard(owner, imgData, { showViewer }) { setTimeout(() => owner.modal?.classList.add('anomalous-material-dragging'), 0); }); card.addEventListener('dragend', () => owner.modal?.classList.remove('anomalous-material-dragging')); - img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url)); + img.onclick = () => (owner.gallerySelectModel ? useAsCover(owner, image) : showViewer(url, isVideo)); card.addEventListener('contextmenu', (event) => { if (!owner.gallerySelectModel) openGalleryCardMenu(event, image, { owner, openDetails: () => openWorkbench(owner, image, url) }); }); @@ -157,6 +160,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 51d148d..34e4df0 100644 --- a/web/modules/ui_settings_page.js +++ b/web/modules/ui_settings_page.js @@ -21,6 +21,7 @@ import { fetchDownloadSettings, hfMirrorOn, saveDownloadSettings } from './model import { saveApiKey } from './ui_scan_page.js'; import { exportBackup, importBackup } from './ui_backup.js'; import { followsSelection, setFollowsSelection } from './shell_open_rules.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. @@ -182,6 +183,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', @@ -361,6 +368,7 @@ function render(owner) { el('h1', 'anomalous-scan-title', t('sidebarSettings')), appearanceGroup(owner, redraw), cardsGroup(owner, redraw), + galleryGroup(owner, redraw), foldersGroup(owner), workflowGroup(owner), downloadGroup(owner, redraw), diff --git a/web/styles/03-gallery-recipes.css b/web/styles/03-gallery-recipes.css index 6d7cc15..d4734b8 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; @@ -337,7 +435,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;