This commit is contained in:
retoor 2026-01-03 22:15:15 +01:00
parent fba501f697
commit 1bcb9e38b4
13 changed files with 1554 additions and 52 deletions

View File

@ -384,3 +384,87 @@
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: 8px; border-radius: 8px;
} }
@media (max-width: 767px) {
.billing-dashboard,
.admin-billing {
padding: 16px;
}
.billing-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.billing-cards {
grid-template-columns: 1fr;
gap: 16px;
}
.stats-cards {
grid-template-columns: 1fr;
gap: 16px;
}
.estimated-cost {
font-size: 1.5rem;
}
.stat-value {
font-size: 1.5rem;
}
.invoices-section,
.payment-methods-section,
.pricing-config-section,
.invoice-generation-section {
padding: 16px;
}
.invoices-table {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.invoices-table table {
min-width: 600px;
}
.pricing-table {
min-width: 500px;
}
.invoice-gen-form {
flex-direction: column;
align-items: stretch;
}
.invoice-gen-form label {
width: 100%;
}
.invoice-gen-form input {
width: 100%;
}
.modal-content {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
border-radius: 0;
}
.modal-actions {
flex-direction: column;
}
.modal-actions .button {
width: 100%;
}
.payment-methods-section .button {
width: 100%;
}
}

View File

@ -88,3 +88,57 @@
.code-editor-body textarea { .code-editor-body textarea {
display: none; display: none;
} }
@media (max-width: 767px) {
.code-editor-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
}
.code-editor-header {
padding: 12px 16px;
padding-top: calc(12px + env(safe-area-inset-top, 0));
flex-wrap: wrap;
gap: 8px;
}
.code-editor-header .header-left {
gap: 8px;
flex: 1;
min-width: 0;
}
.code-editor-header .preview-actions {
gap: 4px;
}
.code-editor-header .button {
min-width: 44px;
min-height: 44px;
padding: 8px 12px;
font-size: 14px;
}
.editor-filename {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.code-editor-body {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.code-editor-body .CodeMirror {
font-size: 13px;
}
.code-editor-body .CodeMirror-linenumber {
padding: 0 4px;
}
}

View File

@ -143,3 +143,64 @@
color: #dc3545; color: #dc3545;
font-weight: 500; font-weight: 500;
} }
@media (max-width: 767px) {
.file-upload-view {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 200;
}
.file-upload-header {
padding: 12px 16px;
padding-top: calc(12px + env(safe-area-inset-top, 0));
}
.file-upload-header .header-left {
gap: 12px;
}
.file-upload-header h2 {
font-size: 16px;
}
.file-upload-header .button {
min-width: 44px;
min-height: 44px;
}
.file-upload-body {
padding: 16px;
gap: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0));
}
.drop-zone {
padding: 32px 16px;
}
.drop-zone-icon {
font-size: 48px;
}
.drop-zone h3 {
font-size: 16px;
}
.upload-item {
padding: 12px;
}
.upload-item-info {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.upload-item-name {
word-break: break-all;
}
}

702
static/css/mobile.css Normal file
View File

