Initial commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<?xml version=\1.0\ encoding=\UTF-8\?>
|
||||
<svg width=\16\ height=\16\ xmlns=\http://www.w3.org/2000/svg\>
|
||||
<rect width=\16\ height=\16\ fill=\#556ee6\ rx=\3\ ry=\3\ />
|
||||
<path d=\M 3,4 V 12 H 13 V 6 H 8 L 6,4 Z\ fill=\#ffffff\ />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 235 B |
@@ -0,0 +1,8 @@
|
||||
while IFS= read -r -d '' file; do
|
||||
if grep -Iq . "$file"; then
|
||||
echo "===== $file ====="
|
||||
cat "$file"
|
||||
echo -e "\n"
|
||||
fi
|
||||
done < <(find . -type f -print0)
|
||||
|
||||
+1991
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
};
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "OxiCloud",
|
||||
"description": "Minimalist cloud storage system"
|
||||
},
|
||||
"nav": {
|
||||
"files": "Files",
|
||||
"shared": "Shared",
|
||||
"recent": "Recent",
|
||||
"favorites": "Favorites",
|
||||
"trash": "Trash"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Search files...",
|
||||
"new_folder": "New folder",
|
||||
"upload": "Upload",
|
||||
"rename": "Rename",
|
||||
"move": "Move to...",
|
||||
"move_to": "Move to",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"files": {
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"size": "Size",
|
||||
"modified": "Modified",
|
||||
"no_files": "No files in this folder",
|
||||
"view_grid": "Grid view",
|
||||
"view_list": "List view",
|
||||
"file_types": {
|
||||
"document": "Document",
|
||||
"image": "Image",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"pdf": "PDF",
|
||||
"text": "Text",
|
||||
"folder": "Folder"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Rename folder",
|
||||
"new_name": "New name",
|
||||
"move_file": "Move file",
|
||||
"select_destination": "Select destination folder",
|
||||
"root": "Root",
|
||||
"delete_confirmation": "Are you sure you want to delete",
|
||||
"and_contents": "and all its contents",
|
||||
"no_undo": "This action cannot be undone"
|
||||
},
|
||||
"dropzone": {
|
||||
"drag_files": "Drag files here or click to select",
|
||||
"drop_files": "Drop files to upload"
|
||||
},
|
||||
"errors": {
|
||||
"file_not_found": "File not found",
|
||||
"folder_not_found": "Folder not found",
|
||||
"delete_error": "Error deleting",
|
||||
"upload_error": "Error uploading file",
|
||||
"rename_error": "Error renaming",
|
||||
"move_error": "Error moving",
|
||||
"empty_name": "Name cannot be empty",
|
||||
"name_exists": "A file or folder with that name already exists",
|
||||
"generic_error": "An error has occurred"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Home"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "OxiCloud",
|
||||
"description": "Sistema de almacenamiento en la nube minimalista"
|
||||
},
|
||||
"nav": {
|
||||
"files": "Archivos",
|
||||
"shared": "Compartidos",
|
||||
"recent": "Recientes",
|
||||
"favorites": "Favoritos",
|
||||
"trash": "Papelera"
|
||||
},
|
||||
"actions": {
|
||||
"search": "Buscar archivos...",
|
||||
"new_folder": "Nueva carpeta",
|
||||
"upload": "Subir",
|
||||
"rename": "Renombrar",
|
||||
"move": "Mover a...",
|
||||
"move_to": "Mover a",
|
||||
"delete": "Eliminar",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar"
|
||||
},
|
||||
"files": {
|
||||
"name": "Nombre",
|
||||
"type": "Tipo",
|
||||
"size": "Tamaño",
|
||||
"modified": "Modificado",
|
||||
"no_files": "No hay archivos en esta carpeta",
|
||||
"view_grid": "Vista de cuadrícula",
|
||||
"view_list": "Vista de lista",
|
||||
"file_types": {
|
||||
"document": "Documento",
|
||||
"image": "Imagen",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"pdf": "PDF",
|
||||
"text": "Texto",
|
||||
"folder": "Carpeta"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renombrar carpeta",
|
||||
"new_name": "Nuevo nombre",
|
||||
"move_file": "Mover archivo",
|
||||
"select_destination": "Selecciona la carpeta destino",
|
||||
"root": "Raíz",
|
||||
"delete_confirmation": "¿Estás seguro de que quieres eliminar",
|
||||
"and_contents": "y todo su contenido",
|
||||
"no_undo": "Esta acción no se puede deshacer"
|
||||
},
|
||||
"dropzone": {
|
||||
"drag_files": "Arrastra archivos aquí o haz clic para seleccionar",
|
||||
"drop_files": "Suelta los archivos para subirlos"
|
||||
},
|
||||
"errors": {
|
||||
"file_not_found": "Archivo no encontrado",
|
||||
"folder_not_found": "Carpeta no encontrada",
|
||||
"delete_error": "Error al eliminar",
|
||||
"upload_error": "Error al subir el archivo",
|
||||
"rename_error": "Error al renombrar",
|
||||
"move_error": "Error al mover",
|
||||
"empty_name": "El nombre no puede estar vacío",
|
||||
"name_exists": "Ya existe un archivo o carpeta con ese nombre",
|
||||
"generic_error": "Ha ocurrido un error"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "Inicio"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
|
||||
<!-- Fondo circular -->
|
||||
<circle cx="250" cy="250" r="200" fill="#f8f9fa" />
|
||||
|
||||
<!-- Forma de nube principal - inspirada en Nextcloud pero con estilo propio -->
|
||||
<path d="M330 280c27.6 0 50-22.4 50-50s-22.4-50-50-50c-5.3 0-10.3 0.8-15.1 2.3C307.4 154.5 282.6 135 253 135c-29.6 0-54.4 19.5-62.9 46.3C185.3 180.8 180.3 180 175 180c-27.6 0-50 22.4-50 50s22.4 50 50 50h155z" fill="#ff5e3a" />
|
||||
|
||||
<!-- Texto del logotipo - solo el nombre -->
|
||||
<text x="250" y="350" font-family="Arial, sans-serif" font-size="55" font-weight="bold" text-anchor="middle" fill="#2b3a4a">OxiCloud</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 657 B |
@@ -0,0 +1,97 @@
|
||||
// OxiCloud Service Worker
|
||||
const CACHE_NAME = 'oxicloud-cache-v1';
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/js/i18n.js',
|
||||
'/js/languageSelector.js',
|
||||
'/locales/en.json',
|
||||
'/locales/es.json',
|
||||
'/favicon.ico',
|
||||
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css',
|
||||
'https://cdn.jsdelivr.net/npm/alpinejs@3.12.3/dist/cdn.min.js'
|
||||
];
|
||||
|
||||
// Install event - cache assets
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('Cache opened');
|
||||
return cache.addAll(ASSETS_TO_CACHE);
|
||||
})
|
||||
.then(() => self.skipWaiting()) // Activate immediately
|
||||
);
|
||||
});
|
||||
|
||||
// Activate event - clean old caches
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.filter(cacheName => {
|
||||
return cacheName !== CACHE_NAME;
|
||||
}).map(cacheName => {
|
||||
return caches.delete(cacheName);
|
||||
})
|
||||
);
|
||||
}).then(() => self.clients.claim()) // Take control of clients
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event - serve from cache, update cache from network
|
||||
self.addEventListener('fetch', event => {
|
||||
// Don't intercept API requests - let them go straight to the network
|
||||
if (event.request.url.includes('/api/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
// Cache hit - return the response from the cached version
|
||||
if (response) {
|
||||
// For non-core assets, still fetch from network for updates
|
||||
if (!ASSETS_TO_CACHE.includes(new URL(event.request.url).pathname)) {
|
||||
fetch(event.request).then(networkResponse => {
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const clonedResponse = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then(cache => {
|
||||
cache.put(event.request, clonedResponse);
|
||||
});
|
||||
}
|
||||
}).catch(() => {
|
||||
// Ignore network fetch errors - we already have a cached version
|
||||
});
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// Not in cache - get from network and add to cache
|
||||
return fetch(event.request).then(response => {
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Clone the response as it's a stream and can only be consumed once
|
||||
const responseToCache = response.clone();
|
||||
|
||||
caches.open(CACHE_NAME).then(cache => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Background sync for failed requests
|
||||
self.addEventListener('sync', event => {
|
||||
if (event.tag === 'oxicloud-sync') {
|
||||
event.waitUntil(
|
||||
// Implement background sync for pending file operations
|
||||
Promise.resolve() // Placeholder for actual implementation
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user