diff --git a/static/css/components/media-viewer.css b/static/css/components/media-viewer.css
new file mode 100644
index 00000000..1867cd07
--- /dev/null
+++ b/static/css/components/media-viewer.css
@@ -0,0 +1,124 @@
+.media-viewer-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0);
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: background 0.3s ease;
+}
+
+.media-viewer-overlay.active {
+ background: rgba(0, 0, 0, 0.92);
+}
+
+.media-viewer-close {
+ position: fixed;
+ top: 16px;
+ right: 16px;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.1);
+ border: none;
+ color: #fff;
+ font-size: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ z-index: 10001;
+ transition: background 0.2s ease;
+ opacity: 0;
+}
+
+.media-viewer-overlay.active .media-viewer-close {
+ opacity: 1;
+}
+
+.media-viewer-close:hover {
+ background: rgba(255, 255, 255, 0.25);
+}
+
+.media-viewer-content-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ max-width: 90vw;
+ max-height: 95vh;
+ cursor: default;
+}
+
+.media-viewer-media {
+ display: block;
+ max-width: 90vw;
+ max-height: 85vh;
+ object-fit: contain;
+ border-radius: 4px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
+}
+
+.media-viewer-video {
+ max-height: 80vh;
+}
+
+.media-viewer-counter {
+ margin-top: 8px;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 0.85em;
+ text-align: center;
+ min-height: 1.2em;
+}
+
+.media-viewer-title {
+ margin-top: 4px;
+ color: rgba(255, 255, 255, 0.7);
+ font-size: 0.9em;
+ text-align: center;
+ max-width: 90vw;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.media-viewer-nav {
+ position: fixed;
+ top: 50%;
+ transform: translateY(-50%);
+ width: 48px;
+ height: 80px;
+ border-radius: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ border: none;
+ color: #fff;
+ font-size: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ z-index: 10001;
+ opacity: 0;
+ transition: opacity 0.2s ease, background 0.2s ease;
+}
+
+.media-viewer-overlay.active .media-viewer-nav {
+ opacity: 1;
+}
+
+.media-viewer-nav:hover {
+ background: rgba(255, 255, 255, 0.18);
+}
+
+.media-viewer-prev {
+ left: 16px;
+}
+
+.media-viewer-next {
+ right: 16px;
+}
diff --git a/static/css/style.css b/static/css/style.css
index b3ab5987..17370b2b 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -39,6 +39,7 @@
@import 'components/keyboard-nav.css'; /* Add keyboard navigation component */
@import 'components/statistics.css'; /* Add statistics component */
@import 'components/sidebar.css'; /* Add sidebar component */
+@import 'components/media-viewer.css';
.initialization-notice {
display: flex;
diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js
index 37e3bf45..f5209c2d 100644
--- a/static/js/components/RecipeModal.js
+++ b/static/js/components/RecipeModal.js
@@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../utils/storageHelpers.js';
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
+import { openMediaViewer } from './shared/MediaViewer.js';
const ALLOWED_GEN_PARAM_KEYS = new Set([
'prompt',
@@ -112,6 +113,23 @@ class RecipeModal {
// Set up document click handler to close edit fields
document.addEventListener('click', (event) => {
+ const recipeModal = document.getElementById('recipeModal');
+ if (recipeModal && recipeModal.style.display !== 'none') {
+ const mediaEl = event.target.closest('.recipe-preview-media');
+ if (mediaEl && mediaEl.tagName) {
+ event.stopPropagation();
+ const isVideo = mediaEl.tagName === 'VIDEO';
+ const url = mediaEl.src || mediaEl.currentSrc;
+ if (url) {
+ openMediaViewer(url, {
+ type: isVideo ? 'video' : 'image',
+ title: document.getElementById('recipeModalTitle')?.textContent || ''
+ });
+ }
+ return;
+ }
+ }
+
// Handle title edit
const titleEditor = document.getElementById('recipeTitleEditor');
if (titleEditor && titleEditor.classList.contains('active') &&
diff --git a/static/js/components/shared/MediaViewer.js b/static/js/components/shared/MediaViewer.js
new file mode 100644
index 00000000..2d88df88
--- /dev/null
+++ b/static/js/components/shared/MediaViewer.js
@@ -0,0 +1,204 @@
+let activeViewer = null;
+
+function createMediaElement(item) {
+ const { url, type = 'image' } = item;
+ if (type === 'video') {
+ const el = document.createElement('video');
+ el.controls = true;
+ el.autoplay = true;
+ el.loop = true;
+ el.muted = true;
+ el.className = 'media-viewer-media media-viewer-video';
+ el.src = url;
+ return el;
+ }
+ const el = document.createElement('img');
+ el.className = 'media-viewer-media media-viewer-image';
+ el.src = url;
+ el.alt = 'Full size preview';
+ el.draggable = false;
+ return el;
+}
+
+function preloadAdjacent(items, index) {
+ [index - 1, index + 1].forEach(i => {
+ if (i >= 0 && i < items.length && items[i].type !== 'video') {
+ const preload = new Image();
+ preload.src = items[i].url;
+ }
+ });
+}
+
+export function openMediaViewer(arg1, arg2, arg3) {
+ closeMediaViewer();
+
+ let items, currentIndex, title = '';
+
+ if (Array.isArray(arg1)) {
+ items = arg1;
+ currentIndex = typeof arg2 === 'number' ? arg2 : 0;
+ title = (arg3 && arg3.title) || '';
+ } else {
+ items = [{ url: arg1, type: (arg2 && arg2.type) || 'image' }];
+ currentIndex = 0;
+ title = (arg2 && arg2.title) || '';
+ }
+
+ if (currentIndex < 0 || currentIndex >= items.length) currentIndex = 0;
+
+ const overlay = document.createElement('div');
+ overlay.className = 'media-viewer-overlay';
+ overlay.setAttribute('role', 'dialog');
+ overlay.setAttribute('aria-label', title || 'Media viewer');
+
+ const closeBtn = document.createElement('button');
+ closeBtn.className = 'media-viewer-close';
+ closeBtn.innerHTML = '';
+ closeBtn.title = 'Close (Esc)';
+ closeBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ closeMediaViewer();
+ });
+
+ const contentContainer = document.createElement('div');
+ contentContainer.className = 'media-viewer-content-container';
+
+ let mediaElement = createMediaElement(items[currentIndex]);
+ contentContainer.appendChild(mediaElement);
+
+ const hasNavigation = items.length > 1;
+
+ const counter = document.createElement('div');
+ counter.className = 'media-viewer-counter';
+ counter.textContent = hasNavigation ? `${currentIndex + 1} / ${items.length}` : '';
+ contentContainer.appendChild(counter);
+
+ if (title) {
+ const titleBar = document.createElement('div');
+ titleBar.className = 'media-viewer-title';
+ titleBar.textContent = title;
+ contentContainer.appendChild(titleBar);
+ }
+
+ let prevBtn, nextBtn;
+ if (hasNavigation) {
+ prevBtn = document.createElement('button');
+ prevBtn.className = 'media-viewer-nav media-viewer-prev';
+ prevBtn.innerHTML = '';
+ prevBtn.title = 'Previous (←)';
+ nextBtn = document.createElement('button');
+ nextBtn.className = 'media-viewer-nav media-viewer-next';
+ nextBtn.innerHTML = '';
+ nextBtn.title = 'Next (→)';
+
+ const navigate = (delta) => {
+ const newIndex = (currentIndex + delta + items.length) % items.length;
+ currentIndex = newIndex;
+
+ const oldMedia = contentContainer.querySelector('.media-viewer-media');
+ const newMedia = createMediaElement(items[currentIndex]);
+
+ if (oldMedia) {
+ if (oldMedia.tagName === 'VIDEO') {
+ oldMedia.pause();
+ oldMedia.src = '';
+ }
+ oldMedia.replaceWith(newMedia);
+ }
+ mediaElement = newMedia;
+
+ counter.textContent = `${currentIndex + 1} / ${items.length}`;
+ preloadAdjacent(items, currentIndex);
+ };
+
+ prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
+ nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(1); });
+
+ overlay.appendChild(prevBtn);
+ overlay.appendChild(nextBtn);
+ }
+
+ overlay.appendChild(closeBtn);
+ overlay.appendChild(contentContainer);
+ document.body.appendChild(overlay);
+
+ requestAnimationFrame(() => {
+ overlay.classList.add('active');
+ });
+
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) {
+ closeMediaViewer();
+ }
+ });
+
+ const keyHandler = (e) => {
+ if (e.key === 'Escape') {
+ closeMediaViewer();
+ return;
+ }
+ if (hasNavigation) {
+ if (e.key === 'ArrowLeft') {
+ e.stopPropagation();
+ e.preventDefault();
+ prevBtn.click();
+ return;
+ }
+ if (e.key === 'ArrowRight') {
+ e.stopPropagation();
+ e.preventDefault();
+ nextBtn.click();
+ return;
+ }
+ }
+ };
+ document.addEventListener('keydown', keyHandler, true);
+
+ activeViewer = { overlay, keyHandler };
+ preloadAdjacent(items, currentIndex);
+
+ if (items[currentIndex].type === 'video') {
+ const recipeVideo = document.getElementById('recipeModalVideo');
+ if (recipeVideo && !recipeVideo.paused) {
+ recipeVideo.pause();
+ }
+ }
+}
+
+export function closeMediaViewer() {
+ if (!activeViewer) return;
+
+ const { overlay, keyHandler } = activeViewer;
+
+ const video = overlay.querySelector('video');
+ if (video) {
+ video.pause();
+ video.src = '';
+ }
+
+ const img = overlay.querySelector('img');
+ if (img) {
+ img.src = '';
+ }
+
+ document.removeEventListener('keydown', keyHandler, true);
+
+ overlay.classList.remove('active');
+ overlay.addEventListener('transitionend', () => {
+ if (overlay.parentNode) {
+ overlay.parentNode.removeChild(overlay);
+ }
+ }, { once: true });
+
+ setTimeout(() => {
+ if (overlay.parentNode) {
+ overlay.parentNode.removeChild(overlay);
+ }
+ }, 500);
+
+ activeViewer = null;
+}
+
+export function isMediaViewerOpen() {
+ return activeViewer !== null;
+}
diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js
index 609673ef..e42d7a6f 100644
--- a/static/js/components/shared/showcase/ShowcaseView.js
+++ b/static/js/components/shared/showcase/ShowcaseView.js
@@ -17,6 +17,7 @@ import {
import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl } from '../../../utils/civitaiUtils.js';
+import { openMediaViewer } from '../MediaViewer.js';
export const showcaseListenerMetrics = {
wheelListeners: 0,
@@ -640,6 +641,27 @@ export function initShowcaseContent(carousel) {
initMediaControlHandlers(carousel);
positionAllMediaControls(carousel);
+ // Click-to-view: open full-size media viewer when clicking showcase images/videos
+ const viewerElements = carousel.querySelectorAll('.media-wrapper img, .media-wrapper video');
+ const allItems = [];
+ const elementIndexMap = new Map();
+ viewerElements.forEach((el) => {
+ const isVideo = el.tagName === 'VIDEO';
+ const url = el.src || el.dataset.localSrc || el.dataset.remoteSrc;
+ if (url) {
+ elementIndexMap.set(el, allItems.length);
+ allItems.push({ url, type: isVideo ? 'video' : 'image' });
+ }
+ });
+ viewerElements.forEach((mediaEl) => {
+ const idx = elementIndexMap.get(mediaEl);
+ if (idx === undefined) return;
+ mediaEl.addEventListener('click', (e) => {
+ e.stopPropagation();
+ openMediaViewer(allItems, idx);
+ });
+ });
+
// Bind scroll-indicator click events
bindScrollIndicatorEvents(carousel);