Initial commit

This commit is contained in:
root
2025-03-17 21:28:08 +01:00
commit fe19bc8505
58 changed files with 7202 additions and 0 deletions
+516
View File
@@ -0,0 +1,516 @@
/**
* OxiCloud File Renderer Module
* Optimized rendering for large file lists using virtual rendering
*/
// Configuration
const ITEMS_PER_PAGE = 100; // Number of items to render at once
const ROW_HEIGHT = 80; // Estimated row height for list view
const CARD_HEIGHT = 180; // Estimated height for grid view card
const CARD_WIDTH = 180; // Estimated width for grid view card
class FileRenderer {
constructor() {
this.files = [];
this.folders = [];
this.currentView = 'grid';
this.gridContainer = document.getElementById('files-grid');
this.listContainer = document.getElementById('files-list-view');
this.visibleItems = {};
this.offsetY = 0;
this.totalHeight = 0;
this.gridColumns = 0;
this.i18n = window.i18n || { t: key => key };
// Setup intersection observer for lazy loading
this.setupIntersectionObserver();
// Handle scroll event for virtual scrolling
this.handleScroll = this.handleScroll.bind(this);
this.setupScrollListeners();
}
/**
* Set up intersection observer for lazy loading
*/
setupIntersectionObserver() {
this.observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const elem = entry.target;
if (elem.dataset.lazySrc) {
elem.src = elem.dataset.lazySrc;
delete elem.dataset.lazySrc;
this.observer.unobserve(elem);
}
}
});
}, {
rootMargin: '200px', // Load images 200px before they come into view
});
}
/**
* Setup scroll listeners for virtual scrolling
*/
setupScrollListeners() {
const container = document.querySelector('.files-list');
if (container) {
container.addEventListener('scroll', this.handleScroll);
// Also update on resize
window.addEventListener('resize', this.updateVisibleItems.bind(this));
}
}
/**
* Handle scroll events for virtual scrolling
*/
handleScroll() {
const container = document.querySelector('.files-list');
if (!container) return;
this.offsetY = container.scrollTop;
requestAnimationFrame(() => this.updateVisibleItems());
}
/**
* Calculate how many items are visible and render only those
*/
updateVisibleItems() {
if (!this.files || !this.folders) return;
const container = document.querySelector('.files-list');
if (!container) return;
const viewportHeight = container.clientHeight;
const viewportWidth = container.clientWidth;
// Calculate grid columns based on container width and card width
this.gridColumns = Math.floor(viewportWidth / CARD_WIDTH);
if (this.gridColumns < 1) this.gridColumns = 1;
if (this.currentView === 'grid') {
this.updateGridView(viewportHeight);
} else {
this.updateListView(viewportHeight);
}
}
/**
* Update grid view with virtual scrolling
*/
updateGridView(viewportHeight) {
const allItems = [...this.folders, ...this.files];
const rows = Math.ceil(allItems.length / this.gridColumns);
this.totalHeight = rows * CARD_HEIGHT;
// Calculate visible range
const startRow = Math.floor(this.offsetY / CARD_HEIGHT);
const visibleRows = Math.ceil(viewportHeight / CARD_HEIGHT) + 1; // +1 for partial rows
const startIdx = startRow * this.gridColumns;
const endIdx = Math.min(allItems.length, (startRow + visibleRows) * this.gridColumns);
// Generate a map of visible items
const newVisibleItems = {};
for (let i = startIdx; i < endIdx; i++) {
newVisibleItems[i] = true;
}
// Remove items that are no longer visible
if (this.gridContainer) {
const children = Array.from(this.gridContainer.children);
children.forEach(child => {
const idx = parseInt(child.dataset.index, 10);
if (!newVisibleItems[idx]) {
this.gridContainer.removeChild(child);
} else {
// Item is still visible, remove from new items list
delete newVisibleItems[idx];
}
});
}
// Add new visible items
const fragment = document.createDocumentFragment();
Object.keys(newVisibleItems).forEach(idx => {
const i = parseInt(idx, 10);
if (i < allItems.length) {
const item = allItems[i];
const elem = this.renderGridItem(item, i);
fragment.appendChild(elem);
}
});
if (this.gridContainer) {
this.gridContainer.appendChild(fragment);
}
this.visibleItems = { ...this.visibleItems, ...newVisibleItems };
}
/**
* Update list view with virtual scrolling
*/
updateListView(viewportHeight) {
const allItems = [...this.folders, ...this.files];
this.totalHeight = allItems.length * ROW_HEIGHT;
// Calculate visible range
const startIdx = Math.floor(this.offsetY / ROW_HEIGHT);
const visibleCount = Math.ceil(viewportHeight / ROW_HEIGHT) + 1; // +1 for partial rows
const endIdx = Math.min(allItems.length, startIdx + visibleCount);
// Generate a map of visible items
const newVisibleItems = {};
for (let i = startIdx; i < endIdx; i++) {
newVisibleItems[i] = true;
}
// Remove items that are no longer visible
if (this.listContainer) {
const children = Array.from(this.listContainer.children);
// Skip the first child as it's the header
for (let i = 1; i < children.length; i++) {
const child = children[i];
const idx = parseInt(child.dataset.index, 10);
if (!newVisibleItems[idx]) {
this.listContainer.removeChild(child);
} else {
// Item is still visible, remove from new items list
delete newVisibleItems[idx];
}
}
}
// Add new visible items
const fragment = document.createDocumentFragment();
Object.keys(newVisibleItems).forEach(idx => {
const i = parseInt(idx, 10);
if (i < allItems.length) {
const item = allItems[i];
const elem = this.renderListItem(item, i);
fragment.appendChild(elem);
}
});
if (this.listContainer) {
this.listContainer.appendChild(fragment);
}
this.visibleItems = { ...this.visibleItems, ...newVisibleItems };
}
/**
* Render a single grid item (file or folder)
*/
renderGridItem(item, index) {
const elem = document.createElement('div');
elem.className = 'file-card';
elem.dataset.index = index;
const isFolder = 'parent_id' in item; // Folders have parent_id
if (isFolder) {
elem.dataset.folderId = item.id;
elem.dataset.folderName = item.name;
elem.dataset.parentId = item.parent_id || "";
elem.innerHTML = `
<div class="file-icon folder-icon">
<i class="fas fa-folder"></i>
</div>
<div class="file-name">${item.name}</div>
`;
// Make draggable
if (item.parent_id) {
elem.setAttribute('draggable', 'true');
elem.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', item.id);
e.dataTransfer.setData('application/oxicloud-folder', 'true');
elem.classList.add('dragging');
});
elem.addEventListener('dragend', () => {
elem.classList.remove('dragging');
document.querySelectorAll('.drop-target').forEach(el => {
el.classList.remove('drop-target');
});
});
}
// Click event
elem.addEventListener('click', () => {
if (typeof window.selectFolder === 'function') {
window.selectFolder(item.id, item.name);
}
});
} else {
// File card
elem.dataset.fileId = item.id;
elem.dataset.fileName = item.name;
elem.dataset.folderId = item.folder_id || "";
// Determine icon based on MIME type
let iconClass = 'fas fa-file';
if (item.mime_type) {
if (item.mime_type.startsWith('image/')) {
iconClass = 'fas fa-file-image';
} else if (item.mime_type.startsWith('text/')) {
iconClass = 'fas fa-file-alt';
} else if (item.mime_type.startsWith('video/')) {
iconClass = 'fas fa-file-video';
} else if (item.mime_type.startsWith('audio/')) {
iconClass = 'fas fa-file-audio';
} else if (item.mime_type === 'application/pdf') {
iconClass = 'fas fa-file-pdf';
}
}
elem.innerHTML = `
<div class="file-icon">
<i class="${iconClass}"></i>
</div>
<div class="file-name">${item.name}</div>
`;
// Make draggable
elem.setAttribute('draggable', 'true');
elem.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', item.id);
elem.classList.add('dragging');
});
elem.addEventListener('dragend', () => {
elem.classList.remove('dragging');
document.querySelectorAll('.drop-target').forEach(el => {
el.classList.remove('drop-target');
});
});
// Click event (download)
elem.addEventListener('click', () => {
window.location.href = `/api/files/${item.id}`;
});
}
// Add to intersection observer (for future thumbnail support)
this.observer.observe(elem);
return elem;
}
/**
* Render a single list item (file or folder)
*/
renderListItem(item, index) {
const elem = document.createElement('div');
elem.className = 'file-item';
elem.dataset.index = index;
const isFolder = 'parent_id' in item; // Folders have parent_id
if (isFolder) {
elem.dataset.folderId = item.id;
elem.dataset.folderName = item.name;
elem.dataset.parentId = item.parent_id || "";
// Format date
const modifiedDate = new Date(item.modified_at * 1000);
const formattedDate = modifiedDate.toLocaleDateString() + ' ' +
modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
elem.innerHTML = `
<div class="name-cell">
<div class="file-icon folder-icon">
<i class="fas fa-folder"></i>
</div>
<span>${item.name}</span>
</div>
<div>${this.i18n.t('files.file_types.folder')}</div>
<div>--</div>
<div>${formattedDate}</div>
`;
// Make draggable
if (item.parent_id) {
elem.setAttribute('draggable', 'true');
elem.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', item.id);
e.dataTransfer.setData('application/oxicloud-folder', 'true');
elem.classList.add('dragging');
});
elem.addEventListener('dragend', () => {
elem.classList.remove('dragging');
document.querySelectorAll('.drop-target').forEach(el => {
el.classList.remove('drop-target');
});
});
}
// Click event
elem.addEventListener('click', () => {
if (typeof window.selectFolder === 'function') {
window.selectFolder(item.id, item.name);
}
});
} else {
// File item
elem.dataset.fileId = item.id;
elem.dataset.fileName = item.name;
elem.dataset.folderId = item.folder_id || "";
// Determine file type label based on MIME type
let typeLabel = this.i18n.t('files.file_types.document');
let iconClass = 'fas fa-file';
if (item.mime_type) {
if (item.mime_type.startsWith('image/')) {
iconClass = 'fas fa-file-image';
typeLabel = this.i18n.t('files.file_types.image');
} else if (item.mime_type.startsWith('text/')) {
iconClass = 'fas fa-file-alt';
typeLabel = this.i18n.t('files.file_types.text');
} else if (item.mime_type.startsWith('video/')) {
iconClass = 'fas fa-file-video';
typeLabel = this.i18n.t('files.file_types.video');
} else if (item.mime_type.startsWith('audio/')) {
iconClass = 'fas fa-file-audio';
typeLabel = this.i18n.t('files.file_types.audio');
} else if (item.mime_type === 'application/pdf') {
iconClass = 'fas fa-file-pdf';
typeLabel = this.i18n.t('files.file_types.pdf');
}
}
// Format file size
const fileSize = this.formatFileSize(item.size);
// Format date
const modifiedDate = new Date(item.modified_at * 1000);
const formattedDate = modifiedDate.toLocaleDateString() + ' ' +
modifiedDate.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
elem.innerHTML = `
<div class="name-cell">
<div class="file-icon">
<i class="${iconClass}"></i>
</div>
<span>${item.name}</span>
</div>
<div>${typeLabel}</div>
<div>${fileSize}</div>
<div>${formattedDate}</div>
`;
// Make draggable
elem.setAttribute('draggable', 'true');
elem.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', item.id);
elem.classList.add('dragging');
});
elem.addEventListener('dragend', () => {
elem.classList.remove('dragging');
document.querySelectorAll('.drop-target').forEach(el => {
el.classList.remove('drop-target');
});
});
// Click event (download)
elem.addEventListener('click', () => {
window.location.href = `/api/files/${item.id}`;
});
}
return elem;
}
/**
* Format file size in human-readable format
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Set current view mode (grid or list)
*/
setView(view) {
this.currentView = view;
this.updateVisibleItems();
}
/**
* Load and render files and folders
*/
loadData(folders, files) {
this.folders = folders || [];
this.files = files || [];
// Reset containers
if (this.gridContainer) {
this.gridContainer.innerHTML = '';
}
if (this.listContainer) {
// Preserve the header
const header = this.listContainer.querySelector('.list-header');
this.listContainer.innerHTML = '';
if (header) {
this.listContainer.appendChild(header);
}
}
this.visibleItems = {};
this.updateVisibleItems();
}
}
// Create the file renderer when the DOM is ready
document.addEventListener('DOMContentLoaded', () => {
window.fileRenderer = new FileRenderer();
// Expose the selectFolder function for navigation
window.selectFolder = (id, name) => {
// Update current path
window.currentPath = id;
// Update breadcrumb
const breadcrumb = document.querySelector('.breadcrumb');
if (breadcrumb) {
const home = breadcrumb.querySelector('.breadcrumb-item');
breadcrumb.innerHTML = '';
if (home) {
breadcrumb.appendChild(home);
if (name) {
const separator = document.createElement('span');
separator.className = 'breadcrumb-separator';
separator.textContent = '>';
breadcrumb.appendChild(separator);
const folderItem = document.createElement('span');
folderItem.className = 'breadcrumb-item';
folderItem.textContent = name;
breadcrumb.appendChild(folderItem);
}
}
}
// Load files for this folder
window.loadFiles();
};
});
+240
View File
@@ -0,0 +1,240 @@
/**
* OxiCloud Internationalization (i18n) Module
*
* This module provides functionality for internationalization of the OxiCloud web interface.
* It loads translations from the server and provides functions to translate keys.
*/
// Current locale code (default to browser locale if available, fallback to English)
let currentLocale =
(navigator.language && navigator.language.substring(0, 2)) ||
(navigator.userLanguage && navigator.userLanguage.substring(0, 2)) ||
'en';
// Supported locales
const supportedLocales = ['en', 'es'];
// Fallback to English if locale is not supported
if (!supportedLocales.includes(currentLocale)) {
currentLocale = 'en';
}
// Cache for translations
const translations = {};
/**
* Load translations for a specific locale
* @param {string} locale - The locale code to load (e.g., 'en', 'es')
* @returns {Promise<object>} - A promise that resolves to the translations object
*/
async function loadTranslations(locale) {
// Check if already loaded
if (translations[locale]) {
return translations[locale];
}
try {
const response = await fetch(`/api/i18n/locales/${locale}`);
if (!response.ok) {
throw new Error(`Failed to load translations for ${locale}`);
}
// Fetch the actual JSON file directly if the API doesn't provide a full translations object
const localeData = await fetch(`/locales/${locale}.json`);
if (!localeData.ok) {
throw new Error(`Failed to load locale file for ${locale}`);
}
translations[locale] = await localeData.json();
return translations[locale];
} catch (error) {
console.error('Error loading translations:', error);
// Try to load from file directly as fallback
try {
const fallbackResponse = await fetch(`/locales/${locale}.json`);
if (fallbackResponse.ok) {
translations[locale] = await fallbackResponse.json();
return translations[locale];
}
} catch (fallbackError) {
console.error('Error loading fallback translations:', fallbackError);
}
// Return empty object as last resort
translations[locale] = {};
return translations[locale];
}
}
/**
* Get a nested translation value
* @param {object} obj - The translations object
* @param {string} path - The dot-notation path to the translation
* @returns {string|null} - The translation value or null if not found
*/
function getNestedValue(obj, path) {
const keys = path.split('.');
let current = obj;
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key];
} else {
return null;
}
}
return typeof current === 'string' ? current : null;
}
/**
* Translate a key to the current locale
* @param {string} key - The translation key (dot notation, e.g., 'app.title')
* @param {object} params - Parameters to replace in the translation (e.g., {name: 'John'})
* @returns {string} - The translated string or the key itself if not found
*/
function t(key, params = {}) {
// Get translation from cache
const localeData = translations[currentLocale];
if (!localeData) {
// Translation not loaded yet, return key
console.warn(`Translations for ${currentLocale} not loaded yet`);
return key;
}
// Get the translation value
const value = getNestedValue(localeData, key);
if (!value) {
// Try fallback to English
if (currentLocale !== 'en' && translations['en']) {
const fallbackValue = getNestedValue(translations['en'], key);
if (fallbackValue) {
return interpolate(fallbackValue, params);
}
}
// Key not found, return key
console.warn(`Translation key not found: ${key}`);
return key;
}
// Replace parameters
return interpolate(value, params);
}
/**
* Replace parameters in a translation string
* @param {string} text - The translation string with placeholders
* @param {object} params - The parameters to replace
* @returns {string} - The interpolated string
*/
function interpolate(text, params) {
return text.replace(/{{\s*([^}]+)\s*}}/g, (_, key) => {
return params[key.trim()] !== undefined ? params[key.trim()] : `{{${key}}}`;
});
}
/**
* Change the current locale
* @param {string} locale - The locale code to switch to
* @returns {Promise<boolean>} - A promise that resolves to true if successful
*/
async function setLocale(locale) {
if (!supportedLocales.includes(locale)) {
console.error(`Locale not supported: ${locale}`);
return false;
}
// Load translations if not loaded yet
if (!translations[locale]) {
await loadTranslations(locale);
}
// Update current locale
currentLocale = locale;
// Save locale preference
localStorage.setItem('oxicloud-locale', locale);
// Trigger an event for components to update
window.dispatchEvent(new CustomEvent('localeChanged', { detail: { locale } }));
// Update all elements with data-i18n attribute
translatePage();
return true;
}
/**
* Initialize the i18n system
* @returns {Promise<void>}
*/
async function initI18n() {
// Load saved locale preference
const savedLocale = localStorage.getItem('oxicloud-locale');
if (savedLocale && supportedLocales.includes(savedLocale)) {
currentLocale = savedLocale;
}
// Load translations for current locale
await loadTranslations(currentLocale);
// Preload English translations as fallback
if (currentLocale !== 'en') {
await loadTranslations('en');
}
// Translate the page
translatePage();
console.log(`I18n initialized with locale: ${currentLocale}`);
}
/**
* Translate all elements with data-i18n attribute
*/
function translatePage() {
document.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n');
element.textContent = t(key);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach(element => {
const key = element.getAttribute('data-i18n-placeholder');
element.placeholder = t(key);
});
document.querySelectorAll('[data-i18n-title]').forEach(element => {
const key = element.getAttribute('data-i18n-title');
element.title = t(key);
});
}
/**
* Get current locale
* @returns {string} - The current locale code
*/
function getCurrentLocale() {
return currentLocale;
}
/**
* Get list of supported locales
* @returns {Array<string>} - Array of supported locale codes
*/
function getSupportedLocales() {
return [...supportedLocales];
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initI18n);
// Export functions for use in other modules
window.i18n = {
t,
setLocale,
getCurrentLocale,
getSupportedLocales,
translatePage
};
+66
View File
@@ -0,0 +1,66 @@
/**
* Language Selector Component for OxiCloud
*/
// Language codes and names
const languages = [
{ code: 'en', name: 'English' },
{ code: 'es', name: 'Español' }
];
/**
* Creates and initializes a language selector component
* @param {string} containerId - ID of the container element
*/
function createLanguageSelector(containerId = 'language-selector') {
// Get or create container
let container = document.getElementById(containerId);
if (!container) {
console.warn(`Container with ID "${containerId}" not found, creating one.`);
container = document.createElement('div');
container.id = containerId;
document.body.appendChild(container);
}
// Create dropdown
const select = document.createElement('select');
select.className = 'language-select';
select.setAttribute('aria-label', 'Select language');
// Add options
languages.forEach(lang => {
const option = document.createElement('option');
option.value = lang.code;
option.textContent = lang.name;
select.appendChild(option);
});
// Set current language
const currentLocale = window.i18n ? window.i18n.getCurrentLocale() : 'en';
select.value = currentLocale;
// Add change event
select.addEventListener('change', async (e) => {
const locale = e.target.value;
if (window.i18n) {
await window.i18n.setLocale(locale);
}
});
// Add to container
container.innerHTML = '';
container.appendChild(select);
// Add event listener for locale changes
window.addEventListener('localeChanged', (e) => {
select.value = e.detail.locale;
});
return container;
}
// Create language selector when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
// Create language selector
createLanguageSelector();
});