mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
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.
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'));
|
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user