This commit is contained in:
2026-01-03 22:15:15 +01:00
parent fba501f697
commit 1bcb9e38b4
13 changed files with 1554 additions and 52 deletions
+121
View File
@@ -1,4 +1,5 @@
import { api } from '../api.js';
import { GestureHandler, PullToRefreshIndicator, ContextMenu, isMobile } from '../gesture-handler.js';
export class FileList extends HTMLElement {
constructor() {
@@ -12,6 +13,9 @@ export class FileList extends HTMLElement {
this.boundHandleClick = this.handleClick.bind(this);
this.boundHandleDblClick = this.handleDblClick.bind(this);
this.boundHandleChange = this.handleChange.bind(this);
this.gestureHandler = null;
this.pullIndicator = null;
this.contextMenu = new ContextMenu();
}
async connectedCallback() {
@@ -27,6 +31,12 @@ export class FileList extends HTMLElement {
this.removeEventListener('click', this.boundHandleClick);
this.removeEventListener('dblclick', this.boundHandleDblClick);
this.removeEventListener('change', this.boundHandleChange);
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
if (this.pullIndicator) {
this.pullIndicator.destroy();
}
}
async loadContents(folderId) {
@@ -305,6 +315,117 @@ export class FileList extends HTMLElement {
attachListeners() {
this.updateBatchActionVisibility();
this.initGestures();
}
initGestures() {
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
if (this.pullIndicator) {
this.pullIndicator.destroy();
}
const container = this.querySelector('.file-list-container');
if (!container) return;
this.pullIndicator = new PullToRefreshIndicator(container);
this.gestureHandler = new GestureHandler(container);
this.gestureHandler.on('pullToRefresh', async () => {
this.pullIndicator.showRefreshing();
await this.loadContents(this.currentFolderId);
this.pullIndicator.hide();
});
this.gestureHandler.on('longPress', (data) => {
if (!isMobile()) return;
const fileItem = data.target?.closest('.file-item');
if (!fileItem) return;
const folderId = fileItem.dataset.folderId;
const fileId = fileItem.dataset.fileId;
if (folderId) {
const folder = this.folders.find(f => f.id === parseInt(folderId));
if (folder) {
this.showFolderContextMenu(data.x, data.y, folder);
}
} else if (fileId) {
const file = this.files.find(f => f.id === parseInt(fileId));
if (file) {
this.showFileContextMenu(data.x, data.y, file);
}
}
});
container.addEventListener('pull-progress', (e) => {
this.pullIndicator.setProgress(e.detail.progress);
});
container.addEventListener('pull-end', () => {
if (this.pullIndicator) {
this.pullIndicator.hide();
}
});
}
showFileContextMenu(x, y, file) {
const items = [
{
label: 'Download',
icon: '⬇',
action: () => this.handleAction('download', file.id)
},
{
label: 'Rename',
icon: '✏',
action: () => this.handleAction('rename', file.id)
},
{
label: 'Share',
icon: '🔗',
action: () => this.handleAction('share', file.id)
},
{ separator: true },
{
label: file.is_starred ? 'Unstar' : 'Star',
icon: file.is_starred ? '★' : '☆',
action: () => this.handleAction(file.is_starred ? 'unstar-file' : 'star-file', file.id)
},
{ separator: true },
{
label: 'Delete',
icon: '🗑',
destructive: true,
action: () => this.handleAction('delete', file.id)
}
];
this.contextMenu.show(x, y, items);
}
showFolderContextMenu(x, y, folder) {
const items = [
{
label: 'Open',
icon: '📂',
action: () => this.loadContents(folder.id)
},
{
label: folder.is_starred ? 'Unstar' : 'Star',
icon: folder.is_starred ? '★' : '☆',
action: () => this.handleAction(folder.is_starred ? 'unstar-folder' : 'star-folder', folder.id)
},
{ separator: true },
{
label: 'Delete',
icon: '🗑',
destructive: true,
action: () => this.handleAction('delete-folder', folder.id)
}
];
this.contextMenu.show(x, y, items);
}
toggleSelectItem(type, id, checked) {
+21
View File
@@ -1,15 +1,36 @@
import { api } from '../api.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
class FilePreview extends HTMLElement {
constructor() {
super();
this.file = null;
this.handleEscape = this.handleEscape.bind(this);
this.gestureHandler = null;
}
connectedCallback() {
this.render();
this.setupEventListeners();
this.initGestures();
}
disconnectedCallback() {
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
}
initGestures() {
const overlay = this.querySelector('.file-preview-overlay');
if (!overlay) return;
this.gestureHandler = new GestureHandler(overlay);
this.gestureHandler.on('swipeDown', () => {
if (isMobile()) {
this.close();
}
});
}
setupEventListeners() {
+19
View File
@@ -1,4 +1,5 @@
import { api } from '../api.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
export class FileUploadView extends HTMLElement {
constructor() {
@@ -6,6 +7,7 @@ export class FileUploadView extends HTMLElement {
this.folderId = null;
this.handleEscape = this.handleEscape.bind(this);
this.uploadItems = new Map();
this.gestureHandler = null;
}
connectedCallback() {
@@ -14,6 +16,21 @@ export class FileUploadView extends HTMLElement {
disconnectedCallback() {
document.removeEventListener('keydown', this.handleEscape);
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
}
initGestures() {
const view = this.querySelector('.file-upload-view');
if (!view) return;
this.gestureHandler = new GestureHandler(view);
this.gestureHandler.on('swipeDown', () => {
if (isMobile()) {
this.close();
}
});
}
setFolder(folderId) {
@@ -57,6 +74,8 @@ export class FileUploadView extends HTMLElement {
backBtn.addEventListener('click', () => this.close());
}
this.initGestures();
if (fileInput) {
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
+73 -4
View File
@@ -15,8 +15,9 @@ import './billing-dashboard.js';
import './admin-billing.js';
import './code-editor-view.js';
import './cookie-consent.js';
import './user-settings.js'; // Import the new user settings component
import './user-settings.js';
import { shortcuts } from '../shortcuts.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
const api = app.getAPI();
const logger = app.getLogger();
@@ -31,6 +32,8 @@ export class MyWebdavApp extends HTMLElement {
this.boundHandlePopState = this.handlePopState.bind(this);
this.popstateAttached = false;
this.currentSearchId = 0;
this.gestureHandler = null;
this.sidebarOpen = false;
}
async connectedCallback() {
@@ -127,10 +130,15 @@ export class MyWebdavApp extends HTMLElement {
<div class="app-container">
<header class="app-header">
<div class="header-left">
<button class="hamburger-btn" id="hamburger-btn" aria-label="Toggle navigation">
<span></span>
<span></span>
<span></span>
</button>
<h1 class="app-title">MyWebdav</h1>
</div>
<div class="header-center">
<input type="search" placeholder="Search..." class="search-input" id="search-input">
<input type="search" placeholder="Search..." class="search-input" id="search-input" inputmode="search">
</div>
<div class="header-right">
<span class="user-info">${this.user.username}</span>
@@ -139,7 +147,8 @@ export class MyWebdavApp extends HTMLElement {
</header>
<div class="app-body">
<aside class="app-sidebar">
<div class="sidebar-overlay" id="sidebar-overlay"></div>
<aside class="app-sidebar" id="app-sidebar">
<nav class="sidebar-nav">
<h3 class="nav-title">Navigation</h3>
<ul class="nav-list">
@@ -160,7 +169,7 @@ export class MyWebdavApp extends HTMLElement {
</nav>
</aside>
<main class="app-main">
<main class="app-main" id="app-main">
<div id="main-content">
<file-list></file-list>
</div>
@@ -191,6 +200,55 @@ export class MyWebdavApp extends HTMLElement {
this.initializeNavigation();
this.attachListeners();
this.registerShortcuts();
this.initGestures();
}
initGestures() {
const appMain = this.querySelector('#app-main');
if (!appMain) return;
this.gestureHandler = new GestureHandler(appMain);
this.gestureHandler.on('edgeSwipeRight', () => {
if (isMobile()) {
this.openSidebar();
}
});
}
toggleSidebar() {
if (this.sidebarOpen) {
this.closeSidebar();
} else {
this.openSidebar();
}
}
openSidebar() {
const sidebar = this.querySelector('#app-sidebar');
const overlay = this.querySelector('#sidebar-overlay');
const hamburger = this.querySelector('#hamburger-btn');
if (sidebar && overlay) {
sidebar.classList.add('open');
overlay.classList.add('visible');
hamburger?.classList.add('active');
this.sidebarOpen = true;
document.body.style.overflow = 'hidden';
}
}
closeSidebar() {
const sidebar = this.querySelector('#app-sidebar');
const overlay = this.querySelector('#sidebar-overlay');
const hamburger = this.querySelector('#hamburger-btn');
if (sidebar && overlay) {
sidebar.classList.remove('open');
overlay.classList.remove('visible');
hamburger?.classList.remove('active');
this.sidebarOpen = false;
document.body.style.overflow = '';
}
}
initializeNavigation() {
@@ -402,11 +460,22 @@ export class MyWebdavApp extends HTMLElement {
api.logout();
});
this.querySelector('#hamburger-btn')?.addEventListener('click', () => {
this.toggleSidebar();
});
this.querySelector('#sidebar-overlay')?.addEventListener('click', () => {
this.closeSidebar();
});
this.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const view = link.dataset.view;
this.switchView(view);
if (isMobile()) {
this.closeSidebar();
}
});
});
+34
View File
@@ -1,20 +1,54 @@
import { api } from '../api.js';
import { GestureHandler, PullToRefreshIndicator } from '../gesture-handler.js';
class PhotoGallery extends HTMLElement {
constructor() {
super();
this.photos = [];
this.boundHandleClick = this.handleClick.bind(this);
this.gestureHandler = null;
this.pullIndicator = null;
}
connectedCallback() {
this.addEventListener('click', this.boundHandleClick);
this.render();
this.loadPhotos();
this.initGestures();
}
disconnectedCallback() {
this.removeEventListener('click', this.boundHandleClick);
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
if (this.pullIndicator) {
this.pullIndicator.destroy();
}
}
initGestures() {
const container = this.querySelector('.photo-gallery');
if (!container) return;
this.pullIndicator = new PullToRefreshIndicator(container);
this.gestureHandler = new GestureHandler(container);
this.gestureHandler.on('pullToRefresh', async () => {
this.pullIndicator.showRefreshing();
await this.loadPhotos();
this.pullIndicator.hide();
});
container.addEventListener('pull-progress', (e) => {
this.pullIndicator.setProgress(e.detail.progress);
});
container.addEventListener('pull-end', () => {
if (this.pullIndicator) {
this.pullIndicator.hide();
}
});
}
async loadPhotos() {
+21
View File
@@ -1,4 +1,5 @@
import { api } from '../api.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
export class ShareModal extends HTMLElement {
constructor() {
@@ -6,10 +7,29 @@ export class ShareModal extends HTMLElement {
this.fileId = null;
this.folderId = null;
this.handleEscape = this.handleEscape.bind(this);
this.gestureHandler = null;
this.render();
this.attachListeners();
}
disconnectedCallback() {
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
}
initGestures() {
const modal = this.querySelector('.share-modal-content');
if (!modal || this.gestureHandler) return;
this.gestureHandler = new GestureHandler(modal);
this.gestureHandler.on('swipeDown', () => {
if (isMobile()) {
this.hide();
}
});
}
render() {
this.innerHTML = `
<div class="share-modal" id="share-modal" style="display: none;">
@@ -90,6 +110,7 @@ export class ShareModal extends HTMLElement {
this.querySelector('#share-result').style.display = 'none';
this.querySelector('#share-form').reset();
document.addEventListener('keydown', this.handleEscape);
this.initGestures();
}
hide() {
+362
View File
@@ -0,0 +1,362 @@
// retoor <retoor@molodetz.nl>
export class GestureHandler {
constructor(element, options = {}) {
this.element = element;
this.options = {
swipeThreshold: 50,
swipeVelocityThreshold: 0.3,
longPressDelay: 500,
pullToRefreshThreshold: 80,
edgeSwipeWidth: 20,
...options
};
this.touchStartX = 0;
this.touchStartY = 0;
this.touchStartTime = 0;
this.longPressTimer = null;
this.isPulling = false;
this.pullDistance = 0;
this.isLongPress = false;
this.callbacks = {
swipeLeft: [],
swipeRight: [],
swipeUp: [],
swipeDown: [],
longPress: [],
pullToRefresh: [],
edgeSwipeRight: []
};
this.boundHandlers = {
touchStart: this.handleTouchStart.bind(this),
touchMove: this.handleTouchMove.bind(this),
touchEnd: this.handleTouchEnd.bind(this),
touchCancel: this.handleTouchCancel.bind(this)
};
this.attach();
}
attach() {
this.element.addEventListener('touchstart', this.boundHandlers.touchStart, { passive: false });
this.element.addEventListener('touchmove', this.boundHandlers.touchMove, { passive: false });
this.element.addEventListener('touchend', this.boundHandlers.touchEnd, { passive: true });
this.element.addEventListener('touchcancel', this.boundHandlers.touchCancel, { passive: true });
}
destroy() {
this.element.removeEventListener('touchstart', this.boundHandlers.touchStart);
this.element.removeEventListener('touchmove', this.boundHandlers.touchMove);
this.element.removeEventListener('touchend', this.boundHandlers.touchEnd);
this.element.removeEventListener('touchcancel', this.boundHandlers.touchCancel);
this.clearLongPressTimer();
}
handleTouchStart(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
this.touchStartX = touch.clientX;
this.touchStartY = touch.clientY;
this.touchStartTime = Date.now();
this.isLongPress = false;
this.startLongPressTimer(e);
if (this.touchStartX <= this.options.edgeSwipeWidth) {
this.isEdgeSwipe = true;
} else {
this.isEdgeSwipe = false;
}
const scrollTop = this.element.scrollTop || 0;
if (scrollTop <= 0 && this.callbacks.pullToRefresh.length > 0) {
this.isPulling = true;
this.pullDistance = 0;
}
}
handleTouchMove(e) {
if (e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaX = touch.clientX - this.touchStartX;
const deltaY = touch.clientY - this.touchStartY;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance > 10) {
this.clearLongPressTimer();
}
if (this.isPulling && deltaY > 0) {
this.pullDistance = Math.min(deltaY, this.options.pullToRefreshThreshold * 1.5);
if (this.pullDistance > 0) {
e.preventDefault();
this.element.dispatchEvent(new CustomEvent('pull-progress', {
detail: {
progress: Math.min(this.pullDistance / this.options.pullToRefreshThreshold, 1),
distance: this.pullDistance
}
}));
}
}
}
handleTouchEnd(e) {
this.clearLongPressTimer();
if (this.isLongPress) {
this.isLongPress = false;
return;
}
const touch = e.changedTouches[0];
const deltaX = touch.clientX - this.touchStartX;
const deltaY = touch.clientY - this.touchStartY;
const deltaTime = Date.now() - this.touchStartTime;
const velocity = Math.sqrt(deltaX * deltaX + deltaY * deltaY) / deltaTime;
if (this.isPulling && this.pullDistance >= this.options.pullToRefreshThreshold) {
this.emit('pullToRefresh');
}
this.isPulling = false;
this.pullDistance = 0;
this.element.dispatchEvent(new CustomEvent('pull-end'));
if (Math.abs(deltaX) < this.options.swipeThreshold &&
Math.abs(deltaY) < this.options.swipeThreshold) {
return;
}
if (velocity < this.options.swipeVelocityThreshold && deltaTime > 300) {
return;
}
const isHorizontal = Math.abs(deltaX) > Math.abs(deltaY);
if (isHorizontal) {
if (deltaX > this.options.swipeThreshold) {
if (this.isEdgeSwipe) {
this.emit('edgeSwipeRight');
} else {
this.emit('swipeRight');
}
} else if (deltaX < -this.options.swipeThreshold) {
this.emit('swipeLeft');
}
} else {
if (deltaY > this.options.swipeThreshold) {
this.emit('swipeDown');
} else if (deltaY < -this.options.swipeThreshold) {
this.emit('swipeUp');
}
}
}
handleTouchCancel() {
this.clearLongPressTimer();
this.isPulling = false;
this.pullDistance = 0;
this.isLongPress = false;
this.element.dispatchEvent(new CustomEvent('pull-end'));
}
startLongPressTimer(e) {
this.clearLongPressTimer();
if (this.callbacks.longPress.length === 0) return;
const touch = e.touches[0];
const target = document.elementFromPoint(touch.clientX, touch.clientY);
this.longPressTimer = setTimeout(() => {
this.isLongPress = true;
this.emit('longPress', {
x: touch.clientX,
y: touch.clientY,
target: target
});
if (navigator.vibrate) {
navigator.vibrate(50);
}
}, this.options.longPressDelay);
}
clearLongPressTimer() {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
on(event, callback) {
if (this.callbacks[event]) {
this.callbacks[event].push(callback);
}
return this;
}
off(event, callback) {
if (this.callbacks[event]) {
const index = this.callbacks[event].indexOf(callback);
if (index !== -1) {
this.callbacks[event].splice(index, 1);
}
}
return this;
}
emit(event, data = {}) {
if (this.callbacks[event]) {
this.callbacks[event].forEach(callback => callback(data));
}
}
}
export class PullToRefreshIndicator {
constructor(container) {
this.container = container;
this.indicator = null;
this.create();
}
create() {
this.indicator = document.createElement('div');
this.indicator.className = 'pull-to-refresh-indicator';
this.indicator.innerHTML = `
<div class="pull-spinner"></div>
<span class="pull-text">Pull to refresh</span>
`;
this.container.insertBefore(this.indicator, this.container.firstChild);
}
setProgress(progress) {
const height = Math.min(progress * 60, 60);
this.indicator.style.height = `${height}px`;
this.indicator.style.opacity = progress;
if (progress >= 1) {
this.indicator.querySelector('.pull-text').textContent = 'Release to refresh';
this.indicator.classList.add('ready');
} else {
this.indicator.querySelector('.pull-text').textContent = 'Pull to refresh';
this.indicator.classList.remove('ready');
}
}
showRefreshing() {
this.indicator.style.height = '60px';
this.indicator.style.opacity = 1;
this.indicator.querySelector('.pull-text').textContent = 'Refreshing...';
this.indicator.classList.add('refreshing');
}
hide() {
this.indicator.style.height = '0';
this.indicator.style.opacity = 0;
this.indicator.classList.remove('ready', 'refreshing');
}
destroy() {
if (this.indicator && this.indicator.parentNode) {
this.indicator.parentNode.removeChild(this.indicator);
}
}
}
export class ContextMenu {
constructor() {
this.menu = null;
this.isVisible = false;
this.boundClose = this.close.bind(this);
}
show(x, y, items) {
this.close();
this.menu = document.createElement('div');
this.menu.className = 'context-menu';
items.forEach(item => {
if (item.separator) {
const sep = document.createElement('div');
sep.className = 'context-menu-separator';
this.menu.appendChild(sep);
return;
}
const menuItem = document.createElement('button');
menuItem.className = 'context-menu-item';
if (item.destructive) {
menuItem.classList.add('destructive');
}
menuItem.innerHTML = `
${item.icon ? `<span class="context-menu-icon">${item.icon}</span>` : ''}
<span class="context-menu-label">${item.label}</span>
`;
menuItem.addEventListener('click', () => {
item.action();
this.close();
});
this.menu.appendChild(menuItem);
});
document.body.appendChild(this.menu);
const rect = this.menu.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let finalX = x;
let finalY = y;
if (x + rect.width > viewportWidth) {
finalX = viewportWidth - rect.width - 10;
}
if (y + rect.height > viewportHeight) {
finalY = viewportHeight - rect.height - 10;
}
this.menu.style.left = `${Math.max(10, finalX)}px`;
this.menu.style.top = `${Math.max(10, finalY)}px`;
this.isVisible = true;
requestAnimationFrame(() => {
this.menu.classList.add('visible');
});
setTimeout(() => {
document.addEventListener('touchstart', this.boundClose);
document.addEventListener('click', this.boundClose);
}, 100);
}
close() {
if (this.menu) {
this.menu.classList.remove('visible');
setTimeout(() => {
if (this.menu && this.menu.parentNode) {
this.menu.parentNode.removeChild(this.menu);
}
this.menu = null;
}, 200);
}
this.isVisible = false;
document.removeEventListener('touchstart', this.boundClose);
document.removeEventListener('click', this.boundClose);
}
}
export function isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}
export function isMobile() {
return window.innerWidth < 768;
}