Compare commits

...

1 Commits

Author SHA1 Message Date
Will Miao
68c5f79a67 Refactor showcase and modal components for improved functionality and performance
- Removed unused showcase toggle functionality from ModelCard and ModelModal.
- Simplified metadata panel handling in MediaUtils and MetadataPanel, transitioning to button-based visibility instead of hover.
- Enhanced showcase rendering logic in ShowcaseView to support new layout and navigation features.
- Updated event handling for media controls and thumbnail navigation to streamline user interactions.
- Improved example image import functionality and error handling.
- Cleaned up redundant code and comments across various components for better readability and maintainability.
2025-07-27 15:52:09 +08:00
7 changed files with 710 additions and 808 deletions

View File

@@ -123,42 +123,6 @@
} }
} }
/* 修改 back-to-top 按钮样式,使其固定在 modal 内部 */
.modal-content .back-to-top {
position: sticky; /* 改用 sticky 定位 */
float: right; /* 使用 float 确保按钮在右侧 */
bottom: 20px; /* 距离底部的距离 */
margin-right: 20px; /* 右侧间距 */
margin-top: -56px; /* 负边距确保不占用额外空间 */
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--card-bg);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
visibility: hidden;
transform: translateY(10px);
transition: all 0.3s ease;
z-index: 10;
}
.modal-content .back-to-top.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.modal-content .back-to-top:hover {
background: var(--lora-accent);
color: white;
transform: translateY(-2px);
}
/* File name copy styles */ /* File name copy styles */
.file-name-wrapper { .file-name-wrapper {
display: flex; display: flex;

View File

@@ -4,31 +4,254 @@
margin-top: var(--space-4); margin-top: var(--space-4);
} }
.carousel { /* Main showcase container */
transition: max-height 0.3s ease-in-out; .showcase-container {
display: flex;
height: 750px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
overflow: hidden; overflow: hidden;
background: var(--lora-surface);
} }
.carousel.collapsed { .showcase-container.empty {
max-height: 0; height: 400px;
} }
.carousel-container { /* Thumbnail Sidebar */
.thumbnail-sidebar {
width: 200px;
background: var(--bg-color);
border-right: 1px solid var(--border-color);
display: flex;
flex-direction: column;
}
.thumbnail-grid {
flex: 1;
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
padding: var(--space-2);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
} }
.thumbnail-grid::-webkit-scrollbar {
display: none; /* WebKit */
}
.thumbnail-item {
position: relative;
aspect-ratio: 1;
border-radius: var(--border-radius-xs);
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s ease;
background: var(--lora-surface);
}
.thumbnail-item:hover {
border-color: var(--lora-accent);
transform: scale(1.02);
}
.thumbnail-item.active {
border-color: var(--lora-accent);
box-shadow: 0 0 0 1px var(--lora-accent);
}
.thumbnail-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.thumbnail-media.blurred {
filter: blur(8px);
}
.video-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7em;
pointer-events: none;
}
.thumbnail-nsfw-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.2em;
}
/* Import Section */
.import-section {
padding: var(--space-2);
border-top: 1px solid var(--border-color);
background: var(--bg-color);
}
.select-files-btn {
width: 100%;
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
padding: var(--space-2);
cursor: pointer;
font-size: 0.9em;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
margin-bottom: var(--space-2);
}
.select-files-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.import-drop-zone {
border: 2px dashed var(--border-color);
border-radius: var(--border-radius-xs);
padding: var(--space-2);
text-align: center;
transition: all 0.3s ease;
background: var(--lora-surface);
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.import-drop-zone.highlight {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
}
.drop-zone-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: var(--text-color);
opacity: 0.6;
font-size: 0.8em;
}
.drop-zone-content i {
font-size: 1.2em;
margin-bottom: 2px;
}
/* Main Display Area */
.main-display-area {
flex: 1;
position: relative;
background: var(--card-bg);
overflow: hidden;
}
.main-display-area.empty {
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
text-align: center;
color: var(--text-color);
opacity: 0.6;
}
.empty-state i {
font-size: 3em;
margin-bottom: var(--space-2);
opacity: 0.5;
}
.empty-state h3 {
margin: 0 0 var(--space-1);
font-weight: 500;
}
.empty-state p {
margin: 0;
font-size: 0.9em;
}
.navigation-controls {
position: absolute;
top: var(--space-2);
right: var(--space-2);
display: flex;
gap: 6px;
z-index: 10;
}
.nav-btn {
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
opacity: 0.8;
}
.nav-btn:hover {
opacity: 1;
transform: translateY(-1px);
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.15);
}
.nav-btn.info-btn.active {
background: var(--lora-accent);
color: var(--lora-text);
border-color: var(--lora-accent);
}
.main-media-container {
position: relative;
width: 100%;
height: 100%;
}
.media-wrapper { .media-wrapper {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%;
background: var(--lora-surface); background: var(--lora-surface);
margin-bottom: var(--space-2); overflow: hidden;
overflow: hidden; /* Ensure metadata panel is contained */
}
.media-wrapper:last-child {
margin-bottom: 0;
} }
.media-wrapper img, .media-wrapper img,
@@ -41,50 +264,11 @@
object-fit: contain; object-fit: contain;
} }
.no-examples { /* Media Controls for main display */
text-align: center;
padding: var(--space-3);
color: var(--text-color);
opacity: 0.7;
}
/* Adjust the media wrapper for tab system */
#showcase-tab .carousel-container {
margin-top: var(--space-2);
}
/* Add styles for blurred showcase content */
.nsfw-media-wrapper {
position: relative;
}
.media-wrapper img.blurred,
.media-wrapper video.blurred {
filter: blur(25px);
}
.media-wrapper .nsfw-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
pointer-events: none;
}
/* Position the toggle button at the top left of showcase media */
.showcase-toggle-btn {
position: absolute;
z-index: 3;
}
/* Add styles for showcase media controls */
.media-controls { .media-controls {
position: absolute; position: absolute;
top: var(--space-2);
left: var(--space-2);
display: flex; display: flex;
gap: 6px; gap: 6px;
z-index: 4; z-index: 4;
@@ -94,15 +278,15 @@
pointer-events: none; pointer-events: none;
} }
.media-controls.visible { .media-wrapper:hover .media-controls {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
pointer-events: auto; pointer-events: auto;
} }
.media-control-btn { .media-control-btn {
width: 28px; width: 32px;
height: 28px; height: 32px;
border-radius: 50%; border-radius: 50%;
background: var(--bg-color); background: var(--bg-color);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -135,13 +319,11 @@
border-color: var(--lora-error); border-color: var(--lora-error);
} }
/* Disabled state for delete button */
.media-control-btn.example-delete-btn.disabled { .media-control-btn.example-delete-btn.disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* Two-step confirmation for delete button */
.media-control-btn.example-delete-btn .confirm-icon { .media-control-btn.example-delete-btn .confirm-icon {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -172,16 +354,29 @@
border-color: var(--lora-error); border-color: var(--lora-error);
} }
@keyframes pulse { /* Toggle blur button for main display */
0% { .showcase-toggle-btn {
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7); position: absolute;
} top: calc(var(--space-2) + 44px);
70% { left: var(--space-2);
box-shadow: 0 0 0 5px rgba(220, 53, 69, 0); z-index: 3;
} width: 32px;
100% { height: 32px;
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); border-radius: 50%;
} background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
opacity: 0;
}
.media-wrapper:hover .showcase-toggle-btn {
opacity: 1;
} }
/* Image Metadata Panel Styles */ /* Image Metadata Panel Styles */
@@ -195,22 +390,20 @@
padding: var(--space-2); padding: var(--space-2);
transform: translateY(100%); transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease; transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease;
z-index: 5; z-index: 15;
max-height: 50%; /* Reduced to take less space */ max-height: 50%;
overflow-y: auto; overflow-y: auto;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
/* Show metadata panel only when the 'visible' class is added */ .image-metadata-panel.visible {
.media-wrapper .image-metadata-panel.visible {
transform: translateY(0); transform: translateY(0);
opacity: 0.98; opacity: 0.98;
pointer-events: auto; pointer-events: auto;
} }
/* Adjust to dark theme */
[data-theme="dark"] .image-metadata-panel { [data-theme="dark"] .image-metadata-panel {
background: var(--card-bg); background: var(--card-bg);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3); box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
@@ -222,7 +415,6 @@
gap: 10px; gap: 10px;
} }
/* Styling for parameters tags */
.params-tags { .params-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -255,7 +447,6 @@
color: var(--lora-accent); color: var(--lora-accent);
} }
/* Special styling for prompt row */
.metadata-row.prompt-row { .metadata-row.prompt-row {
flex-direction: column; flex-direction: column;
padding-top: 0; padding-top: 0;
@@ -281,7 +472,7 @@
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
padding: 6px 30px 6px 8px; padding: 6px 30px 6px 8px;
margin-top: 2px; margin-top: 2px;
max-height: 80px; /* Reduced from 120px */ max-height: 80px;
overflow-y: auto; overflow-y: auto;
word-break: break-word; word-break: break-word;
width: 100%; width: 100%;
@@ -313,27 +504,6 @@
color: var(--lora-accent); color: var(--lora-accent);
} }
/* Scrollbar styling for metadata panel */
.image-metadata-panel::-webkit-scrollbar {
width: 6px;
}
.image-metadata-panel::-webkit-scrollbar-track {
background: transparent;
}
.image-metadata-panel::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
/* For Firefox */
.image-metadata-panel {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
/* No metadata message styling */
.no-metadata-message { .no-metadata-message {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -352,31 +522,66 @@
opacity: 0.8; opacity: 0.8;
} }
/* Scroll Indicator */ /* Scrollbar styling for metadata panel */
.scroll-indicator { .image-metadata-panel::-webkit-scrollbar {
cursor: pointer; width: 6px;
padding: var(--space-2); }
background: var(--lora-surface);
border: 1px solid var(--lora-border); .image-metadata-panel::-webkit-scrollbar-track {
border-radius: var(--border-radius-sm); background: transparent;
}
.image-metadata-panel::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
.image-metadata-panel {
scrollbar-width: thin;
scrollbar-color: var(--border-color) transparent;
}
/* NSFW Content Styles */
.media-wrapper img.blurred,
.media-wrapper video.blurred {
filter: blur(25px);
}
.media-wrapper .nsfw-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; z-index: 2;
pointer-events: none;
}
/* NSFW Filter Notification */
.nsfw-filter-notification {
background: var(--lora-warning);
color: var(--lora-text);
padding: var(--space-2);
border-radius: var(--border-radius-xs);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
transition: background-color 0.2s, transform 0.2s; display: flex;
} align-items: center;
gap: 8px;
.scroll-indicator:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: translateY(-1px);
}
.scroll-indicator span {
font-size: 0.9em; font-size: 0.9em;
color: var(--text-color);
} }
/* No examples message */
.no-examples {
text-align: center;
padding: var(--space-4);
color: var(--text-color);
opacity: 0.7;
}
/* Lazy loading */
.lazy { .lazy {
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
@@ -386,93 +591,24 @@
opacity: 1; opacity: 1;
} }
/* Example Import Area */
.example-import-area {
margin-top: var(--space-4);
padding: var(--space-2);
}
.example-import-area.empty {
margin-top: var(--space-2);
padding: var(--space-4) var(--space-2);
}
.import-container {
border: 2px dashed var(--border-color);
border-radius: var(--border-radius-sm);
padding: var(--space-4);
text-align: center;
transition: all 0.3s ease;
background: var(--lora-surface);
cursor: pointer;
}
.import-container.highlight {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: scale(1.01);
}
.import-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
padding-top: var(--space-1);
}
.import-placeholder i {
font-size: 2.5rem;
/* color: var(--lora-accent); */
opacity: 0.8;
margin-bottom: var(--space-1);
}
.import-placeholder h3 {
margin: 0 0 var(--space-1);
font-size: 1.2rem;
font-weight: 500;
color: var(--text-color);
}
.import-placeholder p {
margin: var(--space-1) 0;
color: var(--text-color);
opacity: 0.8;
}
.import-placeholder .sub-text {
font-size: 0.9em;
opacity: 0.6;
margin: var(--space-1) 0;
}
.import-formats {
font-size: 0.8em !important;
opacity: 0.6 !important;
margin-top: var(--space-2) !important;
}
.select-files-btn {
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
padding: var(--space-2) var(--space-3);
cursor: pointer;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.select-files-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* For dark theme */ /* For dark theme */
[data-theme="dark"] .import-container { [data-theme="dark"] .import-drop-zone {
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
} }
/* Responsive design for smaller screens */
@media (max-width: 768px) {
.thumbnail-sidebar {
width: 160px;
}
.navigation-controls {
top: var(--space-1);
right: var(--space-1);
}
.nav-btn {
width: 32px;
height: 32px;
}
}

View File

@@ -1,7 +1,6 @@
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js'; import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js'; import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js'; import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS } from '../../utils/constants.js'; import { NSFW_LEVELS } from '../../utils/constants.js';
@@ -340,15 +339,6 @@ function showExampleAccessModal(card, modelType) {
tabBtn.click(); tabBtn.click();
} }
// Then toggle showcase if collapsed
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && carousel.classList.contains('collapsed')) {
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
if (scrollIndicator) {
toggleShowcase(scrollIndicator);
}
}
// Finally scroll to the import area // Finally scroll to the import area
importArea.scrollIntoView({ behavior: 'smooth' }); importArea.scrollIntoView({ behavior: 'smooth' });
} }

