Compare commits

...

4 Commits

10 changed files with 962 additions and 187 deletions

View File

@@ -1,5 +1,37 @@
# Changelog
## Changes 11/21/2025 (v1.9.14)
release(v1.9.14): inline folder rows, synced folder icons, and compact theme polish
- Add ACL-aware folder stats and byte counts in FolderModel::countVisible()
- Show subfolders inline as rows above files in table view (Explorer-style)
- Page folders + files together and wire folder rows into existing DnD and context menu flows
- Add folder action buttons (move/rename/color/share) with capability checks from /api/folder/capabilities.php
- Cache folder capabilities and owners to avoid repeat calls per row
- Add user settings to toggle folder strip and inline folder rows (stored in localStorage)
- Default itemsPerPage to 50 and remember current page across renders
- Sync inline folder icon size to file row height and tweak vertical alignment for different row heights
- Update table headers + i18n keys to use Name / Size / Modified / Created / Owner labels
- Compact and consolidate light/dark theme CSS, search pill, pagination, and font-size controls
- Tighten file action button hit areas and add specific styles for folder move/rename buttons
---
## Changes 11/20/2025 (v1.9.13)
release(v1.9.13): style(ui): compact dual-theme polish for lists, inputs, search & modals
- Added compact, unified light/dark theme for core surfaces (file list, upload, folder manager, admin panel).
- Updated modals, dropdown menus, and editor header to use the same modern panel styling in both themes.
- Restyled search bar into a pill-shaped control with a dedicated icon chip and better hover states.
- Refined pagination (Prev/Next) and font size (A-/A+) buttons to be smaller, rounded, and more consistent.
- Normalized input fields so borders render cleanly and focus states are consistent across the app.
- Tweaked button shadows so primary actions (Create/Upload) pop without feeling heavy in light mode.
- Polished dark-mode colors for tables, rows, toasts, and meta text for a more “app-like” feel.
---
## Changes 11/19/2025 (v1.9.12)
release(v1.9.12): feat(pro-acl): add user groups and group-aware ACL

View File

