fix: folder trash/delete operations & frontend refactoring
- Fix recursive CTE: add missing RECURSIVE keyword in move_to_trash and restore_from_trash SQL queries (relation 'descendants' does not exist) - Fix folder deletion: delete descendant files before folder to avoid 'duplicate key violates unique constraint idx_files_unique_name_at_root' - Simplify trash model: only mark the folder as trashed, not child files (implicit trash via parent) - Update trash_items view: filter to show only top-level trashed items - Update schema.sql: change files.folder_id FK from ON DELETE SET NULL to ON DELETE CASCADE - Fix trash view icons: folders and files now show correct visual icons (folder-icon, pdf-icon, etc.) in trash view - Frontend refactoring: extract inline CSS/JS from admin.html and profile.html into dedicated external files - Frontend cleanup: replace all inline style attributes with CSS classes - Frontend cleanup: replace style.display JS - Fix recursive CTE: add missing RECURSIVE keyword in move_to_trash and restore_from_trash SQL queries (relation 'descendants' d
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* OxiCloud - Favorites Module (server-authoritative)
|
||||
*
|
||||
* Source of truth: GET /api/favorites (enriched with name/size/mime via SQL JOIN).
|
||||
* Local in-memory cache (`_cache`) keeps `isFavorite()` synchronous for the
|
||||
* rendering path so star icons can be painted without a round-trip.
|
||||
*/
|
||||
|
||||
const favorites = {
|
||||
/** @type {Map<string, object>} key = "file:<id>" | "folder:<id>" */
|
||||
_cache: new Map(),
|
||||
|
||||
/** Whether the initial fetch from the server has completed */
|
||||
_ready: false,
|
||||
|
||||
// ───────────────────── helpers ─────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
const token = localStorage.getItem('oxicloud_token');
|
||||
const h = {};
|
||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||
return h;
|
||||
},
|
||||
|
||||
_cacheKey(id, type) {
|
||||
return `${type}:${id}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace the entire in-memory cache from an array of FavoriteItemDto
|
||||
* objects (as returned by the batch endpoint). Avoids an extra
|
||||
* GET /api/favorites round-trip.
|
||||
*/
|
||||
_replaceCacheFromResponse(items) {
|
||||
this._cache.clear();
|
||||
for (const item of items) {
|
||||
this._cache.set(this._cacheKey(item.item_id, item.item_type), item);
|
||||
}
|
||||
this._ready = true;
|
||||
console.log(`Favorites cache replaced from response: ${this._cache.size} items`);
|
||||
},
|
||||
|
||||
// ───────────────────── lifecycle ─────────────────────
|
||||
|
||||
/**
|
||||
* Initialise the module: fetch the full list from the server and populate
|
||||
* the in-memory cache. Called once from app.js on startup.
|
||||
*/
|
||||
async init() {
|
||||
console.log('Initializing favorites module (server-authoritative)');
|
||||
await this._fetchFromServer();
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch favourites from the backend and rebuild the cache.
|
||||
*/
|
||||
async _fetchFromServer() {
|
||||
try {
|
||||
const response = await fetch('/api/favorites', {
|
||||
headers: this._authHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`Favorites API returned ${response.status}`);
|
||||
this._ready = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const items = await response.json();
|
||||
this._cache.clear();
|
||||
for (const item of items) {
|
||||
this._cache.set(this._cacheKey(item.item_id, item.item_type), item);
|
||||
}
|
||||
|
||||
this._ready = true;
|
||||
console.log(`Favorites cache loaded: ${this._cache.size} items`);
|
||||
} catch (err) {
|
||||
console.error('Error fetching favorites:', err);
|
||||
this._ready = true;
|
||||
}
|
||||
},
|
||||
|
||||
// ───────────────────── public API ─────────────────────
|
||||
|
||||
/**
|
||||
* Synchronous check used by ui.js to paint star icons.
|
||||
*/
|
||||
isFavorite(id, type) {
|
||||
return this._cache.has(this._cacheKey(id, type));
|
||||
},
|
||||
|
||||
/**
|
||||
* Add an item to favourites (server-first).
|
||||
*/
|
||||
async addToFavorites(id, name, type, _parentId) {
|
||||
try {
|
||||
const response = await fetch(`/api/favorites/${type}/${id}`, {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}`);
|
||||
}
|
||||
|
||||
// Refresh cache from server to get enriched data
|
||||
await this._fetchFromServer();
|
||||
|
||||
// Notify user
|
||||
if (window.ui && window.ui.showNotification) {
|
||||
window.ui.showNotification(
|
||||
window.i18n ? window.i18n.t('favorites.added_title') : 'Added to favorites',
|
||||
`"${name}" ${window.i18n ? window.i18n.t('favorites.added_msg') : 'added to favorites'}`
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error adding to favorites:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove an item from favourites (server-first).
|
||||
*/
|
||||
async removeFromFavorites(id, type) {
|
||||
try {
|
||||
// Remember name for notification before removing from cache
|
||||
const cached = this._cache.get(this._cacheKey(id, type));
|
||||
const itemName = cached?.item_name || id;
|
||||
|
||||
const response = await fetch(`/api/favorites/${type}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: this._authHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}`);
|
||||
}
|
||||
|
||||
// Remove from local cache
|
||||
this._cache.delete(this._cacheKey(id, type));
|
||||
|
||||
if (window.ui && window.ui.showNotification) {
|
||||
window.ui.showNotification(
|
||||
window.i18n ? window.i18n.t('favorites.removed_title') : 'Removed from favorites',
|
||||
`"${itemName}" ${window.i18n ? window.i18n.t('favorites.removed_msg') : 'removed from favorites'}`
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error removing from favorites:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// ───────────────────── display ─────────────────────
|
||||
|
||||
/**
|
||||
* Render the favourites view. All data comes from the in-memory cache
|
||||
* (which was populated from the enriched backend response — zero extra
|
||||
* fetches).
|
||||
*/
|
||||
async displayFavorites() {
|
||||
try {
|
||||
// Ensure cache is fresh
|
||||
if (!this._ready) {
|
||||
await this._fetchFromServer();
|
||||
}
|
||||
|
||||
const filesGrid = document.getElementById('files-grid');
|
||||
const filesListView = document.getElementById('files-list-view');
|
||||
|
||||
filesGrid.innerHTML = '';
|
||||
filesListView.innerHTML = `
|
||||
<div class="list-header">
|
||||
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
||||
<div data-i18n="files.name">Name</div>
|
||||
<div data-i18n="files.type">Type</div>
|
||||
<div data-i18n="files.size">Size</div>
|
||||
<div data-i18n="files.modified">Modified</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
window.ui.updateBreadcrumb('');
|
||||
|
||||
if (this._cache.size === 0) {
|
||||
const emptyState = document.createElement('div');
|
||||
emptyState.className = 'empty-state';
|
||||
emptyState.innerHTML = `
|
||||
<i class="fas fa-star" style="font-size: 48px; color: #ddd; margin-bottom: 16px;"></i>
|
||||
<p>${window.i18n ? window.i18n.t('favorites.empty_state') : 'No favorite items'}</p>
|
||||
<p>${window.i18n ? window.i18n.t('favorites.empty_hint') : 'To mark as favorite, right-click on any file or folder'}</p>
|
||||
`;
|
||||
filesGrid.appendChild(emptyState);
|
||||
return;
|
||||
}
|
||||
|
||||
const folders = [];
|
||||
const files = [];
|
||||
for (const item of this._cache.values()) {
|
||||
if (item.item_type === 'folder') {
|
||||
folders.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
modified_at: item.modified_at || item.created_at
|
||||
});
|
||||
} else {
|
||||
files.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
mime_type: item.item_mime_type,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
category: item.category,
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.modified_at || item.created_at
|
||||
});
|
||||
}
|
||||
}
|
||||
if (folders.length) window.ui.renderFolders(folders);
|
||||
if (files.length) window.ui.renderFiles(files);
|
||||
} catch (error) {
|
||||
console.error('Error displaying favorites:', error);
|
||||
if (window.ui && window.ui.showNotification) {
|
||||
window.ui.showNotification('Error', 'Error loading favorite items');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Expose globally
|
||||
window.favorites = favorites;
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* OxiCloud - Recent Files Module (server-authoritative)
|
||||
*
|
||||
* Source of truth: GET /api/recent (enriched with name/size/mime via SQL JOIN).
|
||||
* File-access events are forwarded to the backend with POST /api/recent/{type}/{id}.
|
||||
* No localStorage usage — the server persists and prunes recent items.
|
||||
*/
|
||||
|
||||
const recent = {
|
||||
/** Maximum items to request from the server */
|
||||
MAX_RECENT_FILES: 20,
|
||||
|
||||
// ───────────────────── helpers ─────────────────────
|
||||
|
||||
_authHeaders() {
|
||||
const token = localStorage.getItem('oxicloud_token');
|
||||
const h = {};
|
||||
if (token) h['Authorization'] = `Bearer ${token}`;
|
||||
return h;
|
||||
},
|
||||
|
||||
// ───────────────────── lifecycle ─────────────────────
|
||||
|
||||
/**
|
||||
* Initialise the module. Called once from app.js on startup.
|
||||
*/
|
||||
init() {
|
||||
console.log('Initializing recent files module (server-authoritative)');
|
||||
this.setupEventListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Listen for file-accessed events dispatched by ui.js and forward
|
||||
* them to the backend.
|
||||
*/
|
||||
setupEventListeners() {
|
||||
document.addEventListener('file-accessed', (event) => {
|
||||
if (event.detail && event.detail.file) {
|
||||
const file = event.detail.file;
|
||||
const itemType = file.item_type || 'file';
|
||||
this._recordAccess(file.id, itemType);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Record an access event on the server.
|
||||
*/
|
||||
async _recordAccess(itemId, itemType) {
|
||||
try {
|
||||
await fetch(`/api/recent/${itemType}/${itemId}`, {
|
||||
method: 'POST',
|
||||
headers: this._authHeaders()
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Failed to record recent access:', err);
|
||||
}
|
||||
},
|
||||
|
||||
// ───────────────────── public API ─────────────────────
|
||||
|
||||
/**
|
||||
* Clear all recent items (delegates to the server).
|
||||
*/
|
||||
async clearRecentFiles() {
|
||||
try {
|
||||
await fetch('/api/recent/clear', {
|
||||
method: 'DELETE',
|
||||
headers: this._authHeaders()
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error clearing recent files:', err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch and display recent files. Data comes directly from the
|
||||
* enriched backend response — zero extra per-item fetches.
|
||||
*/
|
||||
async displayRecentFiles() {
|
||||
try {
|
||||
const response = await fetch(`/api/recent?limit=${this.MAX_RECENT_FILES}`, {
|
||||
headers: this._authHeaders()
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Server returned ${response.status}`);
|
||||
}
|
||||
|
||||
const recentItems = await response.json();
|
||||
|
||||
const filesGrid = document.getElementById('files-grid');
|
||||
const filesListView = document.getElementById('files-list-view');
|
||||
|
||||
filesGrid.innerHTML = '';
|
||||
filesListView.innerHTML = `
|
||||
<div class="list-header">
|
||||
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
||||
<div data-i18n="files.name">Name</div>
|
||||
<div data-i18n="files.type">Type</div>
|
||||
<div data-i18n="files.size">Size</div>
|
||||
<div data-i18n="recent.accessed">Accessed</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
window.ui.updateBreadcrumb('');
|
||||
|
||||
if (recentItems.length === 0) {
|
||||
const emptyState = document.createElement('div');
|
||||
emptyState.className = 'empty-state';
|
||||
emptyState.innerHTML = `
|
||||
<i class="fas fa-clock" style="font-size: 48px; color: #ddd; margin-bottom: 16px;"></i>
|
||||
<p>${window.i18n ? window.i18n.t('recent.empty_state') : 'No recent files'}</p>
|
||||
<p>${window.i18n ? window.i18n.t('recent.empty_hint') : 'Files you open will appear here'}</p>
|
||||
`;
|
||||
filesGrid.appendChild(emptyState);
|
||||
return;
|
||||
}
|
||||
|
||||
const folders = [];
|
||||
const files = [];
|
||||
for (const item of recentItems) {
|
||||
const isFolder = item.item_type === 'folder';
|
||||
if (isFolder) {
|
||||
folders.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
parent_id: item.parent_id || '',
|
||||
modified_at: item.accessed_at
|
||||
});
|
||||
} else {
|
||||
files.push({
|
||||
id: item.item_id,
|
||||
name: item.item_name || item.item_id,
|
||||
folder_id: item.parent_id || '',
|
||||
mime_type: item.item_mime_type,
|
||||
icon_class: item.icon_class,
|
||||
icon_special_class: item.icon_special_class,
|
||||
category: item.category,
|
||||
size: item.item_size || 0,
|
||||
size_formatted: item.size_formatted,
|
||||
modified_at: item.accessed_at
|
||||
});
|
||||
}
|
||||
}
|
||||
if (folders.length) window.ui.renderFolders(folders);
|
||||
if (files.length) window.ui.renderFiles(files);
|
||||
} catch (error) {
|
||||
console.error('Error displaying recent files:', error);
|
||||
if (window.ui && window.ui.showNotification) {
|
||||
window.ui.showNotification('Error', 'Error loading recent files');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Expose globally
|
||||
window.recent = recent;
|
||||
Reference in New Issue
Block a user