View File

@@ -1,9 +1,6 @@
import { showToast, openCivitai } from '../../utils/uiHelpers.js'; import { showToast, openCivitai } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop,
loadExampleImages loadExampleImages
} from './showcase/ShowcaseView.js'; } from './showcase/ShowcaseView.js';
import { setupTabSwitching } from './ModelDescription.js'; import { setupTabSwitching } from './ModelDescription.js';
@@ -33,7 +30,6 @@ export function showModelModal(model, modelType) {
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
// Generate model type specific content // Generate model type specific content
// const typeSpecificContent = modelType === 'loras' ? renderLoraSpecificContent(model, escapedWords) : '';
let typeSpecificContent; let typeSpecificContent;
if (modelType === 'loras') { if (modelType === 'loras') {
typeSpecificContent = renderLoraSpecificContent(model, escapedWords); typeSpecificContent = renderLoraSpecificContent(model, escapedWords);
@@ -184,17 +180,12 @@ export function showModelModal(model, modelType) {
<div class="tab-content"> <div class="tab-content">
${tabPanesContent} ${tabPanesContent}
</div> </div>
<button class="back-to-top" data-action="scroll-to-top">
<i class="fas fa-arrow-up"></i>
</button>
</div> </div>
</div> </div>
</div> </div>
`; `;
const onCloseCallback = function() { const onCloseCallback = function() {
// Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId); const modalElement = document.getElementById(modalId);
if (modalElement && modalElement._clickHandler) { if (modalElement && modalElement._clickHandler) {
modalElement.removeEventListener('click', modalElement._clickHandler); modalElement.removeEventListener('click', modalElement._clickHandler);
@@ -204,7 +195,6 @@ export function showModelModal(model, modelType) {
modalManager.showModal(modalId, content, null, onCloseCallback); modalManager.showModal(modalId, content, null, onCloseCallback);
setupEditableFields(model.file_path, modelType); setupEditableFields(model.file_path, modelType);
setupShowcaseScroll(modalId);
setupTabSwitching(); setupTabSwitching();
setupTagTooltip(); setupTagTooltip();
setupTagEditMode(); setupTagEditMode();
@@ -223,10 +213,9 @@ export function showModelModal(model, modelType) {
} }
} }
// Load example images asynchronously - merge regular and custom images // Load example images asynchronously
const regularImages = model.civitai?.images || []; const regularImages = model.civitai?.images || [];
const customImages = model.civitai?.customImages || []; const customImages = model.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages]; const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, model.sha256); loadExampleImages(allImages, model.sha256);
} }
@@ -261,16 +250,14 @@ function renderEmbeddingSpecificContent(embedding, escapedWords) {
} }
/** /**
* Sets up event handlers using event delegation for LoRA modal * Sets up event handlers using event delegation for modal
* @param {string} filePath - Path to the model file * @param {string} filePath - Path to the model file
*/ */
function setupEventHandlers(filePath) { function setupEventHandlers(filePath) {
const modalElement = document.getElementById('modelModal'); const modalElement = document.getElementById('modelModal');
// Remove existing event listeners first
modalElement.removeEventListener('click', handleModalClick); modalElement.removeEventListener('click', handleModalClick);
// Create and store the handler function
function handleModalClick(event) { function handleModalClick(event) {
const target = event.target.closest('[data-action]'); const target = event.target.closest('[data-action]');
if (!target) return; if (!target) return;
@@ -281,9 +268,6 @@ function setupEventHandlers(filePath) {
case 'close-modal': case 'close-modal':
modalManager.closeModal('modelModal'); modalManager.closeModal('modelModal');
break; break;
case 'scroll-to-top':
scrollToTop(target);
break;
case 'view-civitai': case 'view-civitai':
openCivitai(target.dataset.filepath); openCivitai(target.dataset.filepath);
break; break;
@@ -296,10 +280,7 @@ function setupEventHandlers(filePath) {
} }
} }
// Add the event listener with the named function
modalElement.addEventListener('click', handleModalClick); modalElement.addEventListener('click', handleModalClick);
// Store reference to the handler on the element for potential cleanup
modalElement._clickHandler = handleModalClick; modalElement._clickHandler = handleModalClick;
} }
@@ -421,9 +402,7 @@ async function saveNotes(filePath) {
// Export the model modal API // Export the model modal API
const modelModal = { const modelModal = {
show: showModelModal, show: showModelModal
toggleShowcase,
scrollToTop
}; };
export { modelModal }; export { modelModal };

View File

@@ -182,119 +182,46 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
* @param {HTMLElement} container - Container element with media wrappers * @param {HTMLElement} container - Container element with media wrappers
*/ */
export function initMetadataPanelHandlers(container) { export function initMetadataPanelHandlers(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper'); // Metadata panel interaction is now handled by the info button
// Keep the existing copy functionality but remove hover-based visibility
const metadataPanel = container.querySelector('.image-metadata-panel');
mediaWrappers.forEach(wrapper => { if (metadataPanel) {
// Get the metadata panel and media element (img or video) // Prevent events from bubbling
const metadataPanel = wrapper.querySelector('.image-metadata-panel'); metadataPanel.addEventListener('click', (e) => {
const mediaControls = wrapper.querySelector('.media-controls'); e.stopPropagation();
const mediaElement = wrapper.querySelector('img, video');
if (!mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel and controls when over media content or metadata panel itself
if (isOverMedia || isOverMetadataPanel) {
if (metadataPanel) metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
} else {
if (metadataPanel) metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
}); });
wrapper.addEventListener('mouseleave', () => { // Handle copy prompt buttons
if (!isOverMetadataPanel) { const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
if (metadataPanel) metadataPanel.classList.remove('visible'); copyBtns.forEach(copyBtn => {
if (mediaControls) mediaControls.classList.remove('visible'); const promptIndex = copyBtn.dataset.promptIndex;
} const promptElement = container.querySelector(`#prompt-${promptIndex}`);
});
// Add mouse enter/leave events for the metadata panel itself copyBtn.addEventListener('click', async (e) => {
if (metadataPanel) {
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
});
metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false;
// Only hide if mouse is not over the media
const rect = wrapper.getBoundingClientRect();
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (!isOverMedia) {
metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
// Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
});
// Handle copy prompt buttons if (!promptElement) return;
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => { try {
e.stopPropagation(); await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
if (!promptElement) return; console.error('Copy failed:', err);
showToast('Copy failed', 'error');
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
}
});
});
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Only prevent default if scrolling would cause the panel to scroll
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
} }
}, { passive: true }); });
} });
});
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
}
}, { passive: true });
}
} }
/** /**
@@ -366,9 +293,8 @@ export function initMediaControlHandlers(container) {
btn.addEventListener('click', async function(e) { btn.addEventListener('click', async function(e) {
e.stopPropagation(); e.stopPropagation();
// Explicitly check for disabled state
if (this.classList.contains('disabled')) { if (this.classList.contains('disabled')) {
return; // Don't do anything if button is disabled return;
} }
const shortId = this.dataset.shortId; const shortId = this.dataset.shortId;
@@ -376,14 +302,11 @@ export function initMediaControlHandlers(container) {
if (!shortId) return; if (!shortId) return;
// Handle two-step confirmation
if (btnState === 'initial') { if (btnState === 'initial') {
// First click: show confirmation state
this.dataset.state = 'confirm'; this.dataset.state = 'confirm';
this.classList.add('confirm'); this.classList.add('confirm');
this.title = 'Click again to confirm deletion'; this.title = 'Click again to confirm deletion';
// Auto-reset after 3 seconds
setTimeout(() => { setTimeout(() => {
if (this.dataset.state === 'confirm') { if (this.dataset.state === 'confirm') {
this.dataset.state = 'initial'; this.dataset.state = 'initial';
@@ -395,19 +318,16 @@ export function initMediaControlHandlers(container) {
return; return;
} }
// Second click within 3 seconds: proceed with deletion
if (btnState === 'confirm') { if (btnState === 'confirm') {
this.disabled = true; this.disabled = true;
this.classList.remove('confirm'); this.classList.remove('confirm');
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>'; this.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
// Get model hash from URL or data attribute
const mediaWrapper = this.closest('.media-wrapper'); const mediaWrapper = this.closest('.media-wrapper');
const modelHashAttr = document.querySelector('.showcase-section')?.dataset; const modelHashAttr = document.querySelector('.showcase-section')?.dataset;
const modelHash = modelHashAttr?.modelHash; const modelHash = modelHashAttr?.modelHash;
try { try {
// Call the API to delete the custom example
const response = await fetch('/api/delete-example-image', { const response = await fetch('/api/delete-example-image', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -422,32 +342,45 @@ export function initMediaControlHandlers(container) {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// Success: remove the media wrapper from the DOM // Remove the corresponding thumbnail and update main display
mediaWrapper.style.opacity = '0'; const thumbnailItem = container.querySelector(`.thumbnail-item[data-short-id="${shortId}"]`);
mediaWrapper.style.height = '0'; if (thumbnailItem) {
mediaWrapper.style.transition = 'opacity 0.3s ease, height 0.3s ease 0.3s'; const wasActive = thumbnailItem.classList.contains('active');
thumbnailItem.remove();
setTimeout(() => { // If the deleted item was active, select next item
mediaWrapper.remove(); if (wasActive) {
}, 600); const remainingThumbnails = container.querySelectorAll('.thumbnail-item');
if (remainingThumbnails.length > 0) {
remainingThumbnails[0].click();
} else {
// No more items, show empty state
const mainContainer = container.querySelector('#mainMediaContainer');
if (mainContainer) {
mainContainer.innerHTML = `
<div class="empty-state">
<i class="fas fa-images"></i>
<h3>No example images available</h3>
<p>Import images or videos using the sidebar</p>
</div>
`;
}
}
}
}
// Show success toast
showToast('Example image deleted', 'success'); showToast('Example image deleted', 'success');
// Create an update object with only the necessary properties
const updateData = { const updateData = {
civitai: { civitai: {
customImages: result.custom_images || [] customImages: result.custom_images || []
} }
}; };
// Update the item in the virtual scroller
state.virtualScroller.updateSingleItem(result.model_file_path, updateData); state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
} else { } else {
// Show error message
showToast(result.error || 'Failed to delete example image', 'error'); showToast(result.error || 'Failed to delete example image', 'error');
// Reset button state
this.disabled = false; this.disabled = false;
this.dataset.state = 'initial'; this.dataset.state = 'initial';
this.classList.remove('confirm'); this.classList.remove('confirm');
@@ -458,7 +391,6 @@ export function initMediaControlHandlers(container) {
console.error('Error deleting example image:', error); console.error('Error deleting example image:', error);
showToast('Failed to delete example image', 'error'); showToast('Failed to delete example image', 'error');
// Reset button state
this.disabled = false; this.disabled = false;
this.dataset.state = 'initial'; this.dataset.state = 'initial';
this.classList.remove('confirm'); this.classList.remove('confirm');
@@ -469,11 +401,7 @@ export function initMediaControlHandlers(container) {
}); });
}); });
// Initialize set preview buttons
initSetPreviewHandlers(container); initSetPreviewHandlers(container);
// Media control visibility is now handled in initMetadataPanelHandlers
// Any click handlers or other functionality can still be added here
} }
/** /**
@@ -545,49 +473,3 @@ function initSetPreviewHandlers(container) {
}); });
}); });
} }
/**
* Position media controls within the actual rendered media rectangle
* @param {HTMLElement} mediaWrapper - The wrapper containing the media and controls
*/
export function positionMediaControlsInMediaRect(mediaWrapper) {
const mediaElement = mediaWrapper.querySelector('img, video');
const controlsElement = mediaWrapper.querySelector('.media-controls');
if (!mediaElement || !controlsElement) return;
// Get wrapper dimensions
const wrapperRect = mediaWrapper.getBoundingClientRect();
// Calculate the actual rendered media rectangle
const mediaRect = getRenderedMediaRect(
mediaElement,
wrapperRect.width,
wrapperRect.height
);
// Calculate the position for controls - place them inside the actual media area
const padding = 8; // Padding from the edge of the media
// Position at top-right inside the actual media rectangle
controlsElement.style.top = `${mediaRect.top + padding}px`;
controlsElement.style.right = `${wrapperRect.width - mediaRect.right + padding}px`;
// Also position any toggle blur buttons in the same way but on the left
const toggleBlurBtn = mediaWrapper.querySelector('.toggle-blur-btn');
if (toggleBlurBtn) {
toggleBlurBtn.style.top = `${mediaRect.top + padding}px`;
toggleBlurBtn.style.left = `${mediaRect.left + padding}px`;
}
}
/**
* Position all media controls in a container
* @param {HTMLElement} container - Container with media wrappers
*/
export function positionAllMediaControls(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
positionMediaControlsInMediaRect(wrapper);
});
}

