feat(dragdrop): drag & drop complet de fichiers/dossiers & intégration IA (#89)
This commit is contained in:
@@ -965,6 +965,12 @@ class BooksLM {
|
||||
this._sendMessage();
|
||||
});
|
||||
|
||||
// Setup Drag & Drop for internal items and external files (#89)
|
||||
this._setupDropZones(panel);
|
||||
|
||||
// Save singleton reference on window
|
||||
window.BooksLMInstance = this;
|
||||
|
||||
return panel;
|
||||
}
|
||||
|
||||
@@ -981,6 +987,93 @@ class BooksLM {
|
||||
return Math.max(PANEL_MIN_WIDTH, Math.min(PANEL_MAX_WIDTH, width));
|
||||
}
|
||||
|
||||
_setupDropZones(panel) {
|
||||
const composer = panel.querySelector('.bookslm-composer') || panel;
|
||||
|
||||
const onDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
composer.classList.add('bookslm-drop-over');
|
||||
};
|
||||
|
||||
const onDragLeave = (e) => {
|
||||
if (composer.contains(e.relatedTarget)) return;
|
||||
composer.classList.remove('bookslm-drop-over');
|
||||
};
|
||||
|
||||
const onDrop = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
composer.classList.remove('bookslm-drop-over');
|
||||
|
||||
// Check if internal tree drag
|
||||
const treeData = e.dataTransfer.getData('tree:path') || e.dataTransfer.getData('text/plain');
|
||||
const treeVault = e.dataTransfer.getData('tree:vault');
|
||||
const treeType = e.dataTransfer.getData('tree:type');
|
||||
|
||||
if (treeData && treeVault) {
|
||||
if (treeType === 'directory') {
|
||||
this._addAdhocDir(treeData, treeVault);
|
||||
} else {
|
||||
this._addAdhocFile(treeData, treeVault);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// External files
|
||||
const files = Array.from(e.dataTransfer.files || []);
|
||||
if (files.length > 0) {
|
||||
this.handleExternalDroppedItems(files.map((f) => ({ path: f.name, file: f, isDir: false })));
|
||||
}
|
||||
};
|
||||
|
||||
composer.addEventListener('dragover', onDragOver);
|
||||
composer.addEventListener('dragleave', onDragLeave);
|
||||
composer.addEventListener('drop', onDrop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle files/folders dropped from outside into BooksLM (#89).
|
||||
*/
|
||||
async handleExternalDroppedItems(items) {
|
||||
if (!this._isOpen) {
|
||||
this.open();
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (item.isDir) {
|
||||
continue;
|
||||
}
|
||||
const file = item.file;
|
||||
if (!file) continue;
|
||||
|
||||
if (file.type && file.type.startsWith('image/')) {
|
||||
this._addImageFile(file);
|
||||
} else {
|
||||
// Read text/markdown file content
|
||||
try {
|
||||
const text = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ''));
|
||||
reader.onerror = reject;
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
// Insert into context files or append to composer
|
||||
this._adhocFiles.push({
|
||||
path: item.path,
|
||||
vault: null,
|
||||
inlineContent: text,
|
||||
});
|
||||
this._renderAttachments();
|
||||
} catch (err) {
|
||||
console.warn('Could not read dropped file as text:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_startResize(e) {
|
||||
if (!this._panel || this._isFullscreen) return;
|
||||
e.preventDefault();
|
||||
|
||||
Vendored
+144
-6
@@ -9,8 +9,10 @@
|
||||
*/
|
||||
|
||||
import { api } from './auth.js';
|
||||
import { t } from './i18n.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { readDataTransferEntries, handleVaultDropUpload } from './upload.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
@@ -180,14 +182,21 @@ function onDragStart(e) {
|
||||
}
|
||||
|
||||
function onDragOver(e) {
|
||||
if (!dragState) return;
|
||||
const isExternalFiles = e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files');
|
||||
|
||||
if (!dragState && !isExternalFiles) return;
|
||||
e.preventDefault(); // Required to allow drop
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
if (isExternalFiles) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
} else {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
|
||||
clearAllHighlights();
|
||||
|
||||
const target = getDropTarget(e.target);
|
||||
if (!target || isInvalidDrop(dragState, target)) {
|
||||
if (!target || (!isExternalFiles && isInvalidDrop(dragState, target))) {
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -224,17 +233,33 @@ async function onDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!dragState) return;
|
||||
const isExternalFiles = e.dataTransfer && (
|
||||
(e.dataTransfer.files && e.dataTransfer.files.length > 0) ||
|
||||
(e.dataTransfer.items && e.dataTransfer.items.length > 0) ||
|
||||
Array.from(e.dataTransfer.types || []).includes('Files')
|
||||
);
|
||||
|
||||
if (!dragState && !isExternalFiles) return;
|
||||
|
||||
const target = getDropTarget(e.target);
|
||||
if (!target || isInvalidDrop(dragState, target)) {
|
||||
if (!target || (!isExternalFiles && isInvalidDrop(dragState, target))) {
|
||||
onDragEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const { vault, path, type } = dragState;
|
||||
const { vault: targetVault, targetDir } = target;
|
||||
|
||||
if (isExternalFiles) {
|
||||
onDragEnd();
|
||||
const items = await readDataTransferEntries(e.dataTransfer.items || e.dataTransfer.files);
|
||||
if (items.length > 0) {
|
||||
await handleVaultDropUpload(targetVault, targetDir, items);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { vault, path, type } = dragState;
|
||||
|
||||
// Don't move if source and target are the same vault
|
||||
if (vault !== targetVault) {
|
||||
showToast(t('dragdrop.cross_vault_unsupported'), 'error');
|
||||
@@ -312,6 +337,119 @@ export function initDragDrop() {
|
||||
makeAllDraggable();
|
||||
});
|
||||
observer.observe(container, { childList: true, subtree: true });
|
||||
|
||||
// Initialize global drop overlay
|
||||
initGlobalDropOverlay();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a global dropzone overlay across the entire window
|
||||
* when dragging files/folders from the OS.
|
||||
*/
|
||||
export function initGlobalDropOverlay() {
|
||||
if (document.getElementById('obsigate-global-drop-overlay')) return;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'obsigate-global-drop-overlay';
|
||||
overlay.className = 'obsigate-drop-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="obsigate-drop-backdrop"></div>
|
||||
<div class="obsigate-drop-modal">
|
||||
<h2 class="obsigate-drop-title">${t('dragdrop.overlay_title') || 'Déposer des fichiers ou dossiers'}</h2>
|
||||
<div class="obsigate-drop-zones">
|
||||
<div class="obsigate-drop-zone obsigate-drop-vault" data-zone="vault">
|
||||
<div class="obsigate-drop-zone-icon">📁</div>
|
||||
<div class="obsigate-drop-zone-title">${t('dragdrop.overlay_vault_zone') || 'Importer dans le Vault'}</div>
|
||||
<div class="obsigate-drop-zone-desc" id="obsigate-drop-vault-target"></div>
|
||||
</div>
|
||||
<div class="obsigate-drop-zone obsigate-drop-ai" data-zone="ai">
|
||||
<div class="obsigate-drop-zone-icon">🧠</div>
|
||||
<div class="obsigate-drop-zone-title">${t('dragdrop.overlay_ai_zone') || "Analyser avec l'Assistant IA"}</div>
|
||||
<div class="obsigate-drop-zone-desc">Envoyer au chat BooksLM</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
let dragCounter = 0;
|
||||
|
||||
window.addEventListener('dragenter', (e) => {
|
||||
if (dragState) return; // Don't show global overlay for internal tree reordering
|
||||
const types = Array.from(e.dataTransfer?.types || []);
|
||||
if (!types.includes('Files')) return;
|
||||
|
||||
dragCounter++;
|
||||
if (dragCounter === 1) {
|
||||
// Update target vault description
|
||||
const activeVault = window.state?.activeVault || (document.querySelector('.tree-item.vault-item')?.dataset?.vault) || '';
|
||||
const vaultDesc = overlay.querySelector('#obsigate-drop-vault-target');
|
||||
if (vaultDesc) {
|
||||
vaultDesc.textContent = activeVault ? `Vault : ${activeVault}` : 'Vault par défaut';
|
||||
}
|
||||
overlay.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('dragleave', (e) => {
|
||||
if (dragState) return;
|
||||
const types = Array.from(e.dataTransfer?.types || []);
|
||||
if (!types.includes('Files')) return;
|
||||
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('dragover', (e) => {
|
||||
const types = Array.from(e.dataTransfer?.types || []);
|
||||
if (!types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
const zones = overlay.querySelectorAll('.obsigate-drop-zone');
|
||||
zones.forEach((zone) => {
|
||||
zone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
zone.classList.add('hover');
|
||||
});
|
||||
|
||||
zone.addEventListener('dragleave', () => {
|
||||
zone.classList.remove('hover');
|
||||
});
|
||||
|
||||
zone.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter = 0;
|
||||
overlay.classList.remove('active');
|
||||
zone.classList.remove('hover');
|
||||
|
||||
const zoneType = zone.dataset.zone;
|
||||
const items = await readDataTransferEntries(e.dataTransfer.items || e.dataTransfer.files);
|
||||
if (!items.length) return;
|
||||
|
||||
if (zoneType === 'vault') {
|
||||
const activeVault = window.state?.activeVault || (document.querySelector('.tree-item.vault-item')?.dataset?.vault);
|
||||
if (activeVault) {
|
||||
await handleVaultDropUpload(activeVault, '', items);
|
||||
} else {
|
||||
showToast('Aucun vault sélectionné', 'error');
|
||||
}
|
||||
} else if (zoneType === 'ai') {
|
||||
// Dispatch or pass to BooksLM assistant
|
||||
if (window.BooksLMInstance) {
|
||||
window.BooksLMInstance.handleExternalDroppedItems(items);
|
||||
} else {
|
||||
showToast("Assistant IA non initialisé", 'warning');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* ObsiGate — File & Folder Upload & Drag-and-Drop Core Module (#89).
|
||||
*
|
||||
* Provides utilities for:
|
||||
* - Traversing recursive directories and files from DataTransfer / FileSystem API
|
||||
* - Reading files into base64 / text payloads
|
||||
* - Batch uploading to `/api/vault/{vault}/batch-upload`
|
||||
* - Managing drop progress modals and confirmation dialogs
|
||||
*/
|
||||
|
||||
import { api } from './auth.js';
|
||||
import { t } from './i18n.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
|
||||
/**
|
||||
* Recursively read all FileSystemEntry items (files and directories) from DataTransferItemList.
|
||||
* Falls back to plain File objects if FileSystem API is unavailable.
|
||||
*
|
||||
* @param {DataTransferItemList|FileList|File[]} items - DataTransfer items or files
|
||||
* @returns {Promise<Array<{ path: string, file?: File, isDir?: boolean }>>}
|
||||
*/
|
||||
export async function readDataTransferEntries(items) {
|
||||
const results = [];
|
||||
if (!items) return results;
|
||||
|
||||
// Check if items support webkitGetAsEntry
|
||||
const entries = [];
|
||||
if (items.length && typeof items[0].webkitGetAsEntry === 'function') {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i].webkitGetAsEntry();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
// Traverse entries recursively
|
||||
for (const entry of entries) {
|
||||
await traverseFileSystemEntry(entry, '', results);
|
||||
}
|
||||
} else {
|
||||
// Plain FileList or files
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const file = items[i] instanceof File ? items[i] : items[i].getAsFile ? items[i].getAsFile() : null;
|
||||
if (file) {
|
||||
const relPath = file.webkitRelativePath || file.name;
|
||||
results.push({
|
||||
path: relPath.replace(/\\/g, '/'),
|
||||
file,
|
||||
isDir: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse a single FileSystemEntry recursively.
|
||||
*/
|
||||
async function traverseFileSystemEntry(entry, parentPath, results) {
|
||||
const currentPath = parentPath ? `${parentPath}/${entry.name}` : entry.name;
|
||||
|
||||
if (entry.isFile) {
|
||||
const file = await new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
results.push({
|
||||
path: currentPath.replace(/\\/g, '/'),
|
||||
file,
|
||||
isDir: false,
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
results.push({
|
||||
path: currentPath.replace(/\\/g, '/'),
|
||||
isDir: true,
|
||||
});
|
||||
const dirReader = entry.createReader();
|
||||
const readAllEntries = async () => {
|
||||
const childEntries = await new Promise((resolve, reject) => {
|
||||
dirReader.readEntries(resolve, reject);
|
||||
});
|
||||
if (childEntries.length > 0) {
|
||||
for (const child of childEntries) {
|
||||
await traverseFileSystemEntry(child, currentPath, results);
|
||||
}
|
||||
await readAllEntries(); // Read next batch (readEntries returns max 100 at a time)
|
||||
}
|
||||
};
|
||||
await readAllEntries();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a File object to base64 data string.
|
||||
* @param {File} file
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export function fileToBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const res = reader.result;
|
||||
if (typeof res === 'string') {
|
||||
resolve(res);
|
||||
} else {
|
||||
reject(new Error('Failed to read file as base64 string'));
|
||||
}
|
||||
};
|
||||
reader.onerror = (err) => reject(err);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a batch of extracted items into a vault and target folder.
|
||||
*
|
||||
* @param {string} vault - Target vault name
|
||||
* @param {string} targetDir - Target relative directory (empty for root)
|
||||
* @param {Array<{ path: string, file?: File, isDir?: boolean }>} items - Extracted items
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.overwrite=true]
|
||||
* @param {Function} [options.onProgress] - Callback (current, total)
|
||||
* @returns {Promise<{ success: boolean, uploaded: string[], created_dirs: string[], errors: any[] }>}
|
||||
*/
|
||||
export async function uploadBatch(vault, targetDir, items, options = {}) {
|
||||
if (!vault || !items || !items.length) {
|
||||
return { success: true, uploaded: [], created_dirs: [], errors: [], total_files: 0 };
|
||||
}
|
||||
|
||||
const { overwrite = true, onProgress = null } = options;
|
||||
const payloadFiles = [];
|
||||
const total = items.length;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const item = items[i];
|
||||
if (onProgress) onProgress(i, total, item.path);
|
||||
|
||||
if (item.isDir) {
|
||||
payloadFiles.push({
|
||||
path: item.path,
|
||||
is_dir: true,
|
||||
});
|
||||
} else if (item.file) {
|
||||
try {
|
||||
const b64 = await fileToBase64(item.file);
|
||||
payloadFiles.push({
|
||||
path: item.path,
|
||||
content: b64,
|
||||
is_dir: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`Failed to encode file ${item.path}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onProgress) onProgress(total, total, 'Sending payload...');
|
||||
|
||||
const response = await api(`/api/vault/${encodeURIComponent(vault)}/batch-upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
target_dir: targetDir || '',
|
||||
files: payloadFiles,
|
||||
overwrite,
|
||||
}),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a simple upload modal / toast and execute the upload.
|
||||
*
|
||||
* @param {string} vault
|
||||
* @param {string} targetDir
|
||||
* @param {Array<{ path: string, file?: File, isDir?: boolean }>} items
|
||||
*/
|
||||
export async function handleVaultDropUpload(vault, targetDir, items) {
|
||||
if (!items || !items.length) return;
|
||||
|
||||
const fileCount = items.filter((x) => !x.isDir).length;
|
||||
const dirCount = items.filter((x) => x.isDir).length;
|
||||
const destName = targetDir ? `${vault}/${targetDir}` : vault;
|
||||
|
||||
showToast(`${t('upload.uploading', { count: fileCount }) || `Uploading ${fileCount} file(s) to ${destName}...`}`, 'info');
|
||||
|
||||
try {
|
||||
const res = await uploadBatch(vault, targetDir, items);
|
||||
if (res && res.success) {
|
||||
showToast(t('upload.success', { count: res.uploaded.length }) || `Imported ${res.uploaded.length} file(s) successfully`, 'success');
|
||||
} else if (res && res.uploaded && res.uploaded.length > 0) {
|
||||
showToast(t('upload.partial', { count: res.uploaded.length, errors: res.errors.length }) || `Imported ${res.uploaded.length} file(s) (${res.errors.length} errors)`, 'warning');
|
||||
} else {
|
||||
const errDetail = res?.errors?.[0]?.error || 'Unknown error';
|
||||
showToast(`${t('upload.error') || 'Upload failed'}: ${errDetail}`, 'error');
|
||||
}
|
||||
await refreshSidebarTreePreservingState();
|
||||
} catch (err) {
|
||||
console.error('Batch upload error:', err);
|
||||
showToast(`${t('upload.error') || 'Upload failed'}: ${err.message || err}`, 'error');
|
||||
}
|
||||
}
|
||||
@@ -634,6 +634,13 @@
|
||||
"dashboard.zero_files": "0 files",
|
||||
"dragdrop.cross_vault_unsupported": "Cross-vault moves are not supported",
|
||||
"dragdrop.move_failed": "Move failed",
|
||||
"dragdrop.overlay_title": "Drop files or folders",
|
||||
"dragdrop.overlay_vault_zone": "Import into Vault",
|
||||
"dragdrop.overlay_ai_zone": "Analyze with AI Assistant",
|
||||
"upload.uploading": "Uploading {count} file(s)...",
|
||||
"upload.success": "Successfully imported {count} file(s)",
|
||||
"upload.partial": "Imported {count} file(s) ({errors} errors)",
|
||||
"upload.error": "Upload failed",
|
||||
"editor.cancel": "Cancel",
|
||||
"editor.close": "Close",
|
||||
"editor.delete_error": "Delete error",
|
||||
|
||||
@@ -632,8 +632,15 @@
|
||||
"dashboard.views": "{count} vue(s)",
|
||||
"dashboard.welcome": "Bienvenue dans ObsiGate",
|
||||
"dashboard.zero_files": "0 fichiers",
|
||||
"dragdrop.cross_vault_unsupported": "Cross-vault moves are not supported",
|
||||
"dragdrop.move_failed": "Move failed",
|
||||
"dragdrop.cross_vault_unsupported": "Le déplacement entre vaults différents n'est pas supporté",
|
||||
"dragdrop.move_failed": "Échec du déplacement",
|
||||
"dragdrop.overlay_title": "Déposer des fichiers ou dossiers",
|
||||
"dragdrop.overlay_vault_zone": "Importer dans le Vault",
|
||||
"dragdrop.overlay_ai_zone": "Analyser avec l'Assistant IA",
|
||||
"upload.uploading": "Téléversement de {count} fichier(s)...",
|
||||
"upload.success": "{count} fichier(s) importé(s) avec succès",
|
||||
"upload.partial": "{count} fichier(s) importé(s) ({errors} erreurs)",
|
||||
"upload.error": "Échec de l'importation",
|
||||
"editor.cancel": "Annuler",
|
||||
"editor.close": "Fermer",
|
||||
"editor.delete_error": "Erreur de suppression",
|
||||
|
||||
@@ -1124,6 +1124,93 @@ select {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Global Drag & Drop Overlay (#89) */
|
||||
.obsigate-drop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 99999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.obsigate-drop-overlay.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.obsigate-drop-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.obsigate-drop-modal {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--bg-primary, #1e1e2e);
|
||||
border: 1px solid var(--border-color, #313244);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
width: min(600px, 90vw);
|
||||
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.obsigate-drop-title {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal, #cdd6f4);
|
||||
}
|
||||
|
||||
.obsigate-drop-zones {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.obsigate-drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px 16px;
|
||||
border: 2px dashed var(--border-color, #45475a);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #181825);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.obsigate-drop-zone.hover {
|
||||
border-color: var(--accent, #89b4fa);
|
||||
background: color-mix(in srgb, var(--accent, #89b4fa) 15%, var(--bg-secondary, #181825));
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.obsigate-drop-zone-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.obsigate-drop-zone-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-normal, #cdd6f4);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bookslm-composer.bookslm-drop-over {
|
||||
outline: 2px dashed var(--accent, #89b4fa);
|
||||
outline-offset: 2px;
|
||||
background: color-mix(in srgb, var(--accent, #89b4fa) 10%, var(--bg-secondary, #181825));
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
padding-left: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user