@ -0,0 +1,702 @@
/* retoor <retoor@molodetz.nl> */
/* Mobile-First Responsive Styles */
:root {
--touch-target-min: 44px;
--touch-target-comfortable: 48px;
--sidebar-width-mobile: 280px;
--header-height-mobile: 56px;
--safe-area-inset-top: env(safe-area-inset-top, 0);
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0);
--safe-area-inset-left: env(safe-area-inset-left, 0);
--safe-area-inset-right: env(safe-area-inset-right, 0);
}
/* Hamburger Button */
.hamburger-btn {
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
width: var(--touch-target-min);
height: var(--touch-target-min);
padding: 8px;
background: transparent;
border: none;
cursor: pointer;
gap: 5px;
margin-right: 8px;
border-radius: 8px;
-webkit-tap-highlight-color: transparent;
}
.hamburger-btn span {
display: block;
width: 24px;
height: 2px;
background-color: var(--primary-color);
border-radius: 2px;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.hamburger-btn.active span:nth-child(1) {
transform: translateY(7px) rotate(45deg);
}
.hamburger-btn.active span:nth-child(2) {
opacity: 0;
}
.hamburger-btn.active span:nth-child(3) {
transform: translateY(-7px) rotate(-45deg);
}
.hamburger-btn:active {
background-color: rgba(0, 51, 153, 0.1);
}
/* Sidebar Overlay */
.sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 149;
opacity: 0;
transition: opacity 0.3s ease;
-webkit-tap-highlight-color: transparent;
}
.sidebar-overlay.visible {
opacity: 1;
}
/* Pull to Refresh Indicator */
.pull-to-refresh-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
height: 0;
overflow: hidden;
background-color: var(--background-color);
transition: height 0.2s ease;
opacity: 0;
}
.pull-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
}
.pull-to-refresh-indicator.refreshing .pull-spinner {
animation: spin 0.8s linear infinite;
}
.pull-to-refresh-indicator.ready .pull-spinner {
border-color: var(--primary-color);
border-top-color: var(--primary-color);
}
.pull-text {
font-size: 0.875rem;
color: var(--text-color-light);
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Context Menu */
.context-menu {
position: fixed;
min-width: 180px;
max-width: 280px;
background-color: var(--accent-color);
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
z-index: 1000;
padding: 8px 0;
opacity: 0;
transform: scale(0.95);
transition: opacity 0.2s ease, transform 0.2s ease;
overflow: hidden;
}
.context-menu.visible {
opacity: 1;
transform: scale(1);
}
.context-menu-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 16px;
background: none;
border: none;
font-size: 1rem;
color: var(--text-color);
text-align: left;
cursor: pointer;
min-height: var(--touch-target-min);
-webkit-tap-highlight-color: transparent;
}
.context-menu-item:active {
background-color: rgba(0, 51, 153, 0.1);
}
.context-menu-item.destructive {
color: #d32f2f;
}
.context-menu-icon {
width: 20px;
text-align: center;
}
.context-menu-separator {
height: 1px;
background-color: var(--border-color);
margin: 8px 0;
}
/* Mobile Base Styles (320px+) */
@media (max-width: 767px) {
.hamburger-btn {
display: flex;
}
.sidebar-overlay {
display: block;
}
.app-header {
height: var(--header-height-mobile);
padding: 0 8px;
padding-top: var(--safe-area-inset-top);
}
.app-title {
font-size: 1.25rem;
}
.header-center {
flex: 1;
max-width: none;
margin: 0 8px;
}
.search-input {
height: var(--touch-target-min);
font-size: 16px;
}
.header-right {
gap: 8px;
}
.header-right .user-info {
display: none;
}
.header-right .button {
min-width: var(--touch-target-min);
min-height: var(--touch-target-min);
padding: 8px 12px;
}
.app-body {
flex-direction: column;
}
.app-sidebar {
position: fixed;
top: 0;
left: 0;
width: var(--sidebar-width-mobile);
height: 100vh;
height: 100dvh;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 150;
background-color: var(--accent-color);
box-shadow: none;
padding-top: var(--safe-area-inset-top);
overflow-y: auto;
}
.app-sidebar.open {
transform: translateX(0);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
}
.sidebar-nav {
padding: 16px;
}
.nav-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-color-light);
margin: 16px 0 8px;
}
.nav-title:first-child {
margin-top: 0;
}
.nav-list {
list-style: none;
padding: 0;
margin: 0;
}
.nav-link {
display: flex;
align-items: center;
min-height: var(--touch-target-comfortable);
padding: 12px 16px;
color: var(--text-color);
text-decoration: none;
border-radius: 8px;
font-size: 1rem;
-webkit-tap-highlight-color: transparent;
}
.nav-link:active {
background-color: rgba(0, 51, 153, 0.1);
}
.nav-link.active {
background-color: rgba(0, 51, 153, 0.1);
color: var(--primary-color);
font-weight: 500;
}
.app-main {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.app-footer {
padding: 16px;
padding-bottom: calc(16px + var(--safe-area-inset-bottom));
}
.footer-links {
flex-wrap: wrap;
gap: 8px 16px;
justify-content: center;
}
.footer-links li a {
font-size: 0.75rem;
min-height: var(--touch-target-min);
display: flex;
align-items: center;
}
/* File List Mobile */
.file-list-header {
padding: 12px;
flex-wrap: wrap;
gap: 8px;
}
.file-list-title {
font-size: 1.25rem;
}
.file-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
width: 100%;
}
.file-actions .button {
min-height: var(--touch-target-min);
flex: 1;
min-width: calc(50% - 4px);
justify-content: center;
}
.file-grid {
grid-template-columns: 1fr;
gap: 1px;
background-color: var(--border-color);
padding: 0;
}
.file-item {
min-height: 64px;
padding: 12px 16px;
background-color: var(--accent-color);
gap: 12px;
}
.file-item-checkbox {
width: 24px;
height: 24px;
min-width: 24px;
}
.file-item-checkbox::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: var(--touch-target-min);
height: var(--touch-target-min);
}
.file-item-icon {
width: 40px;
height: 40px;
}
.file-item-info {
flex: 1;
min-width: 0;
}
.file-item-name {
font-size: 1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-item-meta {
font-size: 0.75rem;
color: var(--text-color-light);
}
.file-item-actions {
display: flex;
gap: 4px;
}
.file-item-actions .action-btn {
width: var(--touch-target-min);
height: var(--touch-target-min);
min-width: var(--touch-target-min);
}
/* Breadcrumb Mobile */
.breadcrumb {
padding: 8px 12px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
white-space: nowrap;
}
.breadcrumb-item {
min-height: var(--touch-target-min);
display: inline-flex;
align-items: center;
padding: 0 8px;
}
/* Overlays & Modals Mobile */
.modal-overlay,
.file-upload-overlay,
.file-preview-overlay,
.code-editor-overlay {
padding: 0;
}
.modal-content,
.upload-content,
.preview-content,
.editor-content {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
border-radius: 0;
margin: 0;
}
/* Bottom Sheet Style Modal */
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
max-height: 90vh;
border-radius: 16px 16px 0 0;
padding-bottom: var(--safe-area-inset-bottom);
transform: translateY(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.bottom-sheet.visible {
transform: translateY(0);
}
.bottom-sheet-handle {
width: 36px;
height: 4px;
background-color: var(--border-color);
border-radius: 2px;
margin: 8px auto 16px;
}
/* Login View Mobile */
.login-container {
padding: 16px;
padding-top: calc(16px + var(--safe-area-inset-top));
}
.auth-tabs {
min-height: var(--touch-target-comfortable);
}
.auth-tab {
min-height: var(--touch-target-comfortable);
font-size: 1rem;
}
.auth-form input {
height: var(--touch-target-comfortable);
font-size: 16px;
padding: 12px 16px;
}
.auth-form .button {
min-height: var(--touch-target-comfortable);
font-size: 1rem;
}
/* Photo Gallery Mobile */
.gallery-grid {
grid-template-columns: repeat(2, 1fr);
gap: 2px;
}
.gallery-item {
aspect-ratio: 1;
}
/* Toast Mobile */
.toast-notification {
left: 16px;
right: 16px;
bottom: calc(16px + var(--safe-area-inset-bottom));
max-width: none;
transform: translateY(100px);
}
.toast-notification.visible {
transform: translateY(0);
}
/* Billing Dashboard Mobile */
.billing-container {
padding: 16px;
}
.billing-cards {
flex-direction: column;
}
.billing-card {
width: 100%;
}
.invoice-table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.invoice-table {
min-width: 500px;
}
/* Admin Dashboard Mobile */
.admin-container {
padding: 16px;
}
.user-table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.user-table {
min-width: 600px;
}
/* User Settings Mobile */
.settings-container {
padding: 16px;
}
.settings-section .button {
width: 100%;
min-height: var(--touch-target-comfortable);
}
/* Share Modal Mobile - Bottom Sheet */
.share-modal {
top: auto;
bottom: 0;
left: 0;
right: 0;
transform: translateY(100%);
max-height: 85vh;
border-radius: 16px 16px 0 0;
padding-bottom: var(--safe-area-inset-bottom);
}
.share-modal.visible {
transform: translateY(0);
}
.share-modal-content {
padding: 16px;
}
.share-form input,
.share-form select {
height: var(--touch-target-comfortable);
font-size: 16px;
}
.share-form .button {
min-height: var(--touch-target-comfortable);
width: 100%;
}
/* Cookie Consent Mobile */
.cookie-consent {
left: 8px;
right: 8px;
bottom: calc(8px + var(--safe-area-inset-bottom));
padding: 16px;
}
.cookie-consent-buttons {
flex-direction: column;
gap: 8px;
}
.cookie-consent-buttons .button {
width: 100%;
min-height: var(--touch-target-min);
}
}
/* Mobile Landscape (480px+) */
@media (min-width: 480px) and (max-width: 767px) {
.gallery-grid {
grid-template-columns: repeat(3, 1fr);
}
.file-actions .button {
min-width: auto;
flex: 0 1 auto;
}
}
/* Tablet (768px+) */
@media (min-width: 768px) {
.hamburger-btn {
display: none;
}
.sidebar-overlay {
display: none;
}
.app-sidebar {
position: relative;
transform: none;
width: var(--sidebar-width);
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
/* Desktop (1024px+) */
@media (min-width: 1024px) {
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.gallery-grid {
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
}
/* Touch-specific styles */
@media (hover: none) and (pointer: coarse) {
.button:hover,
.nav-link:hover,
.file-item:hover {
background-color: inherit;
}
.button:active,
.nav-link:active {
background-color: rgba(0, 51, 153, 0.1);
}
.file-item:active {
background-color: rgba(0, 51, 153, 0.05);
}
* {
-webkit-tap-highlight-color: transparent;
}
}
/* Dark Mode Mobile Adjustments */
body.dark-mode .hamburger-btn span {
background-color: var(--text-color);
}
body.dark-mode .hamburger-btn:active {
background-color: rgba(255, 255, 255, 0.1);
}
body.dark-mode .nav-link:active,
body.dark-mode .button:active {
background-color: rgba(255, 255, 255, 0.1);
}
body.dark-mode .context-menu {
background-color: #333;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
body.dark-mode .context-menu-item:active {
background-color: rgba(255, 255, 255, 0.1);
}
body.dark-mode .pull-to-refresh-indicator {
background-color: var(--background-color);
}
body.dark-mode .file-item {
background-color: var(--background-color);
}

View File

@ -579,53 +579,6 @@ body {
font-size: 1.25rem; font-size: 1.25rem;
} }
@media (max-width: 768px) {
.app-header {
flex-direction: column;
height: auto;
padding: var(--spacing-unit);
}
.header-center {
width: 100%;
max-width: none;
margin: var(--spacing-unit) 0;
}
.header-right {
width: 100%;
justify-content: space-between;
}
.app-body {
flex-direction: column;
}
.app-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border-color);
max-height: 200px;
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
@media (max-width: 480px) {
.file-grid {
grid-template-columns: 1fr;
}
.file-actions {
flex-direction: column;
}
.file-actions button {
width: 100%;
}
}
body.dark-mode { body.dark-mode {
--background-color: #222222; --background-color: #222222;
@ -993,7 +946,7 @@ body.dark-mode {
border-color: var(--primary-color); border-color: var(--primary-color);
background-color: rgba(0, 51, 153, 0.05); background-color: rgba(0, 51, 153, 0.05);
} }
-e
.shared-items-container { .shared-items-container {
background-color: var(--accent-color); background-color: var(--accent-color);
border-radius: 8px; border-radius: 8px;

View File

@ -52,6 +52,7 @@
<link rel="stylesheet" href="/static/lib/codemirror/codemirror.min.css"> <link rel="stylesheet" href="/static/lib/codemirror/codemirror.min.css">
<link rel="stylesheet" href="/static/css/code-editor-view.css"> <link rel="stylesheet" href="/static/css/code-editor-view.css">
<link rel="stylesheet" href="/static/css/file-upload-view.css"> <link rel="stylesheet" href="/static/css/file-upload-view.css">
<link rel="stylesheet" href="/static/css/mobile.css">
<link rel="manifest" href="/static/manifest.json"> <link rel="manifest" href="/static/manifest.json">
<script src="https://js.stripe.com/v3/"></script> <script src="https://js.stripe.com/v3/"></script>
<script src="/static/lib/codemirror/codemirror.min.js"></script> <script src="/static/lib/codemirror/codemirror.min.js"></script>

View File

@ -1,4 +1,5 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { GestureHandler, PullToRefreshIndicator, ContextMenu, isMobile } from '../gesture-handler.js';
export class FileList extends HTMLElement { export class FileList extends HTMLElement {
constructor() { constructor() {
@ -12,6 +13,9 @@ export class FileList extends HTMLElement {
this.boundHandleClick = this.handleClick.bind(this); this.boundHandleClick = this.handleClick.bind(this);
this.boundHandleDblClick = this.handleDblClick.bind(this); this.boundHandleDblClick = this.handleDblClick.bind(this);
this.boundHandleChange = this.handleChange.bind(this); this.boundHandleChange = this.handleChange.bind(this);
this.gestureHandler = null;
this.pullIndicator = null;
this.contextMenu = new ContextMenu();
} }
async connectedCallback() { async connectedCallback() {
@ -27,6 +31,12 @@ export class FileList extends HTMLElement {
this.removeEventListener('click', this.boundHandleClick); this.removeEventListener('click', this.boundHandleClick);
this.removeEventListener('dblclick', this.boundHandleDblClick); this.removeEventListener('dblclick', this.boundHandleDblClick);
this.removeEventListener('change', this.boundHandleChange); this.removeEventListener('change', this.boundHandleChange);
if (this.gestureHandler) {
this.gestureHandler.destroy();
}
if (this.pullIndicator) {
this.pullIndicator.destroy();
}
} }
async loadContents(folderId) { async loadContents(folderId) {
@ -305,6 +315,117 @@ export class FileList extends HTMLElement {
attachListeners() { attachListeners() {
this.updateBatchActionVisibility(); 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) { toggleSelectItem(type, id, checked) {

View File

@ -1,15 +1,36 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
class FilePreview extends HTMLElement { class FilePreview extends HTMLElement {
constructor() { constructor() {
super(); super();
this.file = null; this.file = null;
this.handleEscape = this.handleEscape.bind(this); this.handleEscape = this.handleEscape.bind(this);
this.gestureHandler = null;
} }
connectedCallback() { connectedCallback() {
this.render(); this.render();
this.setupEventListeners(); 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() { setupEventListeners() {

View File

@ -1,4 +1,5 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
export class FileUploadView extends HTMLElement { export class FileUploadView extends HTMLElement {
constructor() { constructor() {
@ -6,6 +7,7 @@ export class FileUploadView extends HTMLElement {
this.folderId = null; this.folderId = null;
this.handleEscape = this.handleEscape.bind(this); this.handleEscape = this.handleEscape.bind(this);
this.uploadItems = new Map(); this.uploadItems = new Map();
this.gestureHandler = null;
} }
connectedCallback() { connectedCallback() {
@ -14,6 +16,21 @@ export class FileUploadView extends HTMLElement {
disconnectedCallback() { disconnectedCallback() {
document.removeEventListener('keydown', this.handleEscape); 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) { setFolder(folderId) {
@ -57,6 +74,8 @@ export class FileUploadView extends HTMLElement {
backBtn.addEventListener('click', () => this.close()); backBtn.addEventListener('click', () => this.close());
} }
this.initGestures();
if (fileInput) { if (fileInput) {
fileInput.addEventListener('change', (e) => { fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) { if (e.target.files.length > 0) {

View File

@ -15,8 +15,9 @@ import './billing-dashboard.js';
import './admin-billing.js'; import './admin-billing.js';
import './code-editor-view.js'; import './code-editor-view.js';
import './cookie-consent.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 { shortcuts } from '../shortcuts.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
const api = app.getAPI(); const api = app.getAPI();
const logger = app.getLogger(); const logger = app.getLogger();
@ -31,6 +32,8 @@ export class MyWebdavApp extends HTMLElement {
this.boundHandlePopState = this.handlePopState.bind(this); this.boundHandlePopState = this.handlePopState.bind(this);
this.popstateAttached = false; this.popstateAttached = false;
this.currentSearchId = 0; this.currentSearchId = 0;
this.gestureHandler = null;
this.sidebarOpen = false;
} }
async connectedCallback() { async connectedCallback() {
@ -127,10 +130,15 @@ export class MyWebdavApp extends HTMLElement {
<div class="app-container"> <div class="app-container">
<header class="app-header"> <header class="app-header">
<div class="header-left"> <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> <h1 class="app-title">MyWebdav</h1>
</div> </div>
<div class="header-center"> <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>
<div class="header-right"> <div class="header-right">
<span class="user-info">${this.user.username}</span> <span class="user-info">${this.user.username}</span>
@ -139,7 +147,8 @@ export class MyWebdavApp extends HTMLElement {
</header> </header>
<div class="app-body"> <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"> <nav class="sidebar-nav">
<h3 class="nav-title">Navigation</h3> <h3 class="nav-title">Navigation</h3>
<ul class="nav-list"> <ul class="nav-list">
@ -160,7 +169,7 @@ export class MyWebdavApp extends HTMLElement {
</nav> </nav>
</aside> </aside>
<main class="app-main"> <main class="app-main" id="app-main">
<div id="main-content"> <div id="main-content">
<file-list></file-list> <file-list></file-list>
</div> </div>
@ -191,6 +200,55 @@ export class MyWebdavApp extends HTMLElement {
this.initializeNavigation(); this.initializeNavigation();
this.attachListeners(); this.attachListeners();
this.registerShortcuts(); 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() { initializeNavigation() {
@ -402,11 +460,22 @@ export class MyWebdavApp extends HTMLElement {
api.logout(); api.logout();
}); });
this.querySelector('#hamburger-btn')?.addEventListener('click', () => {
this.toggleSidebar();
});
this.querySelector('#sidebar-overlay')?.addEventListener('click', () => {
this.closeSidebar();
});
this.querySelectorAll('.nav-link').forEach(link => { this.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', (e) => { link.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
const view = link.dataset.view; const view = link.dataset.view;
this.switchView(view); this.switchView(view);
if (isMobile()) {
this.closeSidebar();
}
}); });
}); });

View File

@ -1,20 +1,54 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { GestureHandler, PullToRefreshIndicator } from '../gesture-handler.js';
class PhotoGallery extends HTMLElement { class PhotoGallery extends HTMLElement {
constructor() { constructor() {
super(); super();
this.photos = []; this.photos = [];
this.boundHandleClick = this.handleClick.bind(this); this.boundHandleClick = this.handleClick.bind(this);
this.gestureHandler = null;
this.pullIndicator = null;
} }
connectedCallback() { connectedCallback() {
this.addEventListener('click', this.boundHandleClick); this.addEventListener('click', this.boundHandleClick);
this.render(); this.render();
this.loadPhotos(); this.loadPhotos();
this.initGestures();
} }
disconnectedCallback() { disconnectedCallback() {
this.removeEventListener('click', this.boundHandleClick); 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() { async loadPhotos() {

View File

@ -1,4 +1,5 @@
import { api } from '../api.js'; import { api } from '../api.js';
import { GestureHandler, isMobile } from '../gesture-handler.js';
export class ShareModal extends HTMLElement { export class ShareModal extends HTMLElement {
constructor() { constructor() {
@ -6,10 +7,29 @@ export class ShareModal extends HTMLElement {
this.fileId = null; this.fileId = null;
this.folderId = null; this.folderId = null;
this.handleEscape = this.handleEscape.bind(this); this.handleEscape = this.handleEscape.bind(this);
this.gestureHandler = null;
this.render(); this.render();
this.attachListeners(); 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() { render() {
this.innerHTML = ` this.innerHTML = `
<div class="share-modal" id="share-modal" style="display: none;"> <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-result').style.display = 'none';
this.querySelector('#share-form').reset(); this.querySelector('#share-form').reset();
document.addEventListener('keydown', this.handleEscape); document.addEventListener('keydown', this.handleEscape);
this.initGestures();
} }
hide() { hide() {

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;
}