This commit is contained in:
2025-11-09 23:29:07 +01:00
commit adc861d4b4
39 changed files with 4390 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
class APIClient {
constructor(baseURL = '/') {
this.baseURL = baseURL;
this.token = localStorage.getItem('token');
}
setToken(token) {
this.token = token;
if (token) {
localStorage.setItem('token', token);
} else {
localStorage.removeItem('token');
}
}
getToken() {
return this.token;
}
async request(endpoint, options = {}) {
const url = `${this.baseURL}${endpoint}`;
const headers = {
...options.headers,
};
if (this.token && !options.skipAuth) {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (!(options.body instanceof FormData) && options.body) {
headers['Content-Type'] = 'application/json';
}
const config = {
...options,
headers,
};
if (config.body && !(config.body instanceof FormData)) {
config.body = JSON.stringify(config.body);
}
const response = await fetch(url, config);
if (response.status === 401) {
this.setToken(null);
window.location.href = '/';
}
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(error.detail || 'Request failed');
}
if (response.status === 204) {
return null;
}
return response.json();
}
async register(username, email, password) {
const data = await this.request('auth/register', {
method: 'POST',
body: { username, email, password },
skipAuth: true
});
this.setToken(data.access_token);
return data;
}
async login(username, password) {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const data = await this.request('auth/token', {
method: 'POST',
body: formData,
skipAuth: true
});
this.setToken(data.access_token);
return data;
}
logout() {
this.setToken(null);
window.location.href = '/';
}
async getCurrentUser() {
return this.request('users/me');
}
async listFolders(parentId = null) {
const params = parentId ? `?parent_id=${parentId}` : '';
return this.request(`folders/${params}`);
}
async createFolder(name, parentId = null) {
return this.request('folders/', {
method: 'POST',
body: { name, parent_id: parentId }
});
}
async deleteFolder(folderId) {
return this.request(`folders/${folderId}`, {
method: 'DELETE'
});
}
async uploadFile(file, folderId = null) {
const formData = new FormData();
formData.append('file', file);
const endpoint = folderId ? `files/upload?folder_id=${folderId}` : 'files/upload';
return this.request(endpoint, {
method: 'POST',
body: formData
});
}
async listFiles(folderId = null) {
const params = folderId ? `?folder_id=${folderId}` : '';
return this.request(`files/${params}`);
}
async downloadFile(fileId) {
const url = `${this.baseURL}files/download/${fileId}`;
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${this.token}`
}
});
if (!response.ok) {
throw new Error('Download failed');
}
return response.blob();
}
async deleteFile(fileId) {
return this.request(`files/${fileId}`, {
method: 'DELETE'
});
}
async moveFile(fileId, targetFolderId) {
return this.request(`files/${fileId}/move`, {
method: 'POST',
body: { target_folder_id: targetFolderId }
});
}
async renameFile(fileId, newName) {
return this.request(`files/${fileId}/rename`, {
method: 'POST',
body: { new_name: newName }
});
}
async copyFile(fileId, targetFolderId) {
return this.request(`files/${fileId}/copy`, {
method: 'POST',
body: { target_folder_id: targetFolderId }
});
}
async createShare(fileId = null, folderId = null, expiresAt = null, password = null, permissionLevel = 'viewer') {
return this.request('shares/', {
method: 'POST',
body: {
file_id: fileId,
folder_id: folderId,
expires_at: expiresAt,
password,
permission_level: permissionLevel
}
});
}
async deleteShare(shareId) {
return this.request(`shares/${shareId}`, {
method: 'DELETE'
});
}
async searchFiles(query, filters = {}) {
const params = new URLSearchParams({ q: query, ...filters });
return this.request(`search/files?${params}`);
}
async searchFolders(query) {
return this.request(`search/folders?q=${encodeURIComponent(query)}`);
}
async getPhotos() {
return this.request('files/photos');
}
getThumbnailUrl(fileId) {
return `${this.baseURL}files/thumbnail/${fileId}`;
}
}
export const api = new APIClient();
+201
View File
@@ -0,0 +1,201 @@
import { api } from '../api.js';
export class FileList extends HTMLElement {
constructor() {
super();
this.currentFolderId = null;
this.files = [];
this.folders = [];
}
async connectedCallback() {
await this.loadContents(null);
}
async loadContents(folderId) {
this.currentFolderId = folderId;
try {
this.folders = await api.listFolders(folderId);
this.files = await api.listFiles(folderId);
this.render();
} catch (error) {
console.error('Failed to load contents:', error);
}
}
setFiles(files) {
this.files = files;
this.folders = [];
this.render();
}
render() {
this.innerHTML = `
<div class="file-list-container">
<div class="file-list-header">
<h2>Files</h2>
<div class="file-actions">
<button class="button" id="create-folder-btn">New Folder</button>
<button class="button button-primary" id="upload-btn">Upload</button>
</div>
</div>
<div class="file-grid">
${this.folders.map(folder => this.renderFolder(folder)).join('')}
${this.files.map(file => this.renderFile(file)).join('')}
</div>
</div>
`;
this.attachListeners();
}
renderFolder(folder) {
return `
<div class="file-item folder-item" data-folder-id="${folder.id}">
<div class="file-icon">&#128193;</div>
<div class="file-name">${folder.name}</div>
<div class="file-actions-menu">
<button class="action-btn" data-action="delete-folder" data-id="${folder.id}">Delete</button>
</div>
</div>
`;
}
renderFile(file) {
const icon = this.getFileIcon(file.mime_type);
const size = this.formatFileSize(file.size);
return `
<div class="file-item" data-file-id="${file.id}">
<div class="file-icon">${icon}</div>
<div class="file-name">${file.name}</div>
<div class="file-size">${size}</div>
<div class="file-actions-menu">
<button class="action-btn" data-action="download" data-id="${file.id}">Download</button>
<button class="action-btn" data-action="rename" data-id="${file.id}">Rename</button>
<button class="action-btn" data-action="delete" data-id="${file.id}">Delete</button>
<button class="action-btn" data-action="share" data-id="${file.id}">Share</button>
</div>
</div>
`;
}
getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return '&#128247;';
if (mimeType.startsWith('video/')) return '&#127909;';
if (mimeType.startsWith('audio/')) return '&#127925;';
if (mimeType.includes('pdf')) return '&#128196;';
if (mimeType.includes('text')) return '&#128196;';
return '&#128196;';
}
formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
attachListeners() {
this.querySelector('#upload-btn')?.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('upload-request'));
});
this.querySelector('#create-folder-btn')?.addEventListener('click', async () => {
await this.handleCreateFolder();
});
this.querySelectorAll('.folder-item').forEach(item => {
item.addEventListener('dblclick', () => {
const folderId = parseInt(item.dataset.folderId);
this.dispatchEvent(new CustomEvent('folder-open', { detail: { folderId } }));
});
});
this.querySelectorAll('.file-item:not(.folder-item)').forEach(item => {
item.addEventListener('click', (e) => {
if (!e.target.classList.contains('action-btn')) {
const fileId = parseInt(item.dataset.fileId);
const file = this.files.find(f => f.id === fileId);
this.dispatchEvent(new CustomEvent('photo-click', {
detail: { photo: file },
bubbles: true
}));
}
});
});
this.querySelectorAll('.action-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
const action = btn.dataset.action;
const id = parseInt(btn.dataset.id);
await this.handleAction(action, id);
});
});
}
triggerCreateFolder() {
this.handleCreateFolder();
}
async handleCreateFolder() {
const name = prompt('Enter folder name:');
if (name) {
try {
await api.createFolder(name, this.currentFolderId);
await this.loadContents(this.currentFolderId);
} catch (error) {
alert('Failed to create folder: ' + error.message);
}
}
}
async handleAction(action, id) {
try {
switch (action) {
case 'download':
const blob = await api.downloadFile(id);
const file = this.files.find(f => f.id === id);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
a.click();
URL.revokeObjectURL(url);
break;
case 'rename':
const newName = prompt('Enter new name:');
if (newName) {
await api.renameFile(id, newName);
await this.loadContents(this.currentFolderId);
}
break;
case 'delete':
if (confirm('Are you sure you want to delete this file?')) {
await api.deleteFile(id);
await this.loadContents(this.currentFolderId);
}
break;
case 'delete-folder':
if (confirm('Are you sure you want to delete this folder?')) {
await api.deleteFolder(id);
await this.loadContents(this.currentFolderId);
}
break;
case 'share':
this.dispatchEvent(new CustomEvent('share-request', { detail: { fileId: id } }));
break;
}
} catch (error) {
alert('Action failed: ' + error.message);
}
}
}
customElements.define('file-list', FileList);
+174
View File
@@ -0,0 +1,174 @@
import { api } from '../api.js';
class FilePreview extends HTMLElement {
constructor() {
super();
this.file = null;
this.handleEscape = this.handleEscape.bind(this);
}
connectedCallback() {
this.render();
this.setupEventListeners();
}
setupEventListeners() {
const closeBtn = this.querySelector('.close-preview');
const modal = this.querySelector('.preview-modal');
const downloadBtn = this.querySelector('.download-btn');
const shareBtn = this.querySelector('.share-btn');
closeBtn.addEventListener('click', () => this.close());
modal.addEventListener('click', (e) => {
if (e.target === modal) this.close();
});
downloadBtn.addEventListener('click', () => this.downloadFile());
shareBtn.addEventListener('click', () => this.shareFile());
}
handleEscape(e) {
if (e.key === 'Escape' && this.style.display === 'block') {
this.close();
}
}
async show(file) {
this.file = file;
this.style.display = 'block';
document.addEventListener('keydown', this.handleEscape);
this.renderPreview();
}
close() {
this.style.display = 'none';
this.file = null;
document.removeEventListener('keydown', this.handleEscape);
}
async renderPreview() {
const previewContent = this.querySelector('.preview-content');
const fileName = this.querySelector('.preview-file-name');
const fileInfo = this.querySelector('.preview-file-info');
fileName.textContent = this.file.name;
fileInfo.textContent = `${this.formatFileSize(this.file.size)}${new Date(this.file.created_at).toLocaleDateString()}`;
if (this.file.mime_type.startsWith('image/')) {
previewContent.innerHTML = `<img alt="${this.file.name}">`;
this.loadAuthenticatedMedia(previewContent.querySelector('img'));
} else if (this.file.mime_type.startsWith('video/')) {
previewContent.innerHTML = `
<video controls>
Your browser does not support the video tag.
</video>
`;
this.loadAuthenticatedMedia(previewContent.querySelector('video'));
} else if (this.file.mime_type.startsWith('audio/')) {
previewContent.innerHTML = `
<audio controls>
Your browser does not support the audio tag.
</audio>
`;
this.loadAuthenticatedMedia(previewContent.querySelector('audio'));
} else if (this.file.mime_type === 'application/pdf') {
previewContent.innerHTML = `<iframe type="application/pdf"></iframe>`;
this.loadAuthenticatedMedia(previewContent.querySelector('iframe'));
} else if (this.file.mime_type.startsWith('text/')) {
this.loadTextPreview(previewContent);
} else {
previewContent.innerHTML = `
<div class="no-preview">
<p>Preview not available for this file type</p>
<button class="btn btn-primary" onclick="this.getRootNode().host.downloadFile()">Download File</button>
</div>
`;
}
}
async loadAuthenticatedMedia(element) {
try {
const blob = await api.downloadFile(this.file.id);
const url = URL.createObjectURL(blob);
element.src = url;
} catch (error) {
console.error('Failed to load media:', error);
element.parentElement.innerHTML = '<p>Failed to load preview</p>';
}
}
async loadTextPreview(container) {
try {
const blob = await api.downloadFile(this.file.id);
const text = await blob.text();
container.innerHTML = `<pre>${this.escapeHtml(text)}</pre>`;
} catch (error) {
container.innerHTML = '<p>Failed to load preview</p>';
}
}
async downloadFile() {
try {
const blob = await api.downloadFile(this.file.id);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = this.file.name;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error('Failed to download file:', error);
}
}
shareFile() {
this.dispatchEvent(new CustomEvent('share-file', {
detail: { file: this.file },
bubbles: true
}));
}
formatFileSize(bytes) {
const units = ['B', 'KB', 'MB', 'GB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
render() {
this.innerHTML = `
<div class="preview-modal">
<div class="preview-container">
<div class="preview-header">
<div class="preview-info">
<h3 class="preview-file-name"></h3>
<p class="preview-file-info"></p>
</div>
<div class="preview-actions">
<button class="btn btn-icon download-btn" title="Download">⬇</button>
<button class="btn btn-icon share-btn" title="Share">🔗</button>
<button class="btn btn-icon close-preview" title="Close">✕</button>
</div>
</div>
<div class="preview-content"></div>
</div>
</div>
`;
this.style.display = 'none';
}
}
customElements.define('file-preview', FilePreview);
export { FilePreview };
+125
View File
@@ -0,0 +1,125 @@
import { api } from '../api.js';
export class FileUpload extends HTMLElement {
constructor() {
super();
this.folderId = null;
this.handleEscape = this.handleEscape.bind(this);
this.render();
this.attachListeners();
}
setFolder(folderId) {
this.folderId = folderId;
}
render() {
this.innerHTML = `
<div class="upload-modal" id="upload-modal" style="display: none;">
<div class="upload-modal-content">
<div class="upload-header">
<h3>Upload Files</h3>
<button class="close-btn" id="close-upload">&times;</button>
</div>
<div class="drop-zone" id="drop-zone">
<div class="drop-zone-text">
<p>Drag and drop files here</p>
<p>or</p>
<button class="button button-primary" id="select-files-btn">Select Files</button>
<input type="file" id="file-input" multiple style="display: none;">
</div>
</div>
<div class="upload-list" id="upload-list"></div>
</div>
</div>
`;
}
attachListeners() {
const modal = this.querySelector('#upload-modal');
const dropZone = this.querySelector('#drop-zone');
const fileInput = this.querySelector('#file-input');
const selectBtn = this.querySelector('#select-files-btn');
const closeBtn = this.querySelector('#close-upload');
selectBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => this.handleFiles(e.target.files));
closeBtn.addEventListener('click', () => this.hide());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
this.handleFiles(e.dataTransfer.files);
});
}
handleEscape(e) {
if (e.key === 'Escape') {
const modal = this.querySelector('#upload-modal');
if (modal.style.display === 'flex') {
this.hide();
}
}
}
show() {
this.querySelector('#upload-modal').style.display = 'flex';
document.addEventListener('keydown', this.handleEscape);
}
hide() {
this.querySelector('#upload-modal').style.display = 'none';
this.querySelector('#upload-list').innerHTML = '';
document.removeEventListener('keydown', this.handleEscape);
}
async handleFiles(files) {
const uploadList = this.querySelector('#upload-list');
for (const file of files) {
const itemId = `upload-${Date.now()}-${Math.random()}`;
const item = document.createElement('div');
item.className = 'upload-item';
item.id = itemId;
item.innerHTML = `
<div class="upload-item-name">${file.name}</div>
<div class="upload-item-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: 0%"></div>
</div>
<span class="upload-status">Uploading...</span>
</div>
`;
uploadList.appendChild(item);
try {
await api.uploadFile(file, this.folderId);
const status = item.querySelector('.upload-status');
const progressFill = item.querySelector('.progress-fill');
progressFill.style.width = '100%';
status.textContent = 'Complete';
status.classList.add('success');
} catch (error) {
const status = item.querySelector('.upload-status');
status.textContent = 'Failed: ' + error.message;
status.classList.add('error');
}
}
this.dispatchEvent(new CustomEvent('upload-complete'));
}
}
customElements.define('file-upload', FileUpload);
+102
View File
@@ -0,0 +1,102 @@
import { api } from '../api.js';
export class LoginView extends HTMLElement {
constructor() {
super();
this.render();
this.attachListeners();
}
render() {
this.innerHTML = `
<div class="auth-container">
<div class="auth-box">
<h1>RBox</h1>
<p class="auth-subtitle">Self-hosted cloud storage</p>
<div class="auth-tabs">
<button class="auth-tab active" data-tab="login">Login</button>
<button class="auth-tab" data-tab="register">Register</button>
</div>
<form id="login-form" class="auth-form">
<input type="text" name="username" placeholder="Username" required class="input-field">
<input type="password" name="password" placeholder="Password" required class="input-field">
<button type="submit" class="button button-primary">Login</button>
<div class="error-message" id="login-error"></div>
</form>
<form id="register-form" class="auth-form" style="display: none;">
<input type="text" name="username" placeholder="Username" required class="input-field">
<input type="email" name="email" placeholder="Email" required class="input-field">
<input type="password" name="password" placeholder="Password" required class="input-field">
<button type="submit" class="button button-primary">Register</button>
<div class="error-message" id="register-error"></div>
</form>
</div>
</div>
`;
}
attachListeners() {
const tabs = this.querySelectorAll('.auth-tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => this.switchTab(tab.dataset.tab));
});
this.querySelector('#login-form').addEventListener('submit', (e) => this.handleLogin(e));
this.querySelector('#register-form').addEventListener('submit', (e) => this.handleRegister(e));
}
switchTab(tab) {
const tabs = this.querySelectorAll('.auth-tab');
tabs.forEach(t => t.classList.remove('active'));
this.querySelector(`[data-tab="${tab}"]`).classList.add('active');
const loginForm = this.querySelector('#login-form');
const registerForm = this.querySelector('#register-form');
if (tab === 'login') {
loginForm.style.display = 'block';
registerForm.style.display = 'none';
} else {
loginForm.style.display = 'none';
registerForm.style.display = 'block';
}
}
async handleLogin(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const username = formData.get('username');
const password = formData.get('password');
const errorDiv = this.querySelector('#login-error');
try {
await api.login(username, password);
this.dispatchEvent(new CustomEvent('auth-success'));
} catch (error) {
errorDiv.textContent = error.message;
}
}
async handleRegister(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const username = formData.get('username');
const email = formData.get('email');
const password = formData.get('password');
const errorDiv = this.querySelector('#register-error');
try {
await api.register(username, email, password);
this.dispatchEvent(new CustomEvent('auth-success'));
} catch (error) {
errorDiv.textContent = error.message;
}
}
}
customElements.define('login-view', LoginView);
+90
View File
@@ -0,0 +1,90 @@
import { api } from '../api.js';
class PhotoGallery extends HTMLElement {
constructor() {
super();
this.photos = [];
}
connectedCallback() {
this.render();
this.loadPhotos();
}
async loadPhotos() {
try {
this.photos = await api.getPhotos();
this.renderPhotos();
} catch (error) {
console.error('Failed to load photos:', error);
this.querySelector('.gallery-grid').innerHTML = '<p>Failed to load photos</p>';
}
}
async renderPhotos() {
const grid = this.querySelector('.gallery-grid');
if (this.photos.length === 0) {
grid.innerHTML = '<p class="empty-state">No photos yet</p>';
return;
}
grid.innerHTML = this.photos.map(photo => `
<div class="photo-item" data-file-id="${photo.id}">
<img
data-photo-id="${photo.id}"
alt="${photo.name}"
loading="lazy"
style="background: #f0f0f0;"
>
<div class="photo-info">
<span class="photo-name">${photo.name}</span>
<span class="photo-date">${new Date(photo.created_at).toLocaleDateString()}</span>
</div>
</div>
`).join('');
grid.querySelectorAll('img[data-photo-id]').forEach(async (img) => {
const photoId = img.dataset.photoId;
try {
const response = await fetch(`/files/thumbnail/${photoId}`, {
headers: {
'Authorization': `Bearer ${api.getToken()}`
}
});
if (response.ok) {
const blob = await response.blob();
img.src = URL.createObjectURL(blob);
} else {
img.style.display = 'none';
}
} catch (error) {
img.style.display = 'none';
}
});
grid.querySelectorAll('.photo-item').forEach(item => {
item.addEventListener('click', () => {
const fileId = item.dataset.fileId;
const photo = this.photos.find(p => p.id === parseInt(fileId));
this.dispatchEvent(new CustomEvent('photo-click', {
detail: { photo },
bubbles: true
}));
});
});
}
render() {
this.innerHTML = `
<div class="photo-gallery">
<div class="gallery-header">
<h2>Photo Gallery</h2>
</div>
<div class="gallery-grid"></div>
</div>
`;
}
}
customElements.define('photo-gallery', PhotoGallery);
export { PhotoGallery };
+345
View File
@@ -0,0 +1,345 @@
import { api } from '../api.js';
import './login-view.js';
import './file-list.js';
import './file-upload.js';
import './share-modal.js';
import './photo-gallery.js';
import './file-preview.js';
import { shortcuts } from '../shortcuts.js';
export class RBoxApp extends HTMLElement {
constructor() {
super();
this.currentView = 'files';
this.currentFolderId = null;
this.user = null;
}
async connectedCallback() {
await this.init();
}
async init() {
if (!api.getToken()) {
this.showLogin();
} else {
try {
this.user = await api.getCurrentUser();
this.render();
} catch (error) {
this.showLogin();
}
}
}
showLogin() {
this.innerHTML = '<login-view></login-view>';
const loginView = this.querySelector('login-view');
loginView.addEventListener('auth-success', () => this.init());
}
render() {
this.innerHTML = `
<div class="app-container">
<header class="app-header">
<div class="header-left">
<h1 class="app-title">RBox</h1>
</div>
<div class="header-center">
<input type="search" placeholder="Search..." class="search-input" id="search-input">
</div>
<div class="header-right">
<span class="user-info">${this.user.username}</span>
<button class="button" id="logout-btn">Logout</button>
</div>
</header>
<div class="app-body">
<aside class="app-sidebar">
<nav class="sidebar-nav">
<h3 class="nav-title">Navigation</h3>
<ul class="nav-list">
<li><a href="#" class="nav-link active" data-view="files">My Files</a></li>
<li><a href="#" class="nav-link" data-view="photos">Photo Gallery</a></li>
<li><a href="#" class="nav-link" data-view="shared">Shared Items</a></li>
<li><a href="#" class="nav-link" data-view="deleted">Deleted Files</a></li>
</ul>
<h3 class="nav-title">Quick Access</h3>
<ul class="nav-list">
<li><a href="#" class="nav-link" data-view="starred">Starred</a></li>
<li><a href="#" class="nav-link" data-view="recent">Recent</a></li>
</ul>
</nav>
</aside>
<main class="app-main">
<div id="main-content">
<file-list></file-list>
</div>
</main>
</div>
<file-upload></file-upload>
<share-modal></share-modal>
<file-preview></file-preview>
</div>
`;
this.attachListeners();
this.registerShortcuts();
}
registerShortcuts() {
shortcuts.register('ctrl+u', () => {
const upload = this.querySelector('file-upload');
if (upload) {
upload.setFolder(this.currentFolderId);
upload.show();
}
});
shortcuts.register('ctrl+f', () => {
const searchInput = this.querySelector('#search-input');
if (searchInput) {
searchInput.focus();
}
});
shortcuts.register('ctrl+/', () => {
this.showShortcutsHelp();
});
shortcuts.register('ctrl+shift+n', () => {
if (this.currentView === 'files') {
const fileList = this.querySelector('file-list');
if (fileList) {
fileList.triggerCreateFolder();
}
}
});
shortcuts.register('1', () => {
this.switchView('files');
});
shortcuts.register('2', () => {
this.switchView('photos');
});
shortcuts.register('3', () => {
this.switchView('shared');
});
shortcuts.register('4', () => {
this.switchView('deleted');
});
shortcuts.register('f2', () => {
console.log('Rename shortcut - to be implemented');
});
}
showShortcutsHelp() {
const helpContent = `
<div class="shortcuts-help-modal">
<div class="shortcuts-help-content">
<h2>Keyboard Shortcuts</h2>
<div class="shortcuts-list">
<h3>File Operations</h3>
<div class="shortcut-item">
<kbd>Ctrl + U</kbd>
<span>Upload files</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl + Shift + N</kbd>
<span>Create new folder</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl + F</kbd>
<span>Focus search</span>
</div>
<h3>Navigation</h3>
<div class="shortcut-item">
<kbd>1</kbd>
<span>My Files</span>
</div>
<div class="shortcut-item">
<kbd>2</kbd>
<span>Photo Gallery</span>
</div>
<div class="shortcut-item">
<kbd>3</kbd>
<span>Shared Items</span>
</div>
<div class="shortcut-item">
<kbd>4</kbd>
<span>Deleted Files</span>
</div>
<h3>General</h3>
<div class="shortcut-item">
<kbd>ESC</kbd>
<span>Close modals</span>
</div>
<div class="shortcut-item">
<kbd>Ctrl + /</kbd>
<span>Show this help</span>
</div>
</div>
<button class="button" id="close-shortcuts-help">Close</button>
</div>
</div>
`;
const helpDiv = document.createElement('div');
helpDiv.innerHTML = helpContent;
helpDiv.querySelector('.shortcuts-help-modal').style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
document.body.appendChild(helpDiv);
const closeHelp = () => {
document.body.removeChild(helpDiv);
document.removeEventListener('keydown', handleEscape);
};
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeHelp();
}
};
const closeBtn = helpDiv.querySelector('#close-shortcuts-help');
closeBtn.addEventListener('click', closeHelp);
helpDiv.querySelector('.shortcuts-help-modal').addEventListener('click', (e) => {
if (e.target.classList.contains('shortcuts-help-modal')) closeHelp();
});
document.addEventListener('keydown', handleEscape);
}
attachListeners() {
this.querySelector('#logout-btn')?.addEventListener('click', () => {
api.logout();
});
this.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const view = link.dataset.view;
this.switchView(view);
});
});
const fileList = this.querySelector('file-list');
if (fileList) {
fileList.addEventListener('upload-request', () => {
const upload = this.querySelector('file-upload');
upload.setFolder(this.currentFolderId);
upload.show();
});
fileList.addEventListener('folder-open', (e) => {
this.currentFolderId = e.detail.folderId;
fileList.loadContents(this.currentFolderId);
});
fileList.addEventListener('share-request', (e) => {
const modal = this.querySelector('share-modal');
modal.show(e.detail.fileId);
});
}
const upload = this.querySelector('file-upload');
if (upload) {
upload.addEventListener('upload-complete', () => {
const fileList = this.querySelector('file-list');
fileList.loadContents(this.currentFolderId);
});
}
const searchInput = this.querySelector('#search-input');
if (searchInput) {
let searchTimeout;
searchInput.addEventListener('input', (e) => {
clearTimeout(searchTimeout);
const query = e.target.value.trim();
if (query.length > 0) {
searchTimeout = setTimeout(() => this.performSearch(query), 300);
}
});
}
this.addEventListener('photo-click', (e) => {
const preview = this.querySelector('file-preview');
preview.show(e.detail.photo);
});
this.addEventListener('share-file', (e) => {
const modal = this.querySelector('share-modal');
modal.show(e.detail.file.id);
});
}
async performSearch(query) {
try {
const files = await api.searchFiles(query);
const mainContent = this.querySelector('#main-content');
mainContent.innerHTML = `
<div class="search-results">
<h2>Search Results for "${query}"</h2>
<file-list data-search-mode="true"></file-list>
</div>
`;
const fileList = mainContent.querySelector('file-list');
fileList.setFiles(files);
this.attachListeners();
} catch (error) {
console.error('Search failed:', error);
}
}
switchView(view) {
this.currentView = view;
this.querySelectorAll('.nav-link').forEach(link => {
link.classList.remove('active');
});
this.querySelector(`[data-view="${view}"]`)?.classList.add('active');
const mainContent = this.querySelector('#main-content');
switch (view) {
case 'files':
mainContent.innerHTML = '<file-list></file-list>';
this.attachListeners();
break;
case 'photos':
mainContent.innerHTML = '<photo-gallery></photo-gallery>';
this.attachListeners();
break;
case 'shared':
mainContent.innerHTML = '<div class="placeholder">Shared Items - Coming Soon</div>';
break;
case 'deleted':
mainContent.innerHTML = '<div class="placeholder">Deleted Files - Coming Soon</div>';
break;
case 'starred':
mainContent.innerHTML = '<div class="placeholder">Starred Items - Coming Soon</div>';
break;
case 'recent':
mainContent.innerHTML = '<div class="placeholder">Recent Files - Coming Soon</div>';
break;
}
}
}
+133
View File
@@ -0,0 +1,133 @@
import { api } from '../api.js';
export class ShareModal extends HTMLElement {
constructor() {
super();
this.fileId = null;
this.folderId = null;
this.handleEscape = this.handleEscape.bind(this);
this.render();
this.attachListeners();
}
render() {
this.innerHTML = `
<div class="share-modal" id="share-modal" style="display: none;">
<div class="share-modal-content">
<div class="share-header">
<h3>Share</h3>
<button class="close-btn" id="close-share">&times;</button>
</div>
<form id="share-form">
<div class="form-group">
<label>Permission Level</label>
<select name="permission_level" class="input-field">
<option value="viewer">Viewer</option>
<option value="uploader">Uploader</option>
<option value="editor">Editor</option>
</select>
</div>
<div class="form-group">
<label>Password Protection (optional)</label>
<input type="password" name="password" class="input-field" placeholder="Leave blank for no password">
</div>
<div class="form-group">
<label>Expiration Date (optional)</label>
<input type="datetime-local" name="expires_at" class="input-field">
</div>
<button type="submit" class="button button-primary">Create Share Link</button>
</form>
<div id="share-result" style="display: none;">
<div class="form-group">
<label>Share Link</label>
<div class="share-link-container">
<input type="text" id="share-link" readonly class="input-field">
<button class="button" id="copy-link-btn">Copy</button>
</div>
</div>
</div>
<div class="error-message" id="share-error"></div>
</div>
</div>
`;
}
attachListeners() {
const closeBtn = this.querySelector('#close-share');
closeBtn.addEventListener('click', () => this.hide());
const form = this.querySelector('#share-form');
form.addEventListener('submit', (e) => this.handleCreateShare(e));
const copyBtn = this.querySelector('#copy-link-btn');
copyBtn.addEventListener('click', () => this.copyLink());
}
handleEscape(e) {
if (e.key === 'Escape') {
const modal = this.querySelector('#share-modal');
if (modal.style.display === 'flex') {
this.hide();
}
}
}
show(fileId = null, folderId = null) {
this.fileId = fileId;
this.folderId = folderId;
this.querySelector('#share-modal').style.display = 'flex';
this.querySelector('#share-result').style.display = 'none';
this.querySelector('#share-form').reset();
document.addEventListener('keydown', this.handleEscape);
}
hide() {
this.querySelector('#share-modal').style.display = 'none';
this.fileId = null;
this.folderId = null;
document.removeEventListener('keydown', this.handleEscape);
}
async handleCreateShare(e) {
e.preventDefault();
const form = e.target;
const formData = new FormData(form);
const errorDiv = this.querySelector('#share-error');
try {
const permissionLevel = formData.get('permission_level');
const password = formData.get('password') || null;
const expiresAt = formData.get('expires_at') || null;
const share = await api.createShare(
this.fileId,
this.folderId,
expiresAt,
password,
permissionLevel
);
const shareLink = `${window.location.origin}/share/${share.token}`;
this.querySelector('#share-link').value = shareLink;
this.querySelector('#share-result').style.display = 'block';
errorDiv.textContent = '';
} catch (error) {
errorDiv.textContent = 'Failed to create share link: ' + error.message;
}
}
copyLink() {
const linkInput = this.querySelector('#share-link');
linkInput.select();
document.execCommand('copy');
alert('Link copied to clipboard');
}
}
customElements.define('share-modal', ShareModal);
+23
View File
@@ -0,0 +1,23 @@
import { RBoxApp } from './components/rbox-app.js';
// Define the custom element
customElements.define('rbox-app', RBoxApp);
// Instantiate the main application class
const app = new RBoxApp();
// Append the app to the body (if not already in index.html)
// document.body.appendChild(app);
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/static/service-worker.js')
.then(registration => {
console.log('Service Worker registered with scope:', registration.scope);
})
.catch(error => {
console.log('Service Worker registration failed:', error);
});
});
}
+55
View File
@@ -0,0 +1,55 @@
class KeyboardShortcuts {
constructor() {
this.shortcuts = new Map();
this.enabled = true;
this.init();
}
init() {
document.addEventListener('keydown', (e) => {
if (!this.enabled) return;
const isInput = ['INPUT', 'TEXTAREA'].includes(e.target.tagName);
const key = this.getKeyCombo(e);
if (this.shortcuts.has(key)) {
const { handler, allowInInput } = this.shortcuts.get(key);
if (!isInput || allowInInput) {
e.preventDefault();
handler(e);
}
}
});
}
getKeyCombo(e) {
const parts = [];
if (e.ctrlKey || e.metaKey) parts.push('ctrl');
if (e.altKey) parts.push('alt');
if (e.shiftKey) parts.push('shift');
parts.push(e.key.toLowerCase());
return parts.join('+');
}
register(keyCombo, handler, allowInInput = false) {
this.shortcuts.set(keyCombo.toLowerCase(), { handler, allowInInput });
}
unregister(keyCombo) {
this.shortcuts.delete(keyCombo.toLowerCase());
}
enable() {
this.enabled = true;
}
disable() {
this.enabled = false;
}
getRegisteredShortcuts() {
return Array.from(this.shortcuts.keys());
}
}
export const shortcuts = new KeyboardShortcuts();