View File

@@ -23,6 +23,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
const promptIndex = Math.random().toString(36).substring(2, 15); const promptIndex = Math.random().toString(36).substring(2, 15);
const negPromptIndex = Math.random().toString(36).substring(2, 15); const negPromptIndex = Math.random().toString(36).substring(2, 15);
// Note: Panel visibility is now controlled by the info button, not hover
let content = '<div class="image-metadata-panel"><div class="metadata-content">'; let content = '<div class="image-metadata-panel"><div class="metadata-content">';
if (hasParams) { if (hasParams) {

View File

@@ -9,8 +9,7 @@ import {
initLazyLoading, initLazyLoading,
initNsfwBlurHandlers, initNsfwBlurHandlers,
initMetadataPanelHandlers, initMetadataPanelHandlers,
initMediaControlHandlers, initMediaControlHandlers
positionAllMediaControls
} from './MediaUtils.js'; } from './MediaUtils.js';
import { generateMetadataPanel } from './MetadataPanel.js'; import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
@@ -46,13 +45,10 @@ export async function loadExampleImages(images, modelHash) {
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles); showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
// Re-initialize the showcase event listeners // Re-initialize the showcase event listeners
const carousel = showcaseTab.querySelector('.carousel'); initShowcaseContent(showcaseTab);
if (carousel && !carousel.classList.contains('collapsed')) {
initShowcaseContent(carousel);
}
// Initialize the example import functionality // Initialize the example import functionality
initExampleImport(modelHash, showcaseTab); // initExampleImport(modelHash, showcaseTab);
} catch (error) { } catch (error) {
console.error('Error loading example images:', error); console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab'); const showcaseTab = document.getElementById('showcase-tab');
@@ -71,13 +67,13 @@ export async function loadExampleImages(images, modelHash) {
* Render showcase content * Render showcase content
* @param {Array} images - Array of images/videos to show * @param {Array} images - Array of images/videos to show
* @param {Array} exampleFiles - Local example files * @param {Array} exampleFiles - Local example files
* @param {boolean} startExpanded - Whether to start in expanded state * @param {boolean} startExpanded - Whether to start in expanded state (unused in new design)
* @returns {string} HTML content * @returns {string} HTML content
*/ */
export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) { export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) {
if (!images?.length) { if (!images?.length) {
// Show empty state with import interface // Show empty state with import interface
return renderImportInterface(true); return renderEmptyShowcase();
} }
// Filter images based on SFW setting // Filter images based on SFW setting
@@ -112,29 +108,69 @@ export function renderShowcaseContent(images, exampleFiles = [], startExpanded =
</div>` : ''; </div>` : '';
return ` return `
<div class="scroll-indicator"> ${hiddenNotification}
<i class="fas fa-chevron-${startExpanded ? 'up' : 'down'}"></i> <div class="showcase-container">
<span>Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples</span> <div class="thumbnail-sidebar" id="thumbnailSidebar">
</div> <div class="thumbnail-grid">
<div class="carousel ${startExpanded ? '' : 'collapsed'}"> ${filteredImages.map((img, index) => renderThumbnail(img, index, exampleFiles)).join('')}
${hiddenNotification} </div>
<div class="carousel-container"> ${renderImportInterface()}
${filteredImages.map((img, index) => renderMediaItem(img, index, exampleFiles)).join('')} </div>
<div class="main-display-area">
<div class="navigation-controls">
<button class="nav-btn prev-btn" id="prevBtn" title="Previous (←)">
<i class="fas fa-chevron-left"></i>
</button>
<button class="nav-btn next-btn" id="nextBtn" title="Next (→)">
<i class="fas fa-chevron-right"></i>
</button>
<button class="nav-btn info-btn" id="infoBtn" title="Show/Hide Info (i)">
<i class="fas fa-info-circle"></i>
</button>
</div>
<div class="main-media-container" id="mainMediaContainer">
${filteredImages.length > 0 ? renderMainMediaItem(filteredImages[0], 0, exampleFiles) : ''}
</div>
</div> </div>
${renderImportInterface(false)}
</div> </div>
`; `;
} }
/** /**
* Render a single media item (image or video) * Find the matching local file for an image
* @param {Object} img - Image metadata
* @param {number} index - Image index
* @param {Array} exampleFiles - Array of local files
* @returns {Object|null} Matching local file or null
*/
function findLocalFile(img, index, exampleFiles) {
if (!exampleFiles || exampleFiles.length === 0) return null;
let localFile = null;
if (img.id) {
// This is a custom image, find by custom_<id>
const customPrefix = `custom_${img.id}`;
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
} else {
// This is a regular image from civitai, find by index
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
}
return localFile;
}
/**
* Render a thumbnail for the sidebar
* @param {Object} img - Image/video metadata * @param {Object} img - Image/video metadata
* @param {number} index - Index in the array * @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files * @param {Array} exampleFiles - Local files
* @returns {string} HTML for the media item * @returns {string} HTML for the thumbnail
*/ */
function renderMediaItem(img, index, exampleFiles) { function renderThumbnail(img, index, exampleFiles) {
// Find matching file in our list of actual files // Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles); let localFile = findLocalFile(img, index, exampleFiles);
@@ -143,15 +179,57 @@ function renderMediaItem(img, index, exampleFiles) {
const isVideo = localFile ? localFile.is_video : const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm'); remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// Calculate appropriate aspect ratio // Check if media should be blurred
const aspectRatio = (img.height / img.width) * 100; const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const containerWidth = 800; // modal content maximum width const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
const minHeightPercent = 40;
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100; return `
const heightPercent = Math.max( <div class="thumbnail-item ${index === 0 ? 'active' : ''}"
minHeightPercent, data-index="${index}"
Math.min(maxHeightPercent, aspectRatio) data-nsfw-level="${nsfwLevel}"
); data-short-id="${img.id || ''}">
${isVideo ? `
<video class="thumbnail-media lazy ${shouldBlur ? 'blurred' : ''}"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
muted>
<source data-local-src="${localUrl || ''}" data-remote-src="${remoteUrl}" type="video/mp4">
</video>
<div class="video-indicator">
<i class="fas fa-play"></i>
</div>
` : `
<img class="thumbnail-media lazy ${shouldBlur ? 'blurred' : ''}"
data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
alt="Thumbnail"
width="${img.width}"
height="${img.height}">
`}
${shouldBlur ? `
<div class="thumbnail-nsfw-overlay">
<i class="fas fa-eye-slash"></i>
</div>
` : ''}
</div>
`;
}
/**
* Render the main media item in the display area
* @param {Object} img - Image/video metadata
* @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files
* @returns {string} HTML for the main media item
*/
function renderMainMediaItem(img, index, exampleFiles) {
// Find matching file in our list of actual files
let localFile = findLocalFile(img, index, exampleFiles);
const remoteUrl = img.url || '';
const localUrl = localFile ? localFile.path : '';
const isVideo = localFile ? localFile.is_video :
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
// Check if media should be blurred // Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
@@ -212,69 +290,35 @@ function renderMediaItem(img, index, exampleFiles) {
// Generate the appropriate wrapper based on media type // Generate the appropriate wrapper based on media type
if (isVideo) { if (isVideo) {
return generateVideoWrapper( return generateVideoWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel, img, 100, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml localUrl, remoteUrl, mediaControlsHtml
); );
} }
return generateImageWrapper( return generateImageWrapper(
img, heightPercent, shouldBlur, nsfwText, metadataPanel, img, 100, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml localUrl, remoteUrl, mediaControlsHtml
); );
} }
/** /**
* Find the matching local file for an image * Render empty showcase with import interface
* @param {Object} img - Image metadata * @returns {string} HTML content for empty showcase
* @param {number} index - Image index
* @param {Array} exampleFiles - Array of local files
* @returns {Object|null} Matching local file or null
*/ */
function findLocalFile(img, index, exampleFiles) { function renderEmptyShowcase() {
if (!exampleFiles || exampleFiles.length === 0) return null;
let localFile = null;
if (img.id) {
// This is a custom image, find by custom_<id>
const customPrefix = `custom_${img.id}`;
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
} else {
// This is a regular image from civitai, find by index
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
}
return localFile;
}
/**
* Render the import interface for example images
* @param {boolean} isEmpty - Whether there are no existing examples
* @returns {string} HTML content for import interface
*/
function renderImportInterface(isEmpty) {
return ` return `
<div class="example-import-area ${isEmpty ? 'empty' : ''}"> <div class="showcase-container empty">
<div class="import-container" id="exampleImportContainer"> <div class="thumbnail-sidebar" id="thumbnailSidebar">
<div class="import-placeholder"> <div class="thumbnail-grid">
<i class="fas fa-cloud-upload-alt"></i> <!-- Empty thumbnails grid -->
<h3>${isEmpty ? 'No example images available' : 'Add more examples'}</h3>
<p>Drag & drop images or videos here</p>
<p class="sub-text">or</p>
<button class="select-files-btn" id="selectExampleFilesBtn">
<i class="fas fa-folder-open"></i> Select Files
</button>
<p class="import-formats">Supported formats: jpg, png, gif, webp, mp4, webm</p>
</div> </div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;"> ${renderImportInterface()}
<div class="import-progress-container" style="display: none;"> </div>
<div class="import-progress"> <div class="main-display-area empty">
<div class="progress-bar"></div> <div class="empty-state">
</div> <i class="fas fa-images"></i>
<span class="progress-text">Importing files...</span> <h3>No example images available</h3>
<p>Import images or videos using the sidebar</p>
</div> </div>
</div> </div>
</div> </div>
@@ -282,310 +326,216 @@ function renderImportInterface(isEmpty) {
} }
/** /**
* Initialize the example import functionality * Render the import interface for example images
* @param {string} modelHash - The SHA256 hash of the model * @returns {string} HTML content for import interface
* @param {Element} container - The container element for the import area
*/ */
export function initExampleImport(modelHash, container) { function renderImportInterface() {
if (!container) return; return `
<div class="import-section">
const importContainer = container.querySelector('#exampleImportContainer'); <button class="select-files-btn" id="selectExampleFilesBtn">
const fileInput = container.querySelector('#exampleFilesInput'); <i class="fas fa-plus"></i>
const selectFilesBtn = container.querySelector('#selectExampleFilesBtn'); <span>Add Images</span>
</button>
// Set up file selection button <div class="import-drop-zone" id="importDropZone">
if (selectFilesBtn) { <div class="drop-zone-content">
selectFilesBtn.addEventListener('click', () => { <i class="fas fa-cloud-upload-alt"></i>
fileInput.click(); <span>Drop here</span>
}); </div>
} </div>
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
// Handle file selection </div>
if (fileInput) { `;
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleImportFiles(Array.from(e.target.files), modelHash, importContainer);
}
});
}
// Set up drag and drop
if (importContainer) {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
importContainer.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
// Highlight drop area on drag over
['dragenter', 'dragover'].forEach(eventName => {
importContainer.addEventListener(eventName, () => {
importContainer.classList.add('highlight');
}, false);
});
// Remove highlight on drag leave
['dragleave', 'drop'].forEach(eventName => {
importContainer.addEventListener(eventName, () => {
importContainer.classList.remove('highlight');
}, false);
});
// Handle dropped files
importContainer.addEventListener('drop', (e) => {
const files = Array.from(e.dataTransfer.files);
handleImportFiles(files, modelHash, importContainer);
}, false);
}
}
/**
* Handle the file import process
* @param {File[]} files - Array of files to import
* @param {string} modelHash - The SHA256 hash of the model
* @param {Element} importContainer - The container element for import UI
*/
async function handleImportFiles(files, modelHash, importContainer) {
// Filter for supported file types
const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const supportedVideos = ['.mp4', '.webm'];
const supportedExtensions = [...supportedImages, ...supportedVideos];
const validFiles = files.filter(file => {
const ext = '.' + file.name.split('.').pop().toLowerCase();
return supportedExtensions.includes(ext);
});
if (validFiles.length === 0) {
alert('No supported files selected. Please select image or video files.');
return;
}
try {
// Use FormData to upload files
const formData = new FormData();
formData.append('model_hash', modelHash);
validFiles.forEach(file => {
formData.append('files', file);
});
// Call API to import files
const response = await fetch('/api/import-example-images', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to import example files');
}
// Get updated local files
const updatedFilesResponse = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
const updatedFilesResult = await updatedFilesResponse.json();
if (!updatedFilesResult.success) {
throw new Error(updatedFilesResult.error || 'Failed to get updated file list');
}
// Re-render the showcase content
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
// Get the updated images from the result
const regularImages = result.regular_images || [];
const customImages = result.custom_images || [];
// Combine both arrays for rendering
const allImages = [...regularImages, ...customImages];
showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files, true);
// Re-initialize showcase functionality
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && !carousel.classList.contains('collapsed')) {
initShowcaseContent(carousel);
}
// Initialize the import UI for the new content
initExampleImport(modelHash, showcaseTab);
showToast('Example images imported successfully', 'success');
// Update VirtualScroller if available
if (state.virtualScroller && result.model_file_path) {
// Create an update object with only the necessary properties
const updateData = {
civitai: {
images: regularImages,
customImages: customImages
}
};
// Update the item in the virtual scroller
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
}
}
} catch (error) {
console.error('Error importing examples:', error);
showToast(`Failed to import example images: ${error.message}`, 'error');
}
}
/**
* Toggle showcase expansion
* @param {HTMLElement} element - The scroll indicator element
*/
export function toggleShowcase(element) {
const carousel = element.nextElementSibling;
const isCollapsed = carousel.classList.contains('collapsed');
const indicator = element.querySelector('span');
const icon = element.querySelector('i');
carousel.classList.toggle('collapsed');
if (isCollapsed) {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to hide examples`;
icon.classList.replace('fa-chevron-down', 'fa-chevron-up');
initShowcaseContent(carousel);
} else {
const count = carousel.querySelectorAll('.media-wrapper').length;
indicator.textContent = `Scroll or click to show ${count} examples`;
icon.classList.replace('fa-chevron-up', 'fa-chevron-down');
// Make sure any open metadata panels get closed
const carouselContainer = carousel.querySelector('.carousel-container');
if (carouselContainer) {
carouselContainer.style.height = '0';
setTimeout(() => {
carouselContainer.style.height = '';
}, 300);
}
}
} }
/** /**
* Initialize all showcase content interactions * Initialize all showcase content interactions
* @param {HTMLElement} carousel - The carousel element * @param {HTMLElement} showcase - The showcase element
*/ */
export function initShowcaseContent(carousel) { export function initShowcaseContent(showcase) {
if (!carousel) return; if (!showcase) return;
initLazyLoading(carousel); const container = showcase.querySelector('.showcase-container');
initNsfwBlurHandlers(carousel); if (!container) return;
initMetadataPanelHandlers(carousel);
initMediaControlHandlers(carousel);
positionAllMediaControls(carousel);
// Bind scroll-indicator click to toggleShowcase initLazyLoading(container);
const scrollIndicator = carousel.previousElementSibling; initNsfwBlurHandlers(container);
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) { initThumbnailNavigation(container);
// Remove previous click listeners to avoid duplicates initMainDisplayHandlers(container);
scrollIndicator.onclick = null; initMediaControlHandlers(container);
scrollIndicator.removeEventListener('click', scrollIndicator._toggleShowcaseHandler);
scrollIndicator._toggleShowcaseHandler = () => toggleShowcase(scrollIndicator);
scrollIndicator.addEventListener('click', scrollIndicator._toggleShowcaseHandler);
}
// Add window resize handler // Initialize keyboard navigation
const resizeHandler = () => positionAllMediaControls(carousel); initKeyboardNavigation(container);
window.removeEventListener('resize', resizeHandler);
window.addEventListener('resize', resizeHandler);
// Handle images loading which might change dimensions
const mediaElements = carousel.querySelectorAll('img, video');
mediaElements.forEach(media => {
media.addEventListener('load', () => positionAllMediaControls(carousel));
if (media.tagName === 'VIDEO') {
media.addEventListener('loadedmetadata', () => positionAllMediaControls(carousel));
}
});
} }
/** /**
* Scroll to top of modal content * Initialize thumbnail navigation
* @param {HTMLElement} button - Back to top button * @param {HTMLElement} container - The showcase container
*/ */
export function scrollToTop(button) { function initThumbnailNavigation(container) {
const modalContent = button.closest('.modal-content'); const thumbnails = container.querySelectorAll('.thumbnail-item');
if (modalContent) { const mainContainer = container.querySelector('#mainMediaContainer');
modalContent.scrollTo({
top: 0, if (!mainContainer) return;
behavior: 'smooth'
thumbnails.forEach((thumbnail, index) => {
thumbnail.addEventListener('click', () => {
// Update active thumbnail
thumbnails.forEach(t => t.classList.remove('active'));
thumbnail.classList.add('active');
// Get the corresponding image data and render main media
const showcaseSection = document.querySelector('.showcase-section');
const modelHash = showcaseSection?.dataset.modelHash;
// This would need access to the filtered images array
// For now, we'll trigger a re-render of the main display
updateMainDisplay(index, container);
}); });
});
}
/**
* Initialize main display handlers including navigation and info toggle
* @param {HTMLElement} container - The showcase container
*/
function initMainDisplayHandlers(container) {
const prevBtn = container.querySelector('#prevBtn');
const nextBtn = container.querySelector('#nextBtn');
const infoBtn = container.querySelector('#infoBtn');
if (prevBtn) {
prevBtn.addEventListener('click', () => navigateMedia(container, -1));
}
if (nextBtn) {
nextBtn.addEventListener('click', () => navigateMedia(container, 1));
}
if (infoBtn) {
infoBtn.addEventListener('click', () => toggleMetadataPanel(container));
}
// Initialize metadata panel toggle behavior
initMetadataPanelToggle(container);
}
/**
* Initialize keyboard navigation
* @param {HTMLElement} container - The showcase container
*/
function initKeyboardNavigation(container) {
document.addEventListener('keydown', (e) => {
// Only handle if showcase is visible and focused
if (!container.closest('.modal').classList.contains('show')) return;
switch(e.key) {
case 'ArrowLeft':
e.preventDefault();
navigateMedia(container, -1);
break;
case 'ArrowRight':
e.preventDefault();
navigateMedia(container, 1);
break;
case 'i':
case 'I':
e.preventDefault();
toggleMetadataPanel(container);
break;
}
});
}
/**
* Navigate to previous/next media item
* @param {HTMLElement} container - The showcase container
* @param {number} direction - -1 for previous, 1 for next
*/
function navigateMedia(container, direction) {
const thumbnails = container.querySelectorAll('.thumbnail-item');
const activeThumbnail = container.querySelector('.thumbnail-item.active');
if (!activeThumbnail || thumbnails.length === 0) return;
const currentIndex = Array.from(thumbnails).indexOf(activeThumbnail);
let newIndex = currentIndex + direction;
// Wrap around
if (newIndex < 0) newIndex = thumbnails.length - 1;
if (newIndex >= thumbnails.length) newIndex = 0;
// Click the new thumbnail to trigger the display update
thumbnails[newIndex].click();
}
/**
* Toggle metadata panel visibility
* @param {HTMLElement} container - The showcase container
*/
function toggleMetadataPanel(container) {
const metadataPanel = container.querySelector('.image-metadata-panel');
const infoBtn = container.querySelector('#infoBtn');
if (!metadataPanel || !infoBtn) return;
const isVisible = metadataPanel.classList.contains('visible');
if (isVisible) {
metadataPanel.classList.remove('visible');
infoBtn.classList.remove('active');
} else {
metadataPanel.classList.add('visible');
infoBtn.classList.add('active');
} }
} }
/** /**
* Set up showcase scroll functionality * Initialize metadata panel toggle behavior
* @param {string} modalId - ID of the modal element * @param {HTMLElement} container - The showcase container
*/ */
export function setupShowcaseScroll(modalId) { function initMetadataPanelToggle(container) {
// Listen for wheel events const metadataPanel = container.querySelector('.image-metadata-panel');
document.addEventListener('wheel', (event) => {
const modalContent = document.querySelector(`#${modalId} .modal-content`);
if (!modalContent) return;
const showcase = modalContent.querySelector('.showcase-section'); if (!metadataPanel) return;
if (!showcase) return;
const carousel = showcase.querySelector('.carousel'); // Handle copy prompt buttons
const scrollIndicator = showcase.querySelector('.scroll-indicator'); const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = container.querySelector(`#prompt-${promptIndex}`);
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) { copyBtn.addEventListener('click', async (e) => {
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100; e.stopPropagation();
if (isNearBottom) { if (!promptElement) return;
toggleShowcase(scrollIndicator);
event.preventDefault(); try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('Copy failed', 'error');
} }
} });
}, { passive: false });
// Use MutationObserver to set up back-to-top button when modal content is added
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length) {
const modal = document.getElementById(modalId);
if (modal && modal.querySelector('.modal-content')) {
setupBackToTopButton(modal.querySelector('.modal-content'));
}
}
}
}); });
observer.observe(document.body, { childList: true, subtree: true }); // Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Try to set up the button immediately in case the modal is already open if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
const modalContent = document.querySelector(`#${modalId} .modal-content`); e.stopPropagation();
if (modalContent) { }
setupBackToTopButton(modalContent); }, { passive: true });
}
} }
/** /**
* Set up back-to-top button * Update main display with new media item
* @param {HTMLElement} modalContent - Modal content element * @param {number} index - Index of the media to display
* @param {HTMLElement} container - The showcase container
*/ */
function setupBackToTopButton(modalContent) { function updateMainDisplay(index, container) {
// Remove any existing scroll listeners to avoid duplicates // This function would need to re-render the main display area
modalContent.onscroll = null; // Implementation depends on how the image data is stored and accessed
console.log('Update main display to index:', index);
// Add new scroll listener
modalContent.addEventListener('scroll', () => {
const backToTopBtn = modalContent.querySelector('.back-to-top');
if (backToTopBtn) {
if (modalContent.scrollTop > 300) {
backToTopBtn.classList.add('visible');
} else {
backToTopBtn.classList.remove('visible');
}
}
});
// Trigger a scroll event to check initial position
modalContent.dispatchEvent(new Event('scroll'));
} }