feat(recipes): redesign import modal with URL-first input and unified drop zone

This commit is contained in:
Will Miao
2026-08-21 00:01:50 +08:00
parent 86aa1d8059
commit 45e7c25308
15 changed files with 270 additions and 154 deletions
+68 -25
View File
@@ -77,41 +77,84 @@
margin-bottom: var(--space-3);
}
/* File Input Styles */
.file-input-wrapper {
position: relative;
margin-bottom: var(--space-1);
.import-description {
margin-top: 0;
}
.file-input-wrapper input[type="file"] {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 2;
}
.file-input-button {
/* Unified Drop Zone */
.import-drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--lora-accent);
color: var(--lora-text);
border-radius: var(--border-radius-xs);
font-weight: 500;
gap: var(--space-1);
padding: var(--space-4) var(--space-3);
border: 2px dashed var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--bg-color);
color: var(--text-color);
text-align: center;
cursor: pointer;
transition: background-color 0.2s;
transition: border-color 0.2s, background-color 0.2s;
}
.file-input-button:hover {
background: oklch(from var(--lora-accent) l c h / 0.9);
.import-drop-zone:hover,
.import-drop-zone:focus-visible {
border-color: var(--lora-accent);
outline: none;
}
.file-input-wrapper:hover .file-input-button {
background: oklch(from var(--lora-accent) l c h / 0.9);
.import-drop-zone.drag-over {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent) / 0.08);
}
.drop-zone-icon {
font-size: 1.8em;
color: var(--lora-accent);
}
.drop-zone-primary {
margin: 0;
opacity: 0.8;
}
.drop-zone-filename {
margin: 0;
font-weight: 500;
word-break: break-all;
}
/* Divider between drop zone and URL input */
.import-divider {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-3) 0;
color: var(--text-color);
opacity: 0.6;
font-size: 0.9em;
}
.import-divider::before,
.import-divider::after {
content: '';
flex: 1;
border-top: 1px solid var(--border-color);
}
/* Loading state for the fetch button */
#fetchImageBtn.loading {
opacity: 0.8;
cursor: wait;
}
/* Inputs sit flush against the scrollable step's content edge; an outset
outline (global offset: 2px) gets clipped by overflow-x. Draw the focus
outline inset instead so the full ring stays visible. */
#importModal input:focus-visible,
#importModal select:focus-visible {
outline-offset: -2px;
}
/* Recipe Details Layout */
+82 -42
View File
@@ -25,7 +25,7 @@ export class ImportManager {
this.selectedFolder = '';
this.downloadableLoRAs = [];
this.recipeId = null;
this.importMode = 'url'; // Default mode: 'url' or 'upload'
this.importMode = null; // Set by input handlers: 'url' or 'upload'
this.useDefaultPath = false;
this.apiClient = null;
@@ -70,10 +70,8 @@ export class ImportManager {
this.stepManager.removeInjectedStyles();
});
// Verify visibility and focus on URL input
// Verify visibility and focus on the URL input (primary mode)
setTimeout(() => {
// Ensure URL option is selected and focus on the input
this.toggleImportMode('url');
const urlInput = document.getElementById('imageUrlInput');
if (urlInput) {
urlInput.focus();
@@ -87,6 +85,62 @@ export class ImportManager {
if (useDefaultPathToggle) {
useDefaultPathToggle.addEventListener('change', this.handleToggleDefaultPath);
}
const modal = document.getElementById('importModal');
const dropZone = document.getElementById('importDropZone');
const fileInput = document.getElementById('recipeImageUpload');
const urlInput = document.getElementById('imageUrlInput');
// Submit URL with Enter
if (urlInput) {
urlInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
this.handleUrlInput();
}
});
}
if (dropZone && fileInput) {
// Click or keyboard activation opens the file picker
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
fileInput.click();
}
});
// Drag & drop
dropZone.addEventListener('dragover', (event) => {
event.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (event) => {
event.preventDefault();
dropZone.classList.remove('drag-over');
const file = event.dataTransfer?.files?.[0];
if (file) {
this.imageProcessor.handleDroppedFile(file);
}
});
}
// Paste an image from clipboard while the modal is open
if (modal) {
modal.addEventListener('paste', (event) => {
if (this.stepManager.currentStep !== 'uploadStep') return;
const file = Array.from(event.clipboardData?.files || [])
.find(f => f.type.startsWith('image/'));
if (file) {
event.preventDefault();
this.imageProcessor.handleDroppedFile(file);
}
});
}
}
resetSteps() {
@@ -128,9 +182,11 @@ export class ImportManager {
this.downloadableLoRAs = [];
this.selectedFolder = '';
// Reset import mode
this.importMode = 'url';
this.toggleImportMode('url');
// Import mode is set by the input handlers ('url' or 'upload')
this.importMode = null;
// Reset drop zone filename feedback
this.updateSelectedFileName(null);
// Clear folder tree selection
if (this.folderTreeManager) {
@@ -166,43 +222,24 @@ export class ImportManager {
}
}
toggleImportMode(mode) {
this.importMode = mode;
/**
* Show the selected file name in the drop zone, or restore the default
* hint text when called with null.
*/
updateSelectedFileName(fileName) {
const nameEl = document.getElementById('selectedFileName');
const hintEl = document.getElementById('dropZonePrimaryText');
if (!nameEl || !hintEl) return;
// Update toggle buttons
const uploadBtn = document.querySelector('.toggle-btn[data-mode="upload"]');
const urlBtn = document.querySelector('.toggle-btn[data-mode="url"]');
if (uploadBtn && urlBtn) {
if (mode === 'upload') {
uploadBtn.classList.add('active');
urlBtn.classList.remove('active');
} else {
uploadBtn.classList.remove('active');
urlBtn.classList.add('active');
}
if (fileName) {
nameEl.textContent = fileName;
nameEl.style.display = 'block';
hintEl.style.display = 'none';
} else {
nameEl.textContent = '';
nameEl.style.display = 'none';
hintEl.style.display = '';
}
// Show/hide appropriate sections
const uploadSection = document.getElementById('uploadSection');
const urlSection = document.getElementById('urlSection');
if (uploadSection && urlSection) {
if (mode === 'upload') {
uploadSection.style.display = 'block';
urlSection.style.display = 'none';
} else {
uploadSection.style.display = 'none';
urlSection.style.display = 'block';
}
}
// Clear error messages
const uploadError = document.getElementById('uploadError');
const importUrlError = document.getElementById('importUrlError');
if (uploadError) uploadError.textContent = '';
if (importUrlError) importUrlError.textContent = '';
}
handleImageUpload(event) {
@@ -345,6 +382,9 @@ export class ImportManager {
const urlInput = document.getElementById('imageUrlInput');
if (urlInput) urlInput.value = '';
// Reset drop zone filename feedback
this.updateSelectedFileName(null);
// Clear error messages
const uploadError = document.getElementById('uploadError');
if (uploadError) uploadError.textContent = '';
+50 -9
View File
@@ -8,20 +8,32 @@ export class ImageProcessor {
handleFileUpload(event) {
const file = event.target.files[0];
if (file) {
this.handleDroppedFile(file);
}
}
/**
* Shared entry for files coming from the file picker, drag & drop,
* or clipboard paste.
*/
handleDroppedFile(file) {
const errorElement = document.getElementById('uploadError');
if (!file) return;
// Validate file type
if (!file.type.match('image.*')) {
errorElement.textContent = translate('recipes.controls.import.errors.selectImageFile', {}, 'Please select an image file');
return;
}
// Reset error
errorElement.textContent = '';
this.importManager.recipeImage = file;
this.importManager.importMode = 'upload';
// Show the selected file name in the drop zone
this.importManager.updateSelectedFileName(file.name);
// Auto-proceed to next step if file is selected
this.importManager.uploadAndAnalyzeImage();
}
@@ -30,19 +42,37 @@ export class ImageProcessor {
const urlInput = document.getElementById('imageUrlInput');
const errorElement = document.getElementById('importUrlError');
const input = urlInput.value.trim();
// Validate input
if (!input) {
errorElement.textContent = translate('recipes.controls.import.errors.enterUrlOrPath', {}, 'Please enter a URL or file path');
return;
}
// Front-end format validation before hitting the backend
if (input.startsWith('http://') || input.startsWith('https://')) {
try {
new URL(input);
} catch {
errorElement.textContent = translate('recipes.controls.import.errors.invalidUrl', {}, 'Please enter a valid URL');
return;
}
} else if (!/\.(png|jpe?g|webp|gif|bmp|avif|jxl|mp4|webm)$/i.test(input)) {
errorElement.textContent = translate('recipes.controls.import.errors.invalidInputFormat', {}, 'Please enter an image URL or a local image file path');
return;
}
// Reset error
errorElement.textContent = '';
this.importManager.importMode = 'url';
// Put the fetch button into a loading state to prevent duplicate submits
const fetchBtn = document.getElementById('fetchImageBtn');
this._setFetchButtonLoading(fetchBtn, true);
// Show loading indicator
this.importManager.loadingManager.showSimpleLoading(translate('recipes.controls.import.processingInput', {}, 'Processing input...'));
try {
// Check if it's a URL or a local file path
if (input.startsWith('http://') || input.startsWith('https://')) {
@@ -55,10 +85,21 @@ export class ImageProcessor {
} catch (error) {
errorElement.textContent = error.message || 'Failed to process input';
} finally {
this._setFetchButtonLoading(fetchBtn, false);
this.importManager.loadingManager.hide();
}
}
_setFetchButtonLoading(button, isLoading) {
if (!button) return;
button.disabled = isLoading;
button.classList.toggle('loading', isLoading);
const icon = button.querySelector('i');
if (icon) {
icon.className = isLoading ? 'fas fa-spinner fa-spin' : 'fas fa-download';
}
}
async analyzeImageFromUrl(url) {
try {
// Call the API with URL data
@@ -1,6 +1,7 @@
export class ImportStepManager {
constructor() {
this.injectedStyles = null;
this.currentStep = null;
}
removeInjectedStyles() {
@@ -18,6 +19,7 @@ export class ImportStepManager {
showStep(stepId) {
// Remove any injected styles to prevent conflicts
this.removeInjectedStyles();
this.currentStep = stepId;
// Hide all steps first
document.querySelectorAll('.import-step').forEach(step => {