mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Compare commits
1 Commits
2dae4c1291
...
showcase
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c5f79a67 |
@@ -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-wrapper {
|
||||
display: flex;
|
||||
|
||||
@@ -4,31 +4,254 @@
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.carousel {
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
/* Main showcase container */
|
||||
.showcase-container {
|
||||
display: flex;
|
||||
height: 750px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.carousel.collapsed {
|
||||
max-height: 0;
|
||||
.showcase-container.empty {
|
||||
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;
|
||||
flex-direction: column;
|
||||
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 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--lora-surface);
|
||||
margin-bottom: var(--space-2);
|
||||
overflow: hidden; /* Ensure metadata panel is contained */
|
||||
}
|
||||
|
||||
.media-wrapper:last-child {
|
||||
margin-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.media-wrapper img,
|
||||
@@ -41,50 +264,11 @@
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.no-examples {
|
||||
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 for main display */
|
||||
.media-controls {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
left: var(--space-2);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
z-index: 4;
|
||||
@@ -94,15 +278,15 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.media-controls.visible {
|
||||
.media-wrapper:hover .media-controls {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.media-control-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -135,13 +319,11 @@
|
||||
border-color: var(--lora-error);
|
||||
}
|
||||
|
||||
/* Disabled state for delete button */
|
||||
.media-control-btn.example-delete-btn.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Two-step confirmation for delete button */
|
||||
.media-control-btn.example-delete-btn .confirm-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -172,16 +354,29 @@
|
||||
border-color: var(--lora-error);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 5px rgba(220, 53, 69, 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(220, 53, 69, 0);
|
||||
}
|
||||
/* Toggle blur button for main display */
|
||||
.showcase-toggle-btn {
|
||||
position: absolute;
|
||||
top: calc(var(--space-2) + 44px);
|
||||
left: var(--space-2);
|
||||
z-index: 3;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
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 */
|
||||
@@ -195,22 +390,20 @@
|
||||
padding: var(--space-2);
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.25s ease;
|
||||
z-index: 5;
|
||||
max-height: 50%; /* Reduced to take less space */
|
||||
z-index: 15;
|
||||
max-height: 50%;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Show metadata panel only when the 'visible' class is added */
|
||||
.media-wrapper .image-metadata-panel.visible {
|
||||
.image-metadata-panel.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 0.98;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Adjust to dark theme */
|
||||
[data-theme="dark"] .image-metadata-panel {
|
||||
background: var(--card-bg);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
@@ -222,7 +415,6 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Styling for parameters tags */
|
||||
.params-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -255,7 +447,6 @@
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Special styling for prompt row */
|
||||
.metadata-row.prompt-row {
|
||||
flex-direction: column;
|
||||
padding-top: 0;
|
||||
@@ -281,7 +472,7 @@
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 6px 30px 6px 8px;
|
||||
margin-top: 2px;
|
||||
max-height: 80px; /* Reduced from 120px */
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
width: 100%;
|
||||
@@ -313,27 +504,6 @@
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -352,31 +522,66 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Scroll Indicator */
|
||||
.scroll-indicator {
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: 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);
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
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 {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
@@ -386,93 +591,24 @@
|
||||
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 */
|
||||
[data-theme="dark"] .import-container {
|
||||
[data-theme="dark"] .import-drop-zone {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../../state/index.js';
|
||||
import { showModelModal } from './ModelModal.js';
|
||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
@@ -340,15 +339,6 @@ function showExampleAccessModal(card, modelType) {
|
||||
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
|
||||
importArea.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { showToast, openCivitai } from '../../utils/uiHelpers.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import {
|
||||
toggleShowcase,
|
||||
setupShowcaseScroll,
|
||||
scrollToTop,
|
||||
loadExampleImages
|
||||
} from './showcase/ShowcaseView.js';
|
||||
import { setupTabSwitching } from './ModelDescription.js';
|
||||
@@ -33,7 +30,6 @@ export function showModelModal(model, modelType) {
|
||||
model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : [];
|
||||
|
||||
// Generate model type specific content
|
||||
// const typeSpecificContent = modelType === 'loras' ? renderLoraSpecificContent(model, escapedWords) : '';
|
||||
let typeSpecificContent;
|
||||
if (modelType === 'loras') {
|
||||
typeSpecificContent = renderLoraSpecificContent(model, escapedWords);
|
||||
@@ -184,17 +180,12 @@ export function showModelModal(model, modelType) {
|
||||
<div class="tab-content">
|
||||
${tabPanesContent}
|
||||
</div>
|
||||
|
||||
<button class="back-to-top" data-action="scroll-to-top">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const onCloseCallback = function() {
|
||||
// Clean up all handlers when modal closes for LoRA
|
||||
const modalElement = document.getElementById(modalId);
|
||||
if (modalElement && modalElement._clickHandler) {
|
||||
modalElement.removeEventListener('click', modalElement._clickHandler);
|
||||
@@ -204,7 +195,6 @@ export function showModelModal(model, modelType) {
|
||||
|
||||
modalManager.showModal(modalId, content, null, onCloseCallback);
|
||||
setupEditableFields(model.file_path, modelType);
|
||||
setupShowcaseScroll(modalId);
|
||||
setupTabSwitching();
|
||||
setupTagTooltip();
|
||||
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 customImages = model.civitai?.customImages || [];
|
||||
// Combine images - regular images first, then custom images
|
||||
const allImages = [...regularImages, ...customImages];
|
||||
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
|
||||
*/
|
||||
function setupEventHandlers(filePath) {
|
||||
const modalElement = document.getElementById('modelModal');
|
||||
|
||||
// Remove existing event listeners first
|
||||
modalElement.removeEventListener('click', handleModalClick);
|
||||
|
||||
// Create and store the handler function
|
||||
function handleModalClick(event) {
|
||||
const target = event.target.closest('[data-action]');
|
||||
if (!target) return;
|
||||
@@ -281,9 +268,6 @@ function setupEventHandlers(filePath) {
|
||||
case 'close-modal':
|
||||
modalManager.closeModal('modelModal');
|
||||
break;
|
||||
case 'scroll-to-top':
|
||||
scrollToTop(target);
|
||||
break;
|
||||
case 'view-civitai':
|
||||
openCivitai(target.dataset.filepath);
|
||||
break;
|
||||
@@ -296,10 +280,7 @@ function setupEventHandlers(filePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Add the event listener with the named function
|
||||
modalElement.addEventListener('click', handleModalClick);
|
||||
|
||||
// Store reference to the handler on the element for potential cleanup
|
||||
modalElement._clickHandler = handleModalClick;
|
||||
}
|
||||
|
||||
@@ -421,9 +402,7 @@ async function saveNotes(filePath) {
|
||||
|
||||
// Export the model modal API
|
||||
const modelModal = {
|
||||
show: showModelModal,
|
||||
toggleShowcase,
|
||||
scrollToTop
|
||||
show: showModelModal
|
||||
};
|
||||
|
||||
export { modelModal };
|
||||
@@ -182,119 +182,46 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
|
||||
* @param {HTMLElement} container - Container element with media wrappers
|
||||
*/
|
||||
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 => {
|
||||
// Get the metadata panel and media element (img or video)
|
||||
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
||||
const mediaControls = wrapper.querySelector('.media-controls');
|
||||
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');
|
||||
}
|
||||
if (metadataPanel) {
|
||||
// Prevent events from bubbling
|
||||
metadataPanel.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
wrapper.addEventListener('mouseleave', () => {
|
||||
if (!isOverMetadataPanel) {
|
||||
if (metadataPanel) metadataPanel.classList.remove('visible');
|
||||
if (mediaControls) mediaControls.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Add mouse enter/leave events for the metadata panel itself
|
||||
if (metadataPanel) {
|
||||
metadataPanel.addEventListener('mouseenter', () => {
|
||||
isOverMetadataPanel = true;
|
||||
metadataPanel.classList.add('visible');
|
||||
if (mediaControls) mediaControls.classList.add('visible');
|
||||
});
|
||||
// Handle copy prompt buttons
|
||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
||||
copyBtns.forEach(copyBtn => {
|
||||
const promptIndex = copyBtn.dataset.promptIndex;
|
||||
const promptElement = container.querySelector(`#prompt-${promptIndex}`);
|
||||
|
||||
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) => {
|
||||
copyBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Handle copy prompt buttons
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!promptElement) return;
|
||||
|
||||
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;
|
||||
if (!promptElement) return;
|
||||
|
||||
// Only prevent default if scrolling would cause the panel to scroll
|
||||
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
}
|
||||
}, { 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) {
|
||||
e.stopPropagation();
|
||||
|
||||
// Explicitly check for disabled state
|
||||
if (this.classList.contains('disabled')) {
|
||||
return; // Don't do anything if button is disabled
|
||||
return;
|
||||
}
|
||||
|
||||
const shortId = this.dataset.shortId;
|
||||
@@ -376,14 +302,11 @@ export function initMediaControlHandlers(container) {
|
||||
|
||||
if (!shortId) return;
|
||||
|
||||
// Handle two-step confirmation
|
||||
if (btnState === 'initial') {
|
||||
// First click: show confirmation state
|
||||
this.dataset.state = 'confirm';
|
||||
this.classList.add('confirm');
|
||||
this.title = 'Click again to confirm deletion';
|
||||
|
||||
// Auto-reset after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (this.dataset.state === 'confirm') {
|
||||
this.dataset.state = 'initial';
|
||||
@@ -395,19 +318,16 @@ export function initMediaControlHandlers(container) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Second click within 3 seconds: proceed with deletion
|
||||
if (btnState === 'confirm') {
|
||||
this.disabled = true;
|
||||
this.classList.remove('confirm');
|
||||
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 modelHashAttr = document.querySelector('.showcase-section')?.dataset;
|
||||
const modelHash = modelHashAttr?.modelHash;
|
||||
|
||||
try {
|
||||
// Call the API to delete the custom example
|
||||
const response = await fetch('/api/delete-example-image', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -422,32 +342,45 @@ export function initMediaControlHandlers(container) {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Success: remove the media wrapper from the DOM
|
||||
mediaWrapper.style.opacity = '0';
|
||||
mediaWrapper.style.height = '0';
|
||||
mediaWrapper.style.transition = 'opacity 0.3s ease, height 0.3s ease 0.3s';
|
||||
// Remove the corresponding thumbnail and update main display
|
||||
const thumbnailItem = container.querySelector(`.thumbnail-item[data-short-id="${shortId}"]`);
|
||||
if (thumbnailItem) {
|
||||
const wasActive = thumbnailItem.classList.contains('active');
|
||||
thumbnailItem.remove();
|
||||
|
||||
// If the deleted item was active, select next item
|
||||
if (wasActive) {
|
||||
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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
mediaWrapper.remove();
|
||||
}, 600);
|
||||
|
||||
// Show success toast
|
||||
showToast('Example image deleted', 'success');
|
||||
|
||||
// Create an update object with only the necessary properties
|
||||
const updateData = {
|
||||
civitai: {
|
||||
customImages: result.custom_images || []
|
||||
}
|
||||
};
|
||||
|
||||
// Update the item in the virtual scroller
|
||||
state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
|
||||
} else {
|
||||
// Show error message
|
||||
showToast(result.error || 'Failed to delete example image', 'error');
|
||||
|
||||
// Reset button state
|
||||
this.disabled = false;
|
||||
this.dataset.state = 'initial';
|
||||
this.classList.remove('confirm');
|
||||
@@ -458,7 +391,6 @@ export function initMediaControlHandlers(container) {
|
||||
console.error('Error deleting example image:', error);
|
||||
showToast('Failed to delete example image', 'error');
|
||||
|
||||
// Reset button state
|
||||
this.disabled = false;
|
||||
this.dataset.state = 'initial';
|
||||
this.classList.remove('confirm');
|
||||
@@ -469,11 +401,7 @@ export function initMediaControlHandlers(container) {
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize set preview buttons
|
||||
initSetPreviewHandlers(container);
|
||||
|
||||
// Media control visibility is now handled in initMetadataPanelHandlers
|
||||
// Any click handlers or other functionality can still be added here
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -544,50 +472,4 @@ 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);
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePro
|
||||
const promptIndex = 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">';
|
||||
|
||||
if (hasParams) {
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
initLazyLoading,
|
||||
initNsfwBlurHandlers,
|
||||
initMetadataPanelHandlers,
|
||||
initMediaControlHandlers,
|
||||
positionAllMediaControls
|
||||
initMediaControlHandlers
|
||||
} from './MediaUtils.js';
|
||||
import { generateMetadataPanel } from './MetadataPanel.js';
|
||||
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
|
||||
@@ -46,13 +45,10 @@ export async function loadExampleImages(images, modelHash) {
|
||||
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles);
|
||||
|
||||
// Re-initialize the showcase event listeners
|
||||
const carousel = showcaseTab.querySelector('.carousel');
|
||||
if (carousel && !carousel.classList.contains('collapsed')) {
|
||||
initShowcaseContent(carousel);
|
||||
}
|
||||
initShowcaseContent(showcaseTab);
|
||||
|
||||
// Initialize the example import functionality
|
||||
initExampleImport(modelHash, showcaseTab);
|
||||
// initExampleImport(modelHash, showcaseTab);
|
||||
} catch (error) {
|
||||
console.error('Error loading example images:', error);
|
||||
const showcaseTab = document.getElementById('showcase-tab');
|
||||
@@ -71,13 +67,13 @@ export async function loadExampleImages(images, modelHash) {
|
||||
* Render showcase content
|
||||
* @param {Array} images - Array of images/videos to show
|
||||
* @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
|
||||
*/
|
||||
export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) {
|
||||
if (!images?.length) {
|
||||
// Show empty state with import interface
|
||||
return renderImportInterface(true);
|
||||
return renderEmptyShowcase();
|
||||
}
|
||||
|
||||
// Filter images based on SFW setting
|
||||
@@ -112,29 +108,69 @@ export function renderShowcaseContent(images, exampleFiles = [], startExpanded =
|
||||
</div>` : '';
|
||||
|
||||
return `
|
||||
<div class="scroll-indicator">
|
||||
<i class="fas fa-chevron-${startExpanded ? 'up' : 'down'}"></i>
|
||||
<span>Scroll or click to ${startExpanded ? 'hide' : 'show'} ${filteredImages.length} examples</span>
|
||||
</div>
|
||||
<div class="carousel ${startExpanded ? '' : 'collapsed'}">
|
||||
${hiddenNotification}
|
||||
<div class="carousel-container">
|
||||
${filteredImages.map((img, index) => renderMediaItem(img, index, exampleFiles)).join('')}
|
||||
${hiddenNotification}
|
||||
<div class="showcase-container">
|
||||
<div class="thumbnail-sidebar" id="thumbnailSidebar">
|
||||
<div class="thumbnail-grid">
|
||||
${filteredImages.map((img, index) => renderThumbnail(img, index, exampleFiles)).join('')}
|
||||
</div>
|
||||
${renderImportInterface()}
|
||||
</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>
|
||||
|
||||
${renderImportInterface(false)}
|
||||
</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 {number} index - Index in the array
|
||||
* @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
|
||||
let localFile = findLocalFile(img, index, exampleFiles);
|
||||
|
||||
@@ -143,15 +179,57 @@ function renderMediaItem(img, index, exampleFiles) {
|
||||
const isVideo = localFile ? localFile.is_video :
|
||||
remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm');
|
||||
|
||||
// Calculate appropriate aspect ratio
|
||||
const aspectRatio = (img.height / img.width) * 100;
|
||||
const containerWidth = 800; // modal content maximum width
|
||||
const minHeightPercent = 40;
|
||||
const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
|
||||
const heightPercent = Math.max(
|
||||
minHeightPercent,
|
||||
Math.min(maxHeightPercent, aspectRatio)
|
||||
);
|
||||
// Check if media should be blurred
|
||||
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
|
||||
const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
|
||||
return `
|
||||
<div class="thumbnail-item ${index === 0 ? 'active' : ''}"
|
||||
data-index="${index}"
|
||||
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
|
||||
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
|
||||
if (isVideo) {
|
||||
return generateVideoWrapper(
|
||||
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
|
||||
img, 100, shouldBlur, nsfwText, metadataPanel,
|
||||
localUrl, remoteUrl, mediaControlsHtml
|
||||
);
|
||||
}
|
||||
|
||||
return generateImageWrapper(
|
||||
img, heightPercent, shouldBlur, nsfwText, metadataPanel,
|
||||
img, 100, shouldBlur, nsfwText, metadataPanel,
|
||||
localUrl, remoteUrl, mediaControlsHtml
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* Render empty showcase with import interface
|
||||
* @returns {string} HTML content for empty showcase
|
||||
*/
|
||||
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 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) {
|
||||
function renderEmptyShowcase() {
|
||||
return `
|
||||
<div class="example-import-area ${isEmpty ? 'empty' : ''}">
|
||||
<div class="import-container" id="exampleImportContainer">
|
||||
<div class="import-placeholder">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<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 class="showcase-container empty">
|
||||
<div class="thumbnail-sidebar" id="thumbnailSidebar">
|
||||
<div class="thumbnail-grid">
|
||||
<!-- Empty thumbnails grid -->
|
||||
</div>
|
||||
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
|
||||
<div class="import-progress-container" style="display: none;">
|
||||
<div class="import-progress">
|
||||
<div class="progress-bar"></div>
|
||||
</div>
|
||||
<span class="progress-text">Importing files...</span>
|
||||
${renderImportInterface()}
|
||||
</div>
|
||||
<div class="main-display-area empty">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,310 +326,216 @@ function renderImportInterface(isEmpty) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the example import functionality
|
||||
* @param {string} modelHash - The SHA256 hash of the model
|
||||
* @param {Element} container - The container element for the import area
|
||||
* Render the import interface for example images
|
||||
* @returns {string} HTML content for import interface
|
||||
*/
|
||||
export function initExampleImport(modelHash, container) {
|
||||
if (!container) return;
|
||||
|
||||
const importContainer = container.querySelector('#exampleImportContainer');
|
||||
const fileInput = container.querySelector('#exampleFilesInput');
|
||||
const selectFilesBtn = container.querySelector('#selectExampleFilesBtn');
|
||||
|
||||
// Set up file selection button
|
||||
if (selectFilesBtn) {
|
||||
selectFilesBtn.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// Handle file selection
|
||||
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);
|
||||
}
|
||||
}
|
||||
function renderImportInterface() {
|
||||
return `
|
||||
<div class="import-section">
|
||||
<button class="select-files-btn" id="selectExampleFilesBtn">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span>Add Images</span>
|
||||
</button>
|
||||
<div class="import-drop-zone" id="importDropZone">
|
||||
<div class="drop-zone-content">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
<span>Drop here</span>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="exampleFilesInput" multiple accept="image/*,video/mp4,video/webm" style="display: none;">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all showcase content interactions
|
||||
* @param {HTMLElement} carousel - The carousel element
|
||||
* @param {HTMLElement} showcase - The showcase element
|
||||
*/
|
||||
export function initShowcaseContent(carousel) {
|
||||
if (!carousel) return;
|
||||
export function initShowcaseContent(showcase) {
|
||||
if (!showcase) return;
|
||||
|
||||
initLazyLoading(carousel);
|
||||
initNsfwBlurHandlers(carousel);
|
||||
initMetadataPanelHandlers(carousel);
|
||||
initMediaControlHandlers(carousel);
|
||||
positionAllMediaControls(carousel);
|
||||
|
||||
// Bind scroll-indicator click to toggleShowcase
|
||||
const scrollIndicator = carousel.previousElementSibling;
|
||||
if (scrollIndicator && scrollIndicator.classList.contains('scroll-indicator')) {
|
||||
// Remove previous click listeners to avoid duplicates
|
||||
scrollIndicator.onclick = null;
|
||||
scrollIndicator.removeEventListener('click', scrollIndicator._toggleShowcaseHandler);
|
||||
scrollIndicator._toggleShowcaseHandler = () => toggleShowcase(scrollIndicator);
|
||||
scrollIndicator.addEventListener('click', scrollIndicator._toggleShowcaseHandler);
|
||||
}
|
||||
const container = showcase.querySelector('.showcase-container');
|
||||
if (!container) return;
|
||||
|
||||
// Add window resize handler
|
||||
const resizeHandler = () => positionAllMediaControls(carousel);
|
||||
window.removeEventListener('resize', resizeHandler);
|
||||
window.addEventListener('resize', resizeHandler);
|
||||
initLazyLoading(container);
|
||||
initNsfwBlurHandlers(container);
|
||||
initThumbnailNavigation(container);
|
||||
initMainDisplayHandlers(container);
|
||||
initMediaControlHandlers(container);
|
||||
|
||||
// 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));
|
||||
}
|
||||
});
|
||||
// Initialize keyboard navigation
|
||||
initKeyboardNavigation(container);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to top of modal content
|
||||
* @param {HTMLElement} button - Back to top button
|
||||
* Initialize thumbnail navigation
|
||||
* @param {HTMLElement} container - The showcase container
|
||||
*/
|
||||
export function scrollToTop(button) {
|
||||
const modalContent = button.closest('.modal-content');
|
||||
if (modalContent) {
|
||||
modalContent.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up showcase scroll functionality
|
||||
* @param {string} modalId - ID of the modal element
|
||||
*/
|
||||
export function setupShowcaseScroll(modalId) {
|
||||
// Listen for wheel events
|
||||
document.addEventListener('wheel', (event) => {
|
||||
const modalContent = document.querySelector(`#${modalId} .modal-content`);
|
||||
if (!modalContent) return;
|
||||
|
||||
const showcase = modalContent.querySelector('.showcase-section');
|
||||
if (!showcase) return;
|
||||
|
||||
const carousel = showcase.querySelector('.carousel');
|
||||
const scrollIndicator = showcase.querySelector('.scroll-indicator');
|
||||
|
||||
if (carousel?.classList.contains('collapsed') && event.deltaY > 0) {
|
||||
const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
|
||||
function initThumbnailNavigation(container) {
|
||||
const thumbnails = container.querySelectorAll('.thumbnail-item');
|
||||
const mainContainer = container.querySelector('#mainMediaContainer');
|
||||
|
||||
if (!mainContainer) return;
|
||||
|
||||
thumbnails.forEach((thumbnail, index) => {
|
||||
thumbnail.addEventListener('click', () => {
|
||||
// Update active thumbnail
|
||||
thumbnails.forEach(t => t.classList.remove('active'));
|
||||
thumbnail.classList.add('active');
|
||||
|
||||
if (isNearBottom) {
|
||||
toggleShowcase(scrollIndicator);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}, { passive: false });
|
||||
// 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');
|
||||
|
||||
// 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'));
|
||||
}
|
||||
}
|
||||
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');
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
if (!activeThumbnail || thumbnails.length === 0) return;
|
||||
|
||||
// Try to set up the button immediately in case the modal is already open
|
||||
const modalContent = document.querySelector(`#${modalId} .modal-content`);
|
||||
if (modalContent) {
|
||||
setupBackToTopButton(modalContent);
|
||||
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 back-to-top button
|
||||
* @param {HTMLElement} modalContent - Modal content element
|
||||
* Initialize metadata panel toggle behavior
|
||||
* @param {HTMLElement} container - The showcase container
|
||||
*/
|
||||
function setupBackToTopButton(modalContent) {
|
||||
// Remove any existing scroll listeners to avoid duplicates
|
||||
modalContent.onscroll = null;
|
||||
function initMetadataPanelToggle(container) {
|
||||
const metadataPanel = container.querySelector('.image-metadata-panel');
|
||||
|
||||
// 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');
|
||||
if (!metadataPanel) return;
|
||||
|
||||
// Handle copy prompt buttons
|
||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
||||
copyBtns.forEach(copyBtn => {
|
||||
const promptIndex = copyBtn.dataset.promptIndex;
|
||||
const promptElement = container.querySelector(`#prompt-${promptIndex}`);
|
||||
|
||||
copyBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!promptElement) return;
|
||||
|
||||
try {
|
||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('Copy failed', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Trigger a scroll event to check initial position
|
||||
modalContent.dispatchEvent(new Event('scroll'));
|
||||
// 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 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update main display with new media item
|
||||
* @param {number} index - Index of the media to display
|
||||
* @param {HTMLElement} container - The showcase container
|
||||
*/
|
||||
function updateMainDisplay(index, container) {
|
||||
// This function would need to re-render the main display area
|
||||
// Implementation depends on how the image data is stored and accessed
|
||||
console.log('Update main display to index:', index);
|
||||
}
|
||||
Reference in New Issue
Block a user