@@ -614,7 +614,8 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover{background-color: rgba
#fileList button.edit-btn{background-color: #007bff;
color: white;}
.rename-btn .material-icons,
#renameFolderBtn .material-icons{color: black !important;}
#renameFolderBtn .material-icons,
.folder-rename-btn .material-icons{color: black !important;}
#fileList table{background-color: transparent;
border-collapse: collapse !important;
border-spacing: 0 !important;
@@ -818,25 +819,38 @@ label{font-size: 0.9rem;}
.folder-actions .btn,
.folder-actions .material-icons{transition: none;}
}
#moveFolderBtn{background-color: #ff9800;
#moveFolderBtn,
.folder-move-btn{background-color: #ff9800;
border-color: #ff9800;
color: #fff;}
color: #fff;
}
#moveFolderBtn:hover:not(:disabled):not(.disabled),
.folder-move-btn:hover:not(:disabled):not(.disabled) {
background-color: #fb8c00; /* slightly darker */
border-color: #fb8c00;
}
/* Active/pressed (only when enabled) */
#moveFolderBtn:active:not(:disabled):not(.disabled),
.folder-move-btn:active:not(:disabled):not(.disabled) {
background-color: #f57c00;
border-color: #f57c00;
}
/* Disabled state (both attribute + .disabled class) */
#moveFolderBtn:disabled,
#moveFolderBtn.disabled,
.folder-move-btn:disabled,
.folder-move-btn.disabled {
background-color: #ffb74d;
border-color: #ffb74d;
color: #fff;
opacity: 0.55;
}
.row-selected{background-color: #f2f2f2 !important;}
.dark-mode .row-selected{background-color: #444 !important;
color: #fff !important;}
.custom-prev-next-btn{background-color: #e0e0e0;
color: #000;
border: none;
padding: 6px 12px;
font-size: 14px;
border-radius: 4px;
margin: 0 4px;
cursor: pointer;}
.custom-prev-next-btn:hover:not(:disabled){background-color: #d5d5d5;}
.dark-mode .custom-prev-next-btn{background-color: #444;
color: #fff;
border: none;}
.dark-mode .custom-prev-next-btn:hover:not(:disabled){background-color: #555;}
#customToast{position: fixed;
bottom: 20px;
right: 20px;
@@ -959,7 +973,8 @@ label{font-size: 0.9rem;}
transform: none !important;
box-shadow: none !important;}
}
.btn-group.btn-group-sm[aria-label="File actions"] .btn{padding: .2rem !important;
.btn-group.btn-group-sm[aria-label="File actions"] .btn{padding: .8rem !important;
width: 32px;
height: 32px;
line-height: 1 !important;
@@ -990,6 +1005,7 @@ label{font-size: 0.9rem;}
.btn-group.btn-group-sm[aria-label="File actions"] .btn .material-symbols-rounded{transition: none !important;
transform: none !important;}
}
.breadcrumb-link{cursor: pointer;
color: #007bff;
text-decoration: underline;}
@@ -1705,8 +1721,6 @@ body.dark-mode .folder-strip-container .folder-item:hover{background-color: rgba
--filr-folder-stroke:#a87312;
--filr-paper-fill: #ffffff;
--filr-paper-stroke: #9fb3d6;
--row-h: 28px;
--twisty: 24px;
--twisty-gap: -5px;
@@ -1853,7 +1867,6 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
align-items: center;
gap: 8px;
justify-content: center;
border-radius: 10px;
border: 1px solid var(--tree-ghost-border);
background: var(--tree-ghost-bg);
color: var(--tree-ghost-fg);
@@ -1979,4 +1992,103 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
font-size: 0.85rem;
text-align: center;
cursor: pointer;
}
}
/* ============================================
FileRise polish compact theme layer
============================================ */
:root{--filr-radius-lg:14px;--filr-radius-xl:18px;--filr-shadow-soft:0 12px 35px rgba(15,23,42,.14);--filr-shadow-subtle:0 8px 20px rgba(15,23,42,.10);--filr-header-blur:18px;--filr-transition-fast:150ms ease-out;--filr-transition-med:220ms cubic-bezier(.22,.61,.36,1);--fr-bg-dark:#0f0f0f;--fr-surface-dark:#212121;--fr-surface-dark-2:#181818;--fr-border-dark:#303030;--fr-muted-dark:#aaaaaa;--fr-bg-light:#f9f9f9;--fr-surface-light:#ffffff;--fr-surface-light-2:#f1f1f1;--fr-border-light:#e5e5e5;--fr-muted-light:#606060}
.btn-pro-admin{background:linear-gradient(135deg,#ff9800,#ff5722);border-color:#ff9800;color:#1b0f00!important;font-weight:600;box-shadow:0 0 10px rgba(255,152,0,.4)}
#customToast{border-radius:999px}
#folderTreeContainer .folder-row{border-radius:8px}
.btn,#customChooseBtn, #colorFolderModal .btn-ghost, #cancelMoveFolder, #confirmMoveFolder, #cancelRenameFolder, #submitRenameFolder, #cancelDeleteFolder, #confirmDeleteFolder, #cancelCreateFolder, #submitCreateFolder{border-radius:999px;font-weight:500;border:1px solid transparent;transition:background-color var(--filr-transition-fast),box-shadow var(--filr-transition-fast),transform var(--filr-transition-fast),border-color var(--filr-transition-fast)}
.btn-primary,#createBtn,#uploadBtn,#submitCreateFolder,#submitRenameFolder,#confirmMoveFolder{box-shadow:0 2px 4px rgba(0,0,0,.6)}
.btn-primary:hover,#createBtn:hover,#uploadBtn:hover,#submitCreateFolder:hover,#submitRenameFolder:hover,#confirmMoveFolder:hover{filter:brightness(1.04);transform:translateY(-1px);box-shadow:0 10px 22px rgba(0,140,180,.28)}
#deleteSelectedBtn,#deleteAllBtn,#deleteTrashSelectedBtn,#deleteFolderBtn,#confirmDeleteFolder{border-color:rgba(248,113,113,.9);box-shadow:0 8px 18px rgba(248,113,113,.35)}
input[type=text],input[type=password],input[type=email],input[type=url],select,textarea{border-radius:10px;padding:8px 10px;font-size:.92rem;transition:border-color var(--filr-transition-fast),box-shadow var(--filr-transition-fast),background-color var(--filr-transition-fast)}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--filr-accent-500);box-shadow:0 0 0 1px var(--filr-accent-ring)}
.modal{backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
#fileListContainer,#uploadCard,#folderManagementCard,.card,.admin-panel-content{border-radius:var(--filr-radius-xl);border:1px solid rgba(15,23,42,.06);background:#ffffff;box-shadow:var(--filr-shadow-subtle)}
body{min-height:100vh}
body.dark-mode{background:var(--fr-bg-dark)!important;color:#f1f1f1!important;background-image:none!important}
body.dark-mode #fileListContainer,body.dark-mode #uploadCard,body.dark-mode #folderManagementCard,body.dark-mode .card,body.dark-mode .admin-panel-content,body.dark-mode .media-topbar{background:var(--fr-surface-dark)!important;border-color:var(--fr-border-dark)!important;box-shadow:0 1px 4px rgba(0,0,0,.9)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
body.dark-mode #fileListContainer::before,body.dark-mode #uploadCard::before,body.dark-mode #folderManagementCard::before,body.dark-mode .card::before,body.dark-mode .admin-panel-content::before{box-shadow:none!important}
body.dark-mode .card-header,body.dark-mode .custom-folder-card-body .drag-header{background:var(--fr-surface-dark-2)!important;border-bottom:1px solid var(--fr-border-dark)!important}
body.dark-mode #fileList table thead th{background:var(--fr-surface-dark-2)!important;border-bottom:1px solid var(--fr-border-dark)!important}
body.dark-mode #fileList table.filr-table tbody tr.selected>td,body.dark-mode #fileList table.filr-table tbody tr.row-selected>td,body.dark-mode #fileList table.filr-table tbody tr.selected-row>td,body.dark-mode #fileList table.filr-table tbody tr.is-selected>td{background:rgba(62,166,255,.16)!important;box-shadow:none!important}
body.dark-mode .modal{background-color:rgba(0,0,0,.65)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
body.dark-mode .modal .modal-content,body.dark-mode .editor-modal,body.dark-mode .image-preview-modal-content,body.dark-mode #restoreFilesModal .modal-content,body.dark-mode #downloadProgressModal .modal-content{background:var(--fr-surface-dark)!important;border-radius:12px!important;border:1px solid var(--fr-border-dark)!important;box-shadow:0 8px 24px rgba(0,0,0,.9)!important}
body.dark-mode .modal .modal-content::before,body.dark-mode .editor-modal::before,body.dark-mode .image-preview-modal-content::before,body.dark-mode #restoreFilesModal .modal-content::before,body.dark-mode #downloadProgressModal .modal-content::before{box-shadow:none!important}
body.dark-mode input[type=text],body.dark-mode input[type=password],body.dark-mode input[type=email],body.dark-mode input[type=url],body.dark-mode select,body.dark-mode textarea{background:#121212!important;border-color:#3d3d3d!important;color:#f1f1f1!important}
body.dark-mode input::placeholder,body.dark-mode textarea::placeholder{color:#777!important}
body.dark-mode input:focus,body.dark-mode select:focus,body.dark-mode textarea:focus{border-color:#3ea6ff!important;box-shadow:0 0 0 1px rgba(62,166,255,.7)!important}
body.dark-mode #deleteSelectedBtn,body.dark-mode #deleteAllBtn,body.dark-mode #deleteTrashSelectedBtn,#deleteFolderBtn,#confirmDeleteFolder{background-color:#b3261e!important;border-color:#b3261e!important;box-shadow:0 4px 10px rgba(0,0,0,.7)!important}
body.dark-mode .folder-strip-container.folder-strip-mobile{background:var(--fr-surface-dark-2)!important;border:1px solid var(--fr-border-dark)!important}
body.dark-mode #customToast{background:#212121!important;border:1px solid var(--fr-border-dark)!important;box-shadow:0 8px 20px rgba(0,0,0,.9)!important}
body.dark-mode #fileSummary{color:var(--fr-muted-dark)!important}
body.dark-mode #createMenu,body.dark-mode .user-dropdown .user-menu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu,body.dark-mode #adminPanelModal .modal-content,body.dark-mode #userPermissionsModal .modal-content,body.dark-mode #userFlagsModal .modal-content,body.dark-mode #userGroupsModal .modal-content,body.dark-mode #userPanelModal .modal-content,body.dark-mode #groupAclModal .modal-content,body.dark-mode .editor-modal,body.dark-mode #filePreviewModal .modal-content,body.dark-mode #loginForm,body.dark-mode .editor-header{background:var(--fr-surface-dark)!important;border:1px solid var(--fr-border-dark)!important;color:#f1f1f1!important;border-radius:12px!important;box-shadow:0 8px 24px rgba(0,0,0,.9)!important}
body.dark-mode .user-dropdown .user-menu,body.dark-mode #createMenu,body.dark-mode #fileContextMenu,body.dark-mode #folderContextMenu,body.dark-mode #folderManagerContextMenu{background-clip:padding-box}
body:not(.dark-mode){background:var(--fr-bg-light)!important;color:#111!important;background-image:none!important}
body:not(.dark-mode) #fileListContainer,body:not(.dark-mode) #uploadCard,body:not(.dark-mode) #folderManagementCard,body:not(.dark-mode) .card,body:not(.dark-mode) .admin-panel-content{background:var(--fr-surface-light)!important;border-color:var(--fr-border-light)!important;box-shadow:0 3px 8px rgba(0,0,0,.04)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
body:not(.dark-mode) #fileListContainer::before,body:not(.dark-mode) #uploadCard::before,body:not(.dark-mode) #folderManagementCard::before,body:not(.dark-mode) .card::before,body:not(.dark-mode) .admin-panel-content::before{box-shadow:none!important}
body:not(.dark-mode) .card-header,body:not(.dark-mode) .custom-folder-card-body .drag-header{background:var(--fr-surface-light-2)!important;border-bottom:1px solid var(--fr-border-light)!important}
body:not(.dark-mode) #fileList table thead th{background:var(--fr-surface-light-2)!important;border-bottom:1px solid var(--fr-border-light)!important}
body:not(.dark-mode) #fileList table.filr-table tbody tr.selected>td,body:not(.dark-mode) #fileList table.filr-table tbody tr.row-selected>td,body:not(.dark-mode) #fileList table.filr-table tbody tr.selected-row>td,body:not(.dark-mode) #fileList table.filr-table tbody tr.is-selected>td{background:rgba(33,150,243,.12)!important;box-shadow:none!important}
body:not(.dark-mode) .modal{background-color:rgba(0,0,0,.4)!important;backdrop-filter:none!important;-webkit-backdrop-filter:none!important}
body:not(.dark-mode) .modal .modal-content,body:not(.dark-mode) .editor-modal,body:not(.dark-mode) .image-preview-modal-content,body:not(.dark-mode) #restoreFilesModal .modal-content,body:not(.dark-mode) #downloadProgressModal .modal-content{background:var(--fr-surface-light)!important;border-radius:12px!important;border:1px solid var(--fr-border-light)!important;box-shadow:0 8px 24px rgba(0,0,0,.18)!important}
body:not(.dark-mode) .modal .modal-content::before,body:not(.dark-mode) .editor-modal::before,body:not(.dark-mode) .image-preview-modal-content::before,body:not(.dark-mode) #restoreFilesModal .modal-content::before,body:not(.dark-mode) #downloadProgressModal .modal-content::before{box-shadow:none!important}
body:not(.dark-mode) input[type=text],body:not(.dark-mode) input[type=password],body:not(.dark-mode) input[type=email],body:not(.dark-mode) input[type=url],body:not(.dark-mode) select,body:not(.dark-mode) textarea{background:#fff!important;border-color:#d0d0d0!important;color:#111!important}
body:not(.dark-mode) input::placeholder,body:not(.dark-mode) textarea::placeholder{color:#9e9e9e!important}
body:not(.dark-mode) input:focus,body:not(.dark-mode) select:focus,body:not(.dark-mode) textarea:focus{border-color:#2196f3!important;box-shadow:0 0 0 1px rgba(33,150,243,.55)!important}
body:not(.dark-mode) #deleteSelectedBtn,body:not(.dark-mode) #deleteAllBtn,body:not(.dark-mode) #deleteTrashSelectedBtn{box-shadow:0 2px 6px rgba(244,67,54,.3)!important}
body:not(.dark-mode) .folder-strip-container.folder-strip-mobile{background:#f1f1f1!important;border:1px solid var(--fr-border-light)!important}
body:not(.dark-mode) #customToast{background:#212121!important;color:#fff!important;border:1px solid #000!important;box-shadow:0 8px 18px rgba(0,0,0,.45)!important}
body:not(.dark-mode) #fileSummary{color:var(--fr-muted-light)!important}
body:not(.dark-mode) #createMenu,body:not(.dark-mode) .user-dropdown .user-menu,body:not(.dark-mode) #fileContextMenu,body:not(.dark-mode) #folderContextMenu,body:not(.dark-mode) #folderManagerContextMenu,body:not(.dark-mode) #adminPanelModal .modal-content,body:not(.dark-mode) #userPermissionsModal .modal-content,body:not(.dark-mode) #userFlagsModal .modal-content,body:not(.dark-mode) #userGroupsModal .modal-content,body:not(.dark-mode) #userPanelModal .modal-content,body:not(.dark-mode) #groupAclModal .modal-content,body:not(.dark-mode) .editor-modal,body:not(.dark-mode) #filePreviewModal .modal-content,body:not(.dark-mode) #loginForm,body:not(.dark-mode) .editor-header{background:var(--fr-surface-light)!important;border:1px solid var(--fr-border-light)!important;color:#111!important;border-radius:12px!important;box-shadow:0 4px 12px rgba(0,0,0,.12)!important}
#searchIcon{display:inline-flex;align-items:center;justify-content:center;width:38px;height:36px;padding:0;border-radius:999px 0 0 999px;border:1px solid rgba(0,0,0,.18);border-right:none;background:#fff;cursor:pointer;box-shadow:none;transform:none}
#searchIcon .material-icons{font-size:20px;line-height:1;color:#555}
#searchIcon:hover{background:#f5f5f5}
#searchIcon+#searchInput{height:36px;border-radius:0 999px 999px 0;border-left:none;padding-top:6px;padding-bottom:6px}
body.dark-mode #searchIcon{background:#212121;border-color:#3d3d3d}
body.dark-mode #searchIcon .material-icons{color:#f1f1f1}
body.dark-mode #searchIcon:hover{background:#303030}
body.dark-mode #searchIcon+#searchInput{border-left:none}
#advancedSearchToggle{border-radius:999px;border:1px solid #d0d0d0;padding:6px 12px;font-size:.9rem;background:#f5f5f5;color:#333;cursor:pointer;display:inline-flex;align-items:center;gap:4px;margin-right:8px;transition:background .15s ease,box-shadow .15s ease,transform .1s ease}
#advancedSearchToggle:hover,#advancedSearchToggle:focus-visible{background:#e8e8e8;box-shadow:0 1px 4px rgba(0,0,0,.16);outline:none;transform:translateY(-1px)}
.dark-mode #advancedSearchToggle{background:#2a2a2a;border-color:#444;color:#f1f1f1}
.dark-mode #advancedSearchToggle:hover,.dark-mode #advancedSearchToggle:focus-visible{background:#333;box-shadow:0 1px 4px rgba(0,0,0,.5)}
.custom-prev-next-btn{display:inline-flex;align-items:center;justify-content:center;min-width:64px;padding:6px 14px;font-size:13px;font-weight:500;border-radius:999px;border:1px solid rgba(0,0,0,.14);background:#f1f1f1;color:#111;cursor:pointer;transition:background-color 140ms ease-out,border-color 140ms ease-out,box-shadow 140ms ease-out,transform 120ms ease-out}
.custom-prev-next-btn:not(:disabled):hover{background:#e5e5e5;border-color:rgba(0,0,0,.22);box-shadow:0 2px 6px rgba(0,0,0,.18);transform:translateY(-1px)}
.custom-prev-next-btn:not(:disabled):active{transform:translateY(0);box-shadow:0 1px 3px rgba(0,0,0,.25)}
.custom-prev-next-btn:disabled{opacity:.5;cursor:default;box-shadow:none}
body.dark-mode .custom-prev-next-btn{background:#212121;border-color:#3d3d3d;color:#f1f1f1}
body.dark-mode .custom-prev-next-btn:not(:disabled):hover{background:#2a2a2a;border-color:#4a4a4a;box-shadow:0 2px 6px rgba(0,0,0,.7)}
input[type=text]:not(#searchInput),input[type=password],input[type=email],input[type=url],input[type=number],textarea,select{border:1px solid rgba(148,163,184,.6)!important;border-radius:10px;background:#ffffff;box-sizing:border-box}
#decreaseFont,#increaseFont{display:inline-flex;align-items:center;justify-content:center;margin-top:5px;height:24px;min-width:30px;padding:2px 8px;font-size:11px;font-weight:500;line-height:1;border-radius:999px;border:1px solid rgba(0,0,0,.16);background:#f5f5f5;color:#222;cursor:pointer;margin-left:4px;transition:background-color 140ms ease-out,border-color 140ms ease-out,box-shadow 140ms ease-out,transform 120ms ease-out}
#decreaseFont:not(:disabled):hover,#increaseFont:not(:disabled):hover{background:#e8e8e8;border-color:rgba(0,0,0,.24);box-shadow:0 1px 4px rgba(0,0,0,.18);transform:translateY(-1px)}
#decreaseFont:not(:disabled):active,#increaseFont:not(:disabled):active{transform:translateY(5px);box-shadow:0 1px 2px rgba(0,0,0,.25)}
#decreaseFont:disabled,#increaseFont:disabled{opacity:.5;cursor:default;box-shadow:none}
body.dark-mode #decreaseFont,body.dark-mode #increaseFont{background:#212121;border-color:#3d3d3d;color:#f1f1f1}
body.dark-mode #decreaseFont:not(:disabled):hover,body.dark-mode #increaseFont:not(:disabled):hover{background:#2a2a2a;border-color:#4a4a4a;box-shadow:0 1px 4px rgba(0,0,0,.7)}
#closeEditorX{margin-right:10px}
#fileList .folder-row-icon .folder-front{fill:var(--filr-folder-front,#f6b84e);stroke:var(--filr-folder-stroke,#a87312);stroke-width:.5;stroke-linejoin:round;stroke-linecap:round}
#fileList .folder-row-icon .folder-back{fill:var(--filr-folder-back,#fcd68a);stroke:var(--filr-folder-stroke,#a87312);stroke-width:.5;stroke-linejoin:round;stroke-linecap:round}
#fileList .folder-row-icon .paper{fill:#fff;stroke:#b2c2db;stroke-width:1;vector-effect:non-scaling-stroke}
#fileList .folder-row-icon .paper-fold{fill:#b2c2db}
#fileList .folder-row-icon .paper-line{stroke:#b2c2db;stroke-width:1;stroke-linecap:round;fill:none;vector-effect:non-scaling-stroke}
#fileList .folder-row-icon .paper-ink{stroke:#4da3ff;stroke-width:.9;stroke-linecap:round;stroke-linejoin:round;fill:none;opacity:.85}
#fileList .folder-row-icon .lip-highlight{fill:none;vector-effect:non-scaling-stroke;stroke-linecap:round;stroke-linejoin:round}
#fileList .folder-row-name{font-weight:500;margin-right:4px}
#fileList .folder-row-meta{margin-left:4px;opacity:.75;font-size:.86em}
#fileList tbody tr.folder-row{height:var(--file-row-height,44px);cursor:pointer}
#fileList tbody tr.folder-row .folder-name-cell{padding-top:0;padding-bottom:0}
#fileList tbody tr.folder-row .folder-row-inner{cursor:inherit}
#fileList tbody tr.folder-row .folder-icon-cell{text-align:left;vertical-align:middle}
#fileList tbody tr.folder-row .folder-row-icon svg{display:block}
.folder-row-icon{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;margin-right:8px;position:relative;left:-8px;top:5px}
.folder-row-inner{display:flex;align-items:center}
#fileList table.filr-table th.checkbox-col,#fileList table.filr-table td.checkbox-col,#fileList table.filr-table td.folder-icon-cell{width:30px!important;max-width:30px!important}
#fileList tr.folder-row.folder-row-droptarget{background:var(--filr-accent-50,rgba(250,204,21,.12));box-shadow:inset 0 0 0 1px var(--filr-accent-400,rgba(250,204,21,.6))}
#fileList tr.folder-row.folder-row-droptarget .folder-row-name{font-weight:600}
#fileList table.filr-table tbody tr.folder-row>td{padding-top:0!important;padding-bottom:0!important}
#fileList table.filr-table tbody tr.folder-row>td.folder-icon-cell{overflow:visible}
#fileList tr.folder-row .folder-row-inner,#fileList tr.folder-row .folder-row-name{cursor:inherit}

View File

@@ -90,7 +90,8 @@ export function initializeApp() {
window.currentFolder = last ? last : "root";
const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true';
// default: false (unchecked)
window.showFoldersInList = stored === 'true';
// Load public site config early (safe subset)
loadAdminConfigFunc();
@@ -99,6 +100,7 @@ export function initializeApp() {
initTagSearch();
/*
// Hook DnD relay from fileList area into upload area
const fileListArea = document.getElementById('fileList');
@@ -146,7 +148,7 @@ export function initializeApp() {
uploadArea.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
}
});
}
}*/
// App subsystems
initDragAndDrop();

View File

@@ -351,30 +351,73 @@ export async function openUserPanel() {
langFs.appendChild(langSel);
content.appendChild(langFs);
// --- Display fieldset: “Show folders above files” ---
// --- Display fieldset: strip + inline folder rows ---
const dispFs = document.createElement('fieldset');
dispFs.style.marginBottom = '15px';
const dispLegend = document.createElement('legend');
dispLegend.textContent = t('display');
dispFs.appendChild(dispLegend);
const dispLabel = document.createElement('label');
dispLabel.style.cursor = 'pointer';
const dispCb = document.createElement('input');
dispCb.type = 'checkbox';
dispCb.id = 'showFoldersInList';
dispCb.style.verticalAlign = 'middle';
const stored = localStorage.getItem('showFoldersInList');
dispCb.checked = stored === null ? true : stored === 'true';
dispLabel.appendChild(dispCb);
dispLabel.append(` ${t('show_folders_above_files')}`);
dispFs.appendChild(dispLabel);
// 1) Show folder strip above list
const stripLabel = document.createElement('label');
stripLabel.style.cursor = 'pointer';
stripLabel.style.display = 'block';
stripLabel.style.marginBottom = '4px';
const stripCb = document.createElement('input');
stripCb.type = 'checkbox';
stripCb.id = 'showFoldersInList';
stripCb.style.verticalAlign = 'middle';
{
const storedStrip = localStorage.getItem('showFoldersInList');
// default: unchecked
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
}
stripLabel.appendChild(stripCb);
stripLabel.append(` ${t('show_folders_above_files')}`);
dispFs.appendChild(stripLabel);
// 2) Show inline folder rows above files in table view
const inlineLabel = document.createElement('label');
inlineLabel.style.cursor = 'pointer';
inlineLabel.style.display = 'block';
const inlineCb = document.createElement('input');
inlineCb.type = 'checkbox';
inlineCb.id = 'showInlineFolders';
inlineCb.style.verticalAlign = 'middle';
{
const storedInline = localStorage.getItem('showInlineFolders');
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
}
inlineLabel.appendChild(inlineCb);
// youll want a string like this in i18n:
// "show_inline_folders": "Show folders inline (above files)"
inlineLabel.append(` ${t('show_inline_folders') || 'Show folders inline (above files)'}`);
dispFs.appendChild(inlineLabel);
content.appendChild(dispFs);
dispCb.addEventListener('change', () => {
window.showFoldersInList = dispCb.checked;
localStorage.setItem('showFoldersInList', dispCb.checked);
// reload the entire file list (and strip) in one go:
loadFileList(window.currentFolder);
// Handlers: toggle + refresh list
stripCb.addEventListener('change', () => {
window.showFoldersInList = stripCb.checked;
localStorage.setItem('showFoldersInList', stripCb.checked);
if (typeof window.loadFileList === 'function') {
window.loadFileList(window.currentFolder || 'root');
}
});
inlineCb.addEventListener('change', () => {
window.showInlineFolders = inlineCb.checked;
localStorage.setItem('showInlineFolders', inlineCb.checked);
if (typeof window.loadFileList === 'function') {
window.loadFileList(window.currentFolder || 'root');
}
});
// wire up imageinput change
@@ -425,6 +468,18 @@ export async function openUserPanel() {
modal.querySelector('#userTOTPEnabled').checked = totp_enabled;
modal.querySelector('#languageSelector').value = localStorage.getItem('language') || 'en';
modal.querySelector('h3').textContent = `${t('user_panel')} (${username})`;
// sync display toggles from localStorage
const stripCb = modal.querySelector('#showFoldersInList');
const inlineCb = modal.querySelector('#showInlineFolders');
if (stripCb) {
const storedStrip = localStorage.getItem('showFoldersInList');
stripCb.checked = storedStrip === null ? false : storedStrip === 'true';
}
if (inlineCb) {
const storedInline = localStorage.getItem('showInlineFolders');
inlineCb.checked = storedInline === null ? true : storedInline === 'true';
}
}
// show

View File

@@ -160,11 +160,11 @@ export function buildFileTableHeader(sortOrder) {
<thead>
<tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
<th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="hide-small sortable-col">${t("file_size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("uploader")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="name" class="sortable-col">${t("name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("created")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="size" class="hide-small sortable-col">${t("size")} ${sortOrder.column === "size" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploader" class="hide-small hide-medium sortable-col">${t("owner")} ${sortOrder.column === "uploader" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th>${t("actions")}</th>
</tr>
</thead>

View File

@@ -240,16 +240,29 @@ window.addEventListener('resize', () => {
if (strip) applyFolderStripLayout(strip);
});
// Listen once: update strip + tree when folder color changes
// Listen once: update strip + tree + inline rows when folder color changes
window.addEventListener('folderColorChanged', (e) => {
const { folder } = e.detail || {};
if (!folder) return;
// Update the strip (if that folder is currently shown)
// 1) Update the strip (if that folder is currently shown)
repaintStripIcon(folder);
// And refresh the tree icon too (existing function)
// 2) Refresh the tree icon (existing function)
try { refreshFolderIcon(folder); } catch { }
// 3) Repaint any inline folder rows in the file table
try {
const safeFolder = CSS.escape(folder);
document
.querySelectorAll(`#fileList tr.folder-row[data-folder="${safeFolder}"]`)
.forEach(row => {
// reuse the same helper we used when injecting inline rows
attachStripIconAsync(row, folder, 28);
});
} catch {
// CSS.escape might not exist on very old browsers; fail silently
}
});
// Hide "Edit" for files >10 MiB
@@ -259,11 +272,25 @@ const MAX_EDIT_BYTES = 10 * 1024 * 1024;
let __fileListReqSeq = 0;
window.itemsPerPage = parseInt(
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '50',
10
);
window.currentPage = window.currentPage || 1;
window.viewMode = localStorage.getItem("viewMode") || "table";
window.currentSubfolders = window.currentSubfolders || [];
// Default folder display settings from localStorage
try {
const storedStrip = localStorage.getItem('showFoldersInList');
const storedInline = localStorage.getItem('showInlineFolders');
window.showFoldersInList = storedStrip === null ? true : storedStrip === 'true';
window.showInlineFolders = storedInline === null ? true : storedInline === 'true';
} catch {
// if localStorage blows up, fall back to both enabled
window.showFoldersInList = true;
window.showInlineFolders = true;
}
// Global flag for advanced search mode.
window.advancedSearchEnabled = false;
@@ -387,7 +414,6 @@ function attachStripIconAsync(hostEl, fullPath, size = 28) {
const back = _lighten(hex, 14);
const stroke = _darken(hex, 22);
// apply vars on the tile (or icon span)
hostEl.style.setProperty('--filr-folder-front', front);
hostEl.style.setProperty('--filr-folder-back', back);
hostEl.style.setProperty('--filr-folder-stroke', stroke);
@@ -395,15 +421,26 @@ function attachStripIconAsync(hostEl, fullPath, size = 28) {
const iconSpan = hostEl.querySelector('.folder-svg');
if (!iconSpan) return;
// 1) initial "empty" icon
iconSpan.dataset.kind = 'empty';
iconSpan.innerHTML = folderSVG('empty'); // size is baked into viewBox; add a size arg if you prefer
iconSpan.innerHTML = folderSVG('empty');
// make sure this brand-new SVG is sized correctly
try { syncFolderIconSizeToRowHeight(); } catch {}
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(fullPath)}&t=${Date.now()}`;
_fetchJSONWithTimeout(url, 2500).then(({ folders = 0, files = 0 }) => {
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
iconSpan.dataset.kind = 'paper';
iconSpan.innerHTML = folderSVG('paper');
}
}).catch(() => { });
_fetchJSONWithTimeout(url, 2500)
.then(({ folders = 0, files = 0 }) => {
if ((folders + files) > 0 && iconSpan.dataset.kind !== 'paper') {
// 2) swap to "paper" icon
iconSpan.dataset.kind = 'paper';
iconSpan.innerHTML = folderSVG('paper');
// re-apply sizing to this new SVG too
try { syncFolderIconSizeToRowHeight(); } catch {}
}
})
.catch(() => { /* ignore */ });
}
/* -----------------------------
@@ -426,6 +463,56 @@ async function safeJson(res) {
}
return body ?? {};
}
// --- Folder capabilities + owner cache ----------------------
const _folderCapsCache = new Map();
async function fetchFolderCaps(folder) {
if (!folder) return null;
if (_folderCapsCache.has(folder)) {
return _folderCapsCache.get(folder);
}
try {
const res = await fetch(
`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`,
{ credentials: 'include' }
);
const data = await safeJson(res);
_folderCapsCache.set(folder, data || null);
if (data && (data.owner || data.user)) {
_folderOwnerCache.set(folder, data.owner || data.user || "");
}
return data || null;
} catch {
_folderCapsCache.set(folder, null);
return null;
}
}
// --- Folder owner cache + helper ----------------------
const _folderOwnerCache = new Map();
async function fetchFolderOwner(folder) {
if (!folder) return "";
if (_folderOwnerCache.has(folder)) {
return _folderOwnerCache.get(folder);
}
try {
const res = await fetch(
`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`,
{ credentials: 'include' }
);
const data = await safeJson(res);
const owner = data && (data.owner || data.user || "");
_folderOwnerCache.set(folder, owner || "");
return owner || "";
} catch {
_folderOwnerCache.set(folder, "");
return "";
}
}
// ---- Viewed badges (table + gallery) ----
// ---------- Badge factory (center text vertically) ----------
function makeBadge(state) {
@@ -917,6 +1004,13 @@ export async function loadFileList(folderParam) {
document.documentElement.style.setProperty("--file-row-height", v + "px");
localStorage.setItem("rowHeight", v);
rowValue.textContent = v + "px";
// mark compact mode for very low heights
if (v <= 32) {
document.documentElement.setAttribute('data-row-compact', '1');
} else {
document.documentElement.removeAttribute('data-row-compact');
}
syncFolderIconSizeToRowHeight();
};
}
}
@@ -966,6 +1060,9 @@ export async function loadFileList(folderParam) {
return !hidden.has(lower) && !lower.startsWith("resumable_");
});
// Expose for inline folder rows in table view
window.currentSubfolders = subfolders;
let strip = document.getElementById("folderStripContainer");
if (!strip) {
strip = document.createElement("div");
@@ -976,6 +1073,11 @@ export async function loadFileList(folderParam) {
// NEW: paged + responsive strip
renderFolderStripPaged(strip, subfolders);
// Re-render table view once folders are known so they appear inline above files
if (window.viewMode === "table" && reqId === __fileListReqSeq) {
renderFileTable(folder);
}
} catch {
// ignore folder errors; rows already rendered
}
@@ -1000,24 +1102,456 @@ export async function loadFileList(folderParam) {
}
}
function injectInlineFolderRows(fileListContent, folder, pageSubfolders) {
const table = fileListContent.querySelector('table.filr-table');
// Use the paged subfolders if provided, otherwise fall back to all
const subfolders = Array.isArray(pageSubfolders) && pageSubfolders.length
? pageSubfolders
: (Array.isArray(window.currentSubfolders) ? window.currentSubfolders : []);
if (!table || !subfolders.length) return;
const thead = table.tHead;
const tbody = table.tBodies && table.tBodies[0];
if (!thead || !tbody) return;
const headerRow = thead.rows[0];
if (!headerRow) return;
const headerCells = Array.from(headerRow.cells);
const colCount = headerCells.length;
// --- Column indices -------------------------------------------------------
let checkboxIdx = headerCells.findIndex(th =>
th.classList.contains("checkbox-col") ||
th.querySelector('input[type="checkbox"]')
);
let nameIdx = headerCells.findIndex(th =>
(th.dataset && th.dataset.column === "name") ||
/\bname\b/i.test((th.textContent || "").trim())
);
if (nameIdx < 0) {
nameIdx = Math.min(1, colCount - 1); // fallback to 2nd col
}
let sizeIdx = headerCells.findIndex(th =>
(th.dataset && (th.dataset.column === "size" || th.dataset.column === "filesize")) ||
/\bsize\b/i.test((th.textContent || "").trim())
);
if (sizeIdx < 0) sizeIdx = -1;
let uploaderIdx = headerCells.findIndex(th =>
(th.dataset && th.dataset.column === "uploader") ||
/\buploader\b/i.test((th.textContent || "").trim())
);
if (uploaderIdx < 0) uploaderIdx = -1;
let actionsIdx = headerCells.findIndex(th =>
(th.dataset && th.dataset.column === "actions") ||
/\bactions?\b/i.test((th.textContent || "").trim()) ||
/\bactions?-col\b/i.test(th.className || "")
);
if (actionsIdx < 0) actionsIdx = -1;
// Remove any previous folder rows
tbody.querySelectorAll("tr.folder-row").forEach(tr => tr.remove());
const firstDataRow = tbody.firstElementChild;
subfolders.forEach(sf => {
const tr = document.createElement("tr");
tr.classList.add("folder-row");
tr.dataset.folder = sf.full;
for (let i = 0; i < colCount; i++) {
const td = document.createElement("td");
// *** copy header classes so responsive breakpoints match file rows ***
// but strip Bootstrap margin helpers (ml-2 / mx-2) so we don't get a big gap
const headerClass = headerCells[i] && headerCells[i].className;
if (headerClass) {
td.className = headerClass;
td.classList.remove("ml-2", "mx-2");
}
// 1) icon / checkbox column
if (i === checkboxIdx) {
td.classList.add("folder-icon-cell");
td.style.textAlign = "left";
td.style.verticalAlign = "middle";
const iconSpan = document.createElement("span");
iconSpan.className = "folder-svg folder-row-icon";
td.appendChild(iconSpan);
// 2) name column
} else if (i === nameIdx) {
td.classList.add("name-cell", "file-name-cell", "folder-name-cell");
const wrap = document.createElement("div");
wrap.className = "folder-row-inner";
const nameSpan = document.createElement("span");
nameSpan.className = "folder-row-name";
nameSpan.textContent = sf.name || sf.full;
const metaSpan = document.createElement("span");
metaSpan.className = "folder-row-meta";
metaSpan.textContent = ""; // "(15 folders, 19 files)" later
wrap.appendChild(nameSpan);
wrap.appendChild(metaSpan);
td.appendChild(wrap);
// 3) size column
} else if (i === sizeIdx) {
td.classList.add("folder-size-cell");
td.textContent = "…"; // placeholder until we load stats
// 4) uploader / owner column
} else if (i === uploaderIdx) {
td.classList.add("uploader-cell", "folder-uploader-cell");
td.textContent = ""; // filled asynchronously with owner
// 5) actions column
} else if (i === actionsIdx) {
td.classList.add("folder-actions-cell");
const group = document.createElement("div");
group.className = "btn-group btn-group-sm folder-actions-group";
group.setAttribute("role", "group");
group.setAttribute("aria-label", "File actions");
const makeActionBtn = (iconName, titleKey, btnClass, actionKey, handler) => {
const btn = document.createElement("button");
btn.type = "button";
// base classes same size as file actions
btn.className = `btn ${btnClass} py-1`;
// kill any Bootstrap margin helpers that got passed in
btn.classList.remove("ml-2", "mx-2");
btn.setAttribute("data-folder-action", actionKey);
btn.setAttribute("data-i18n-title", titleKey);
btn.title = t(titleKey);
const icon = document.createElement("i");
icon.className = "material-icons";
icon.textContent = iconName;
btn.appendChild(icon);
btn.addEventListener("click", e => {
e.stopPropagation();
window.currentFolder = sf.full;
try { localStorage.setItem("lastOpenedFolder", sf.full); } catch {}
handler();
});
// start disabled; caps logic will enable
btn.disabled = true;
btn.style.pointerEvents = "none";
btn.style.opacity = "0.5";
group.appendChild(btn);
};
makeActionBtn("drive_file_move", "move_folder", "btn-warning folder-move-btn", "move", () => openMoveFolderUI());
makeActionBtn("palette", "color_folder", "btn-color-folder","color", () => openColorFolderModal(sf.full));
makeActionBtn("drive_file_rename_outline", "rename_folder", "btn-warning folder-rename-btn", "rename", () => openRenameFolderModal());
makeActionBtn("share", "share_folder", "btn-secondary", "share", () => openFolderShareModal(sf.full));
td.appendChild(group);
}
// IMPORTANT: always append the cell, no matter which column we're in
tr.appendChild(td);
}
// click → navigate, same as before
tr.addEventListener("click", e => {
if (e.button !== 0) return;
const dest = sf.full;
if (!dest) return;
window.currentFolder = dest;
try { localStorage.setItem("lastOpenedFolder", dest); } catch { }
updateBreadcrumbTitle(dest);
document.querySelectorAll(".folder-option.selected")
.forEach(o => o.classList.remove("selected"));
const treeNode = document.querySelector(
`.folder-option[data-folder="${CSS.escape(dest)}"]`
);
if (treeNode) treeNode.classList.add("selected");
const strip = document.getElementById("folderStripContainer");
if (strip) {
strip.querySelectorAll(".folder-item.selected")
.forEach(i => i.classList.remove("selected"));
const stripItem = strip.querySelector(
`.folder-item[data-folder="${CSS.escape(dest)}"]`
);
if (stripItem) stripItem.classList.add("selected");
}
loadFileList(dest);
});
// DnD + context menu keep existing logic, but also add a visual highlight
tr.addEventListener("dragover", e => {
folderDragOverHandler(e);
tr.classList.add("folder-row-droptarget");
});
tr.addEventListener("dragleave", e => {
folderDragLeaveHandler(e);
tr.classList.remove("folder-row-droptarget");
});
tr.addEventListener("drop", e => {
folderDropHandler(e);
tr.classList.remove("folder-row-droptarget");
});
tr.addEventListener("contextmenu", e => {
e.preventDefault();
e.stopPropagation();
const dest = sf.full;
if (!dest) return;
window.currentFolder = dest;
try { localStorage.setItem("lastOpenedFolder", dest); } catch { }
const menuItems = [
{ label: t("create_folder"), action: () => document.getElementById("createFolderModal").style.display = "block" },
{ label: t("move_folder"), action: () => openMoveFolderUI() },
{ label: t("rename_folder"), action: () => openRenameFolderModal() },
{ label: t("color_folder"), action: () => openColorFolderModal(dest) },
{ label: t("folder_share"), action: () => openFolderShareModal(dest) },
{ label: t("delete_folder"), action: () => openDeleteFolderModal() }
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
});
// insert row above first file row
tbody.insertBefore(tr, firstDataRow || null);
// ----- ICON: color + alignment (size is driven by row height) -----
attachStripIconAsync(tr, sf.full);
const iconSpan = tr.querySelector(".folder-row-icon");
if (iconSpan) {
iconSpan.style.display = "inline-flex";
iconSpan.style.alignItems = "center";
iconSpan.style.justifyContent = "flex-start";
iconSpan.style.marginLeft = "0px"; // small left nudge
iconSpan.style.marginTop = "0px"; // small down nudge
}
// ----- FOLDER STATS + OWNER + CAPS (keep your existing code below here) -----
const sizeCellIndex = (sizeIdx >= 0 && sizeIdx < tr.cells.length) ? sizeIdx : -1;
const nameCellIndex = (nameIdx >= 0 && nameIdx < tr.cells.length) ? nameIdx : -1;
const url = `/api/folder/isEmpty.php?folder=${encodeURIComponent(sf.full)}&t=${Date.now()}`;
_fetchJSONWithTimeout(url, 2500).then(stats => {
if (!stats) return;
const foldersCount = Number.isFinite(stats.folders) ? stats.folders : 0;
const filesCount = Number.isFinite(stats.files) ? stats.files : 0;
const bytes = Number.isFinite(stats.bytes)
? stats.bytes
: (Number.isFinite(stats.sizeBytes) ? stats.sizeBytes : null);
let pieces = [];
if (foldersCount) pieces.push(`${foldersCount} folder${foldersCount === 1 ? "" : "s"}`);
if (filesCount) pieces.push(`${filesCount} file${filesCount === 1 ? "" : "s"}`);
if (!pieces.length) pieces.push("0 items");
const countLabel = pieces.join(", ");
if (nameCellIndex >= 0) {
const nameCell = tr.cells[nameCellIndex];
if (nameCell) {
const metaSpan = nameCell.querySelector(".folder-row-meta");
if (metaSpan) metaSpan.textContent = ` (${countLabel})`;
}
}
if (sizeCellIndex >= 0) {
const sizeCell = tr.cells[sizeCellIndex];
if (sizeCell) {
let sizeLabel = "—";
if (bytes != null && bytes >= 0) {
sizeLabel = formatSize(bytes);
}
sizeCell.textContent = sizeLabel;
sizeCell.title = `${countLabel}${bytes != null && bytes >= 0 ? " • " + sizeLabel : ""}`;
}
}
}).catch(() => {
if (sizeCellIndex >= 0) {
const sizeCell = tr.cells[sizeCellIndex];
if (sizeCell && !sizeCell.textContent) sizeCell.textContent = "—";
}
});
// OWNER + action permissions
if (uploaderIdx >= 0 || actionsIdx >= 0) {
fetchFolderCaps(sf.full).then(caps => {
if (!caps || !document.body.contains(tr)) return;
if (uploaderIdx >= 0 && uploaderIdx < tr.cells.length) {
const uploaderCell = tr.cells[uploaderIdx];
if (uploaderCell) {
const owner = caps.owner || caps.user || "";
uploaderCell.textContent = owner || "";
}
}
if (actionsIdx >= 0 && actionsIdx < tr.cells.length) {
const actCell = tr.cells[actionsIdx];
if (!actCell) return;
actCell.querySelectorAll('button[data-folder-action]').forEach(btn => {
const action = btn.getAttribute('data-folder-action');
let enabled = false;
switch (action) {
case "move":
enabled = !!caps.canMoveFolder;
break;
case "color":
enabled = !!caps.canRename; // same gate as tree “color” button
break;
case "rename":
enabled = !!caps.canRename;
break;
case "share":
enabled = !!caps.canShareFolder;
break;
}
if (enabled === undefined) {
enabled = true; // fallback so admin still gets buttons even if a flag is missing
}
if (enabled) {
btn.disabled = false;
btn.style.pointerEvents = "";
btn.style.opacity = "";
} else {
btn.disabled = true;
btn.style.pointerEvents = "none";
btn.style.opacity = "0.5";
}
});
}
}).catch(() => { /* ignore */ });
}
});
syncFolderIconSizeToRowHeight();
}
function syncFolderIconSizeToRowHeight() {
const cs = getComputedStyle(document.documentElement);
const raw = cs.getPropertyValue('--file-row-height') || '48px';
const rowH = parseInt(raw, 10) || 60;
const FUDGE = 5;
const MAX_GROWTH_ROW = 44; // after this, stop growing the icon
const BASE_ROW_FOR_OFFSET = 40; // where icon looks centered
const OFFSET_FACTOR = 0.25;
// cap growth for size, like you already do
const effectiveRow = Math.min(rowH, MAX_GROWTH_ROW);
const boxSize = Math.max(25, Math.min(35, effectiveRow - 20 + FUDGE));
const scale = 1.20;
// use your existing offset curve
const clampedForOffset = Math.max(30, Math.min(60, rowH));
let offsetY = (clampedForOffset - BASE_ROW_FOR_OFFSET) * OFFSET_FACTOR;
// 3044: untouched (you said this range is perfect)
// 4560: same curve, but shifted up slightly
if (rowH > 53) {
offsetY -= 3;
}
document.querySelectorAll('#fileList .folder-row-icon').forEach(iconSpan => {
iconSpan.style.width = boxSize + 'px';
iconSpan.style.height = boxSize + 'px';
iconSpan.style.overflow = 'visible';
const svg = iconSpan.querySelector('svg');
if (!svg) return;
svg.setAttribute('width', String(boxSize));
svg.setAttribute('height', String(boxSize));
svg.style.transformOrigin = 'left center';
svg.style.transform = `translateY(${offsetY}px) scale(${scale})`;
});
}
/**
* Render table view
*/
export function renderFileTable(folder, container, subfolders) {
const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "50", 10);
let currentPage = window.currentPage || 1;
// Files (filtered by search)
const filteredFiles = searchFiles(searchTerm);
const totalFiles = filteredFiles.length;
const totalPages = Math.ceil(totalFiles / itemsPerPageSetting);
// Inline folders: sort once (Explorer-style A→Z)
const allSubfolders = Array.isArray(window.currentSubfolders)
? window.currentSubfolders
: [];
const subfoldersSorted = [...allSubfolders].sort((a, b) =>
(a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" })
);
const totalFiles = filteredFiles.length;
const totalFolders = subfoldersSorted.length;
const totalRows = totalFiles + totalFolders;
const hasFolders = totalFolders > 0;
// Pagination is now over (folders + files)
const totalPages = totalRows > 0
? Math.ceil(totalRows / itemsPerPageSetting)
: 1;
if (currentPage > totalPages) {
currentPage = totalPages > 0 ? totalPages : 1;
currentPage = totalPages;
window.currentPage = currentPage;
}
const startRow = (currentPage - 1) * itemsPerPageSetting;
const endRow = Math.min(startRow + itemsPerPageSetting, totalRows);
// Figure out which folders + files belong to THIS page
const pageFolders = [];
const pageFiles = [];
for (let rowIndex = startRow; rowIndex < endRow; rowIndex++) {
if (rowIndex < totalFolders) {
pageFolders.push(subfoldersSorted[rowIndex]);
} else {
const fileIdx = rowIndex - totalFolders;
const file = filteredFiles[fileIdx];
if (file) pageFiles.push(file);
}
}
// Stable id per file row on this page
const rowIdFor = (file, idx) =>
`${encodeURIComponent(file.name)}-p${currentPage}-${idx}`;
// We pass a harmless "base" string to keep buildFileTableRow happy,
// then we will FIX the preview/thumbnail URLs to the API below.
const fakeBase = "#/";
@@ -1040,19 +1574,16 @@ export function renderFileTable(folder, container, subfolders) {
return `<table class="filr-table"${attrs}>`;
});
const startIndex = (currentPage - 1) * itemsPerPageSetting;
const endIndex = Math.min(startIndex + itemsPerPageSetting, totalFiles);
let rowsHTML = "<tbody>";
if (totalFiles > 0) {
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
// Build row with a neutral base, then correct the links/preview below.
// Give the row an ID so we can patch attributes safely
const idSafe = encodeURIComponent(file.name) + "-" + (startIndex + idx);
let rowsHTML = "<tbody>";
if (pageFiles.length > 0) {
pageFiles.forEach((file, idx) => {
const rowKey = rowIdFor(file, idx);
let rowHTML = buildFileTableRow(file, fakeBase);
// add row id + data-file-name, and ensure the name cell also has "name-cell"
rowHTML = rowHTML
.replace("<tr", `<tr id="file-row-${idSafe}" data-file-name="${escapeHTML(file.name)}"`)
.replace("<tr", `<tr id="file-row-${rowKey}" data-file-name="${escapeHTML(file.name)}"`)
.replace('class="file-name-cell"', 'class="file-name-cell name-cell"');
let tagBadgesHTML = "";
@@ -1063,54 +1594,63 @@ export function renderFileTable(folder, container, subfolders) {
});
tagBadgesHTML += "</div>";
}
rowsHTML += rowHTML.replace(
/(<td\s+class="[^"]*\bfile-name-cell\b[^"]*">)([\s\S]*?)(<\/td>)/,
(m, open, inner, close) => {
// keep the original filename content, then add your tag badges, then close
return `${open}<span class="filename-text">${inner}</span>${tagBadgesHTML}${close}`;
}
);
});
} else {
rowsHTML += `<tr><td colspan="8">No files found.</td></tr>`;
} else if (!hasFolders && totalFiles === 0) {
// Only show "No files found" if there are no folders either
rowsHTML += `<tr><td colspan="8">${t("no_files_found") || "No files found."}</td></tr>`;
}
rowsHTML += "</tbody></table>";
const bottomControlsHTML = buildBottomControls(itemsPerPageSetting);
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
// Inject inline folder rows for THIS page (Explorer-style)
if (window.showInlineFolders !== false && pageFolders.length) {
injectInlineFolderRows(fileListContent, folder, pageFolders);
}
wireSelectAll(fileListContent);
// PATCH each row's preview/thumb to use the secure API URLs
if (totalFiles > 0) {
filteredFiles.slice(startIndex, endIndex).forEach((file, idx) => {
const rowEl = document.getElementById(`file-row-${encodeURIComponent(file.name)}-${startIndex + idx}`);
if (!rowEl) return;
const previewUrl = apiFileUrl(file.folder || folder, file.name, true);
// Preview button dataset
const previewBtn = rowEl.querySelector(".preview-btn");
if (previewBtn) {
previewBtn.dataset.previewUrl = previewUrl;
previewBtn.dataset.previewName = file.name;
}
// Thumbnail (if present)
const thumbImg = rowEl.querySelector("img");
if (thumbImg) {
thumbImg.src = previewUrl;
thumbImg.setAttribute("data-cache-key", previewUrl);
}
// Any anchor that might have been built to point at a file path
rowEl.querySelectorAll('a[href]').forEach(a => {
// Only rewrite obvious file anchors (ignore actions with '#', 'javascript:', etc.)
if (/^#|^javascript:/i.test(a.getAttribute('href') || '')) return;
a.href = previewUrl;
// PATCH each row's preview/thumb to use the secure API URLs
if (pageFiles.length > 0) {
pageFiles.forEach((file, idx) => {
const rowKey = rowIdFor(file, idx);
const rowEl = document.getElementById(`file-row-${rowKey}`);
if (!rowEl) return;
const previewUrl = apiFileUrl(file.folder || folder, file.name, true);
// Preview button dataset
const previewBtn = rowEl.querySelector(".preview-btn");
if (previewBtn) {
previewBtn.dataset.previewUrl = previewUrl;
previewBtn.dataset.previewName = file.name;
}
// Thumbnail (if present)
const thumbImg = rowEl.querySelector("img");
if (thumbImg) {
thumbImg.src = previewUrl;
thumbImg.setAttribute("data-cache-key", previewUrl);
}
// Any anchor that might have been built to point at a file path
rowEl.querySelectorAll('a[href]').forEach(a => {
// Only rewrite obvious file anchors (ignore actions with '#', 'javascript:', etc.)
if (/^#|^javascript:/i.test(a.getAttribute('href') || '')) return;
a.href = previewUrl;
});
});
});
}
}
fileListContent.querySelectorAll('.folder-item').forEach(el => {
el.addEventListener('click', () => loadFileList(el.dataset.folder));
@@ -1147,7 +1687,7 @@ export function renderFileTable(folder, container, subfolders) {
renderFileTable(folder, container);
});
// Row-select
// Row-select (only file rows have checkboxes; folder rows are ignored here)
fileListContent.querySelectorAll("tbody tr").forEach(row => {
row.addEventListener("click", e => {
const cb = row.querySelector(".file-checkbox");
@@ -1156,6 +1696,8 @@ export function renderFileTable(folder, container, subfolders) {
});
});
// Download buttons
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
btn.addEventListener("click", e => {
@@ -1248,12 +1790,15 @@ export function renderFileTable(folder, container, subfolders) {
});
updateFileActionButtons();
// Dragstart only for file rows (skip folder rows)
document.querySelectorAll("#fileList tbody tr").forEach(row => {
if (row.classList.contains("folder-row")) return;
row.setAttribute("draggable", "true");
import('./fileDragDrop.js?v={{APP_QVER}}').then(module => {
row.addEventListener("dragstart", module.fileDragStartHandler);
});
});
document.querySelectorAll(".download-btn, .edit-btn, .rename-btn").forEach(btn => {
btn.addEventListener("click", e => e.stopPropagation());
});

View File

@@ -928,7 +928,6 @@ export function openColorFolderModal(folder) {
border: 1px solid var(--ghost-border, #cfcfcf);
color: var(--ghost-fg, #222);
padding: 6px 12px;
border-radius: 8px;
}
#colorFolderModal .btn-ghost:hover {
background: var(--ghost-hover-bg, #f5f5f5);

View File

@@ -268,7 +268,7 @@ const translations = {
"columns": "Columns",
"row_height": "Row Height",
"api_docs": "API Docs",
"show_folders_above_files": "Show folders above files",
"show_folders_above_files": "Show folder strip above list",
"display": "Display",
"create_file": "Create File",
"create_new_file": "Create New File",
@@ -331,7 +331,13 @@ const translations = {
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder.",
"load_more_folders": "Load More Folders"
"load_more_folders": "Load More Folders",
"show_inline_folders": "Show folders as rows above files",
"name": "Name",
"size": "Size",
"modified": "Modified",
"created": "Created",
"owner": "Owner"
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v1.9.12';
window.APP_VERSION = 'v1.9.14';

View File

@@ -11,87 +11,111 @@ class FolderModel
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
* ============================================================ */
public static function countVisible(string $folder, string $user, array $perms): array
{
$folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) return ['folders' => 0, 'files' => 0];
$base = realpath((string)UPLOAD_DIR);
if ($base === false) return ['folders' => 0, 'files' => 0];
// Resolve target dir + ACL-relative prefix
if ($folder === 'root') {
$dir = $base;
$relPrefix = '';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) return ['folders' => 0, 'files' => 0];
}
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) return ['folders' => 0, 'files' => 0];
$relPrefix = implode('/', $parts);
}
// Ignore lists (expandable)
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir);
if ($entries === false) return ['folders' => 0, 'files' => 0];
$hasChildFolder = false;
$hasFile = false;
// Cap scanning to avoid pathological dirs
$MAX_SCAN = 4000;
$scanned = 0;
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) break;
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name;
if (@is_dir($abs)) {
// Symlink defense on children
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) continue;
}
// Only count child dirs the user can view (admin/read/read_own)
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
if (
ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel)
) {
$hasChildFolder = true;
}
} elseif (@is_file($abs)) {
// Any file present is enough for the "files" flag once the folder itself is viewable
$hasFile = true;
}
if ($hasChildFolder && $hasFile) break; // early exit
}
return [
'folders' => $hasChildFolder ? 1 : 0,
'files' => $hasFile ? 1 : 0,
];
}
public static function countVisible(string $folder, string $user, array $perms): array
{
$folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
// NEW: distinguish full read vs own-only for this folder
$hasFullRead = ACL::isAdmin($perms) || ACL::canRead($user, $perms, $folder);
// if !$hasFullRead but $canViewFolder is true, theyre effectively "view own" only
$base = realpath((string)UPLOAD_DIR);
if ($base === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
// Resolve target dir + ACL-relative prefix
if ($folder === 'root') {
$dir = $base;
$relPrefix = '';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
}
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$relPrefix = implode('/', $parts);
}
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir);
if ($entries === false) {
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$folderCount = 0;
$fileCount = 0;
$totalBytes = 0;
$MAX_SCAN = 4000;
$scanned = 0;
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) {
break;
}
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name;
if (@is_dir($abs)) {
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) {
continue;
}
}
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
if (
ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel)
) {
$folderCount++;
}
} elseif (@is_file($abs)) {
// Only count files if the user has full read on *this* folder.
// If theyre view_own-only here, dont leak or mis-report counts.
if (!$hasFullRead) {
continue;
}
$fileCount++;
$sz = @filesize($abs);
if (is_int($sz) && $sz > 0) {
$totalBytes += $sz;
}
}
}
return [
'folders' => $folderCount,
'files' => $fileCount,
'bytes' => $totalBytes,
];
}
/* Helpers (private) */
private static function isSafeSegment(string $name): bool