feat: add admin/profile UI, i18n (fr/de/pt), fix CI pipelines

- Add admin panel link and profile modal in user menu dropdown
- Add French, German and Portuguese locale support (full translations)
- Register new locales across JS frontend and Rust backend
- Create CI pipeline (fmt, clippy, test, audit, build)
- Fix docker-build.yml (cache, real tests, reduced timeout)
- Fix docker-publish.yml (multi-arch via QEMU, latest tag, pre-publish tests)
- Add dependabot.yml for automated dependency updates
This commit is contained in:
Dionisio
2026-02-11 12:26:29 +01:00
parent 6b55cdf3c4
commit 3050955f9c
21 changed files with 1422 additions and 408 deletions
+95
View File
@@ -143,6 +143,10 @@ function setupUserMenu() {
const logoutBtn = document.getElementById('user-menu-logout');
const themeBtn = document.getElementById('user-menu-theme');
const aboutBtn = document.getElementById('user-menu-about');
const adminBtn = document.getElementById('user-menu-admin');
const adminDivider = document.getElementById('user-menu-admin-divider');
const profileBtn = document.getElementById('user-menu-profile');
const roleBadge = document.getElementById('user-menu-role-badge');
if (!wrapper || !avatarBtn || !menu) return;
@@ -153,6 +157,13 @@ function setupUserMenu() {
wrapper.classList.toggle('open');
if (!isOpen) {
updateUserMenuData();
// Show/hide admin panel button based on user role
const USER_DATA_KEY = 'oxicloud_user';
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
const isAdmin = userData.role === 'admin';
if (adminBtn) adminBtn.style.display = isAdmin ? 'flex' : 'none';
if (adminDivider) adminDivider.style.display = isAdmin ? 'block' : 'none';
if (roleBadge) roleBadge.style.display = isAdmin ? 'block' : 'none';
}
});
@@ -192,6 +203,22 @@ function setupUserMenu() {
});
}
// Admin panel link
if (adminBtn) {
adminBtn.addEventListener('click', () => {
wrapper.classList.remove('open');
window.location.href = '/admin.html';
});
}
// Profile button — shows user info modal
if (profileBtn) {
profileBtn.addEventListener('click', () => {
wrapper.classList.remove('open');
showUserProfileModal();
});
}
// About modal
if (aboutBtn) {
aboutBtn.addEventListener('click', () => {
@@ -1417,6 +1444,74 @@ async function refreshUserData() {
// Expose refreshUserData globally
window.refreshUserData = refreshUserData;
/**
* Show User Profile modal with account details
*/
function showUserProfileModal() {
const USER_DATA_KEY = 'oxicloud_user';
const userData = JSON.parse(localStorage.getItem(USER_DATA_KEY) || '{}');
const username = userData.username || 'Usuario';
const email = userData.email || '';
const role = userData.role || 'user';
const initials = username.substring(0, 2).toUpperCase();
const usedBytes = userData.storage_used_bytes || 0;
const quotaBytes = userData.storage_quota_bytes || 0;
const percentage = quotaBytes > 0 ? Math.min(Math.round((usedBytes / quotaBytes) * 100), 100) : 0;
const barColor = percentage > 90 ? '#ef4444' : percentage > 70 ? '#f59e0b' : '#22c55e';
const t = (key, fallback) => (window.i18n && window.i18n.t) ? window.i18n.t(key) || fallback : fallback;
// Remove existing modal if any
const existing = document.getElementById('profile-modal-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'profile-modal-overlay';
overlay.className = 'about-modal-overlay';
overlay.innerHTML = `
<div class="about-modal" style="max-width:380px">
<div style="text-align:center;padding:20px 20px 0">
<div style="width:64px;height:64px;border-radius:50%;background:linear-gradient(135deg,#3b82f6,#6366f1);color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;margin-bottom:12px">${initials}</div>
<h3 style="margin:0;font-size:18px;color:#1a1a2e">${username}</h3>
<p style="margin:4px 0 0;font-size:13px;color:#64748b">${email}</p>
<span style="display:inline-block;margin-top:8px;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600;${
role === 'admin'
? 'background:#dbeafe;color:#1d4ed8'
: 'background:#f1f5f9;color:#64748b'
}">${role === 'admin' ? '🛡️ Admin' : '👤 ' + t('user_menu.role_user', 'User')}</span>
</div>
<div style="padding:16px 20px">
<div style="font-size:12px;color:#64748b;text-transform:uppercase;letter-spacing:.05em;margin-bottom:6px">
<i class="fas fa-database" style="margin-right:4px"></i>${t('storage.title', 'Storage')}
</div>
<div style="background:#f1f5f9;border-radius:6px;height:8px;overflow:hidden;margin-bottom:4px">
<div style="height:100%;width:${percentage}%;background:${barColor};border-radius:6px;transition:width .3s"></div>
</div>
<div style="font-size:12px;color:#64748b;text-align:right">${percentage}% · ${formatFileSize(usedBytes)} / ${quotaBytes > 0 ? formatFileSize(quotaBytes) : '∞'}</div>
</div>
<div style="padding:0 20px 16px;display:flex;justify-content:center">
<button id="profile-modal-close" style="padding:8px 24px;border:1px solid #e2e8f0;border-radius:8px;background:#fff;color:#334155;font-size:13px;font-weight:600;cursor:pointer;transition:background .15s">${t('actions.close', 'Close')}</button>
</div>
</div>
`;
document.body.appendChild(overlay);
// Show with animation
requestAnimationFrame(() => overlay.classList.add('show'));
// Close handlers
overlay.querySelector('#profile-modal-close').addEventListener('click', () => {
overlay.classList.remove('show');
setTimeout(() => overlay.remove(), 200);
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
overlay.classList.remove('show');
setTimeout(() => overlay.remove(), 200);
}
});
}
/**
* Check if user is authenticated and load user's home folder
*/
+3 -3
View File
@@ -74,9 +74,9 @@ const ALL_LANGUAGES = [
{ code: 'es', name: 'Spanish', nativeName: 'Español', flag: '🇪🇸', popular: true },
{ code: 'zh', name: 'Chinese', nativeName: '中文', flag: '🇨🇳', popular: true },
{ code: 'fa', name: 'Persian', nativeName: 'فارسی', flag: '🇮🇷', popular: true },
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', popular: false },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', popular: false },
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷', popular: false },
{ code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷', popular: true },
{ code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪', popular: true },
{ code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷', popular: true },
{ code: 'it', name: 'Italian', nativeName: 'Italiano', flag: '🇮🇹', popular: false },
{ code: 'ru', name: 'Russian', nativeName: 'Русский', flag: '🇷🇺', popular: false },
{ code: 'ja', name: 'Japanese', nativeName: '日本語', flag: '🇯🇵', popular: false },
+1 -1
View File
@@ -13,7 +13,7 @@ let currentLocale =
// Supported locales (languages that have locale files on the server)
// When a locale file is not found, the system gracefully falls back to English
const supportedLocales = ['en', 'es', 'zh', 'fa'];
const supportedLocales = ['en', 'es', 'zh', 'fa', 'fr', 'de', 'pt'];
// Fallback to English if locale is not supported
if (!supportedLocales.includes(currentLocale)) {
+4 -1
View File
@@ -13,7 +13,10 @@ function getAvailableLanguages() {
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'zh', name: '中文', flag: '🇨🇳' },
{ code: 'fa', name: 'فارسی', flag: '🇮🇷' }
{ code: 'fa', name: 'فارسی', flag: '🇮🇷' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'pt', name: 'Português', flag: '🇧🇷' }
];
}