Compare commits

...

7 Commits

42 changed files with 4577 additions and 882 deletions

View File

@@ -1,5 +1,87 @@
# Changelog
## Changes 11/23/2025 (v2.0.0)
### FileRise Core v2.0.0 & FileRise Pro v1.1.0
```text
release(v2.0.0): feat(pro): client portals + portal login flow
release(v2.0.1): fix: harden portal + core login redirects for codeql
```
### Core v2.0.0
- **Portal plumbing in core**
- New public pages: `portal.html` and `portal-login.html` for client-facing views.
- New portal controller + API endpoints that read portal definitions from the Pro bundle, enforce expiry, and expose safe public metadata.
- Login flow now respects a `?redirect=` parameter so portals can bounce through login cleanly and land back on the right slug.
- **Admin UX + styling**
- Admin panel CSS pulled into a dedicated `adminPanelStyles.js` helper instead of inline styles.
- User Groups and Client Portals modals use the new shared styling and dark-mode tweaks so they match the rest of the UI.
- **Breadcrumb root fix**
- Breadcrumbs now always show **root** explicitly and behave correctly when youre at top level vs nested folders.
- **Routing**
- Apache rewrite added for pretty portal URLs:
`https://host/portal/<slug>``portal.html?slug=<slug>` without affecting other routes.
### Pro v1.1.0 Client Portals
- **Client portal definitions (Admin → FileRise Pro → Client Portals)**
- Create multiple portals, each with:
- Slug + display name
- Target folder
- Optional client email
- Upload-only / allow-download flags
- Per-portal expiry date
- Portal-level copy and branding:
- Optional title + instructions
- Accent color used throughout the portal UI
- Footer text at bottom of the portal page
- **Optional intake form before uploads**
- Enable a form per portal with fields: name, email, reference, notes.
- Per-field “default value” and “required” toggles.
- Form must be completed before uploads when enabled.
- **Submissions log**
- Each portal keeps a submissions list showing:
- Date/time, folder, submitting user, IP address
- The intake form values (name, email, reference, notes).
- **Client-facing experience**
- New portal UI with:
- Branded header (title + accent color)
- Optional intake form
- Drag-and-drop upload dropzone
- If downloads are enabled, a clean list/grid of files already in that portals folder with download buttons.
- **Portal login page**
- Minimal login screen that pulls title/accent/footer from portal metadata.
- After successful login, user is redirected back to the original portal URL.
---
## 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

View File

@@ -22,7 +22,7 @@ Drag & drop uploads, ACLaware sharing, OnlyOffice integration, and a clean UI
- 🎨 **Polished UI** Dark/light mode, responsive layout, inbrowser previews & code editor.
- 🔑 **Login + SSO** Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v1.9.7.png)
![FileRise](https://raw.githubusercontent.com/error311/FileRise/master/resources/filerise-v2.0.0.png)
> 💡 Looking for **FileRise Pro** (brandable header, Pro features, license handling)?
> Check out [filerise.net](https://filerise.net) FileRise Core stays fully opensource (MIT).

View File

@@ -26,6 +26,7 @@ RewriteRule - - [L]
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
RewriteRule "(^|/)\.(?!well-known/)" - [F]
RewriteRule ^portal/([A-Za-z0-9_-]+)$ portal.html?slug=$1 [L,QSA]
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
# - allow /api/*.php (API endpoints)

View File

@@ -0,0 +1,27 @@
<?php
// public/api/pro/portals/get.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/PortalController.php';
try {
$slug = isset($_GET['slug']) ? (string)$_GET['slug'] : '';
// For v1: we do NOT require auth here; this is just metadata,
// real ACL/access control must still be enforced at upload/download endpoints.
$portal = PortalController::getPortalBySlug($slug);
echo json_encode([
'success' => true,
'portal' => $portal,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

View File

@@ -0,0 +1,32 @@
<?php
// public/api/pro/portals/list.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
try {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
$ctrl = new AdminController();
$portals = $ctrl->getProPortals();
echo json_encode([
'success' => true,
'portals' => $portals,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

View File

@@ -0,0 +1,108 @@
<?php
// public/api/pro/portals/publicMeta.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
// --- Basic Pro checks ---
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'FileRise Pro is not active.',
]);
exit;
}
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
if ($slug === '') {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => 'Missing portal slug.',
]);
exit;
}
// --- Locate portals.json written by saveProPortals() ---
$bundleDir = defined('FR_PRO_BUNDLE_DIR') ? (string)FR_PRO_BUNDLE_DIR : '';
if ($bundleDir === '' || !is_dir($bundleDir)) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Pro bundle directory not found.',
]);
exit;
}
$jsonPath = rtrim($bundleDir, "/\\") . '/portals.json';
if (!is_file($jsonPath)) {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'No portals defined.',
]);
exit;
}
$raw = @file_get_contents($jsonPath);
if ($raw === false) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Could not read portals store.',
]);
exit;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Invalid portals store.',
]);
exit;
}
$portals = $data['portals'] ?? [];
if (!is_array($portals) || !isset($portals[$slug]) || !is_array($portals[$slug])) {
http_response_code(404);
echo json_encode([
'success' => false,
'error' => 'Portal not found.',
]);
exit;
}
$portal = $portals[$slug];
// Optional: handle expiry if youre using expiresAt as ISO date string
if (!empty($portal['expiresAt'])) {
$ts = strtotime((string)$portal['expiresAt']);
if ($ts !== false && $ts < time()) {
http_response_code(410); // Gone
echo json_encode([
'success' => false,
'error' => 'This portal has expired.',
]);
exit;
}
}
// Only expose the bits the login page needs (no folder, email, etc.)
$public = [
'slug' => $slug,
'label' => (string)($portal['label'] ?? ''),
'title' => (string)($portal['title'] ?? ''),
'introText' => (string)($portal['introText'] ?? ''),
'brandColor' => (string)($portal['brandColor'] ?? ''),
'footerText' => (string)($portal['footerText'] ?? ''),
];
echo json_encode([
'success' => true,
'portal' => $public,
]);

View File

@@ -0,0 +1,51 @@
<?php
// public/api/pro/portals/save.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
return;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
AdminController::requireAuth();
AdminController::requireAdmin();
AdminController::requireCsrf();
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
return;
}
$portals = $body['portals'] ?? null;
if (!is_array($portals)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid or missing "portals" payload']);
return;
}
$ctrl = new AdminController();
$ctrl->saveProPortals($portals);
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
try {
// --- Basic auth / admin check (keep it simple & consistent with your other admin APIs)
@session_start();
$username = (string)($_SESSION['username'] ?? '');
$isAdmin = !empty($_SESSION['isAdmin']) || (!empty($_SESSION['admin']) && $_SESSION['admin'] === '1');
if ($username === '' || !$isAdmin) {
http_response_code(403);
echo json_encode([
'success' => false,
'error' => 'Forbidden',
]);
return;
}
// Snapshot done, release lock for concurrency
@session_write_close();
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$slug = isset($_GET['slug']) ? trim((string)$_GET['slug']) : '';
if ($slug === '') {
throw new InvalidArgumentException('Missing slug.');
}
// Use your ProPortalSubmissions helper from the bundle
$proSubmissionsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
if (!is_file($proSubmissionsPath)) {
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
}
require_once $proSubmissionsPath;
$store = new ProPortalSubmissions((string)FR_PRO_BUNDLE_DIR);
$submissions = $store->listBySlug($slug, 200);
echo json_encode([
'success' => true,
'slug' => $slug,
'submissions' => $submissions,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (InvalidArgumentException $e) {
http_response_code(400);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Server error: ' . $e->getMessage(),
]);
}

View File

@@ -0,0 +1,91 @@
<?php
// public/api/pro/portals/submitForm.php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/PortalController.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
try {
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
http_response_code(405);
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
return;
}
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
// For now, portal forms still require a logged-in user
AdminController::requireAuth();
AdminController::requireCsrf();
$raw = file_get_contents('php://input');
$body = json_decode($raw, true);
if (!is_array($body)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
return;
}
$slug = isset($body['slug']) ? trim((string)$body['slug']) : '';
if ($slug === '') {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Missing portal slug']);
return;
}
$form = isset($body['form']) && is_array($body['form']) ? $body['form'] : [];
$name = trim((string)($form['name'] ?? ''));
$email = trim((string)($form['email'] ?? ''));
$reference = trim((string)($form['reference'] ?? ''));
$notes = trim((string)($form['notes'] ?? ''));
// Make sure portal exists and is not expired
$portal = PortalController::getPortalBySlug($slug);
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$subPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortalSubmissions.php';
if (!is_file($subPath)) {
throw new RuntimeException('ProPortalSubmissions.php not found in Pro bundle.');
}
require_once $subPath;
$submittedBy = (string)($_SESSION['username'] ?? '');
$payload = [
'slug' => $slug,
'portalLabel' => $portal['label'] ?? '',
'folder' => $portal['folder'] ?? '',
'form' => [
'name' => $name,
'email' => $email,
'reference' => $reference,
'notes' => $notes,
],
'submittedBy' => $submittedBy,
'ip' => $_SERVER['REMOTE_ADDR'] ?? '',
'userAgent' => $_SERVER['HTTP_USER_AGENT'] ?? '',
'createdAt' => gmdate('c'),
];
$store = new ProPortalSubmissions(FR_PRO_BUNDLE_DIR);
$ok = $store->store($slug, $payload);
if (!$ok) {
throw new RuntimeException('Failed to store portal submission.');
}
echo json_encode(['success' => true], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $e) {
$code = $e instanceof InvalidArgumentException ? 400 : 500;
http_response_code($code);
echo json_encode([
'success' => false,
'error' => $e->getMessage(),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}

View File

@@ -8,7 +8,8 @@ img.logo{width:50px; height:50px; display:block;}
#userPermissionsModal .modal-content,
#userFlagsModal .modal-content,
#userGroupsModal .modal-content,
#groupAclModal .modal-content{border-radius: var(--menu-radius);}
#groupAclModal .modal-content,
#clientPortalsModal .modal-content{border-radius: var(--menu-radius);}
#fr-login-tip{min-height: 40px;
max-width: 520px;
margin: 8px auto 0;
@@ -614,7 +615,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,9 +820,34 @@ 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;}
@@ -947,7 +974,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;
@@ -978,6 +1006,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;}
@@ -1693,8 +1722,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;
@@ -1841,7 +1868,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);
@@ -1971,653 +1997,99 @@ body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole{fill: rgba(255,25
/* ============================================
FileRise polish compact theme layer
============================================ */
/* Tokens */
: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);
/* Dark theme */
--fr-bg-dark: #0f0f0f;
--fr-surface-dark: #212121;
--fr-surface-dark-2: #181818;
--fr-border-dark: #303030;
--fr-muted-dark: #aaaaaa;
/* Light theme */
--fr-bg-light: #f9f9f9;
--fr-surface-light: #ffffff;
--fr-surface-light-2: #f1f1f1;
--fr-border-light: #e5e5e5;
--fr-muted-light: #606060;
}
/* Pro badge */
.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);
}
/* Toast base shape (colors themed below) */
#customToast {
border-radius: 999px;
}
/* Folder tree row rounding */
#folderTreeContainer .folder-row { border-radius: 8px; }
/* Buttons pill style + hover lift */
.btn,
#customChooseBtn {
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);
}
/* Upload / create / primary: shared shadow in light + dark */
.btn-primary,
#createBtn,
#uploadBtn {
box-shadow: 0 2px 4px rgba(0,0,0,.6);
}
.btn-primary:hover,
#createBtn:hover,
#uploadBtn:hover {
filter: brightness(1.04);
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(0,140,180,.28);
}
/* Destructive buttons */
#deleteSelectedBtn,
#deleteAllBtn,
#deleteTrashSelectedBtn {
border-color: rgba(248,113,113,.9);
box-shadow: 0 8px 18px rgba(248,113,113,.35);
}
/* Forms & inputs base */
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);
}
/* Modals subtle blur baseline (overridden per theme) */
.modal {
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Core elevated surfaces */
#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);
}
/* Full-height body */
body { min-height: 100vh; }
/* ============================================
Dark theme
============================================ */
body.dark-mode {
background: var(--fr-bg-dark) !important;
color: #f1f1f1 !important;
background-image: none !important;
}
/* Main surfaces */
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;
}
/* Remove inner “glass” highlight if present */
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;
}
/* Section headers */
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;
}
/* File list header / rows */
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:hover:not(.selected,.row-selected,.selected-row,.is-selected)>td {
background: rgba(255,255,255,.04) !important;
box-shadow: none !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;
}
/* Dark modals */
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;
}
/* Dark inputs */
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;
}
/* Dark destructive buttons */
body.dark-mode #deleteSelectedBtn,
body.dark-mode #deleteAllBtn,
body.dark-mode #deleteTrashSelectedBtn {
background-color: #b3261e !important;
border-color: #b3261e !important;
box-shadow: 0 4px 10px rgba(0,0,0,.7) !important;
}
/* Dark folder strip */
body.dark-mode .folder-strip-container.folder-strip-mobile {
background: var(--fr-surface-dark-2) !important;
border: 1px solid var(--fr-border-dark) !important;
}
/* Dark toast */
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;
}
/* Dark meta text */
body.dark-mode #fileSummary { color: var(--fr-muted-dark) !important; }
/* Menus & panels (dark) */
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;
}
/* ============================================
Light theme
============================================ */
body:not(.dark-mode) {
background: var(--fr-bg-light) !important;
color: #111 !important;
background-image: none !important;
}
/* Light surfaces */
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;
}
/* Remove inner highlight */
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;
}
/* Light section headers */
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;
}
/* Light file list */
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:hover:not(.selected,.row-selected,.selected-row,.is-selected)>td {
background: rgba(0,0,0,.02) !important;
box-shadow: none !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;
}
/* Light modals */
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;
}
/* Light inputs */
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;
}
/* Light destructive buttons */
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;
}
/* Light folder strip */
body:not(.dark-mode) .folder-strip-container.folder-strip-mobile {
background: #f1f1f1 !important;
border: 1px solid var(--fr-border-light) !important;
}
/* Light toast */
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;
}
/* Light meta text */
body:not(.dark-mode) #fileSummary { color: var(--fr-muted-light) !important; }
/* Menus & panels (light) */
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;
}
/* ============================================
Search group / advanced search / pagination
============================================ */
/* Search icon + input */
#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;
}
/* Dark search */
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; }
/* Advanced search toggle */
#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);
}
/* Prev / Next pagination */
.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);
}
/* Normalize normal inputs (everything except the search pill) */
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;
}
/* Compact font-size controls (A- / A+) */
#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, 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;
}
/* Hover / active */
#decreaseFont:not(:disabled):hover,
#increaseFont:not(:disabled):hover {
background: #e8e8e8;
border-color: rgba(0, 0, 0, 0.24);
box-shadow: 0 1px 4px rgba(0,0,0,0.18);
transform: translateY(-1px);
}
#decreaseFont:not(:disabled):active,
#increaseFont:not(:disabled):active {
transform: translateY(5);
box-shadow: 0 1px 2px rgba(0,0,0,0.25);
}
/* Disabled */
#decreaseFont:disabled,
#increaseFont:disabled {
opacity: 0.5;
cursor: default;
box-shadow: none;
}
/* Dark mode tweaks */
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,0.7);
}
#closeEditorX {
margin-right: 10px;
}
: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,#clientPortalsModal .modal-content{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,body:not(.dark-mode) #clientPortalsModal .modal-content{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}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,302 @@
// Admin panel inline CSS moved out of adminPanel.js
// This file is imported for its side effects only.
(function () {
if (document.getElementById('adminPanelStyles')) return;
const style = document.createElement('style');
style.id = 'adminPanelStyles';
style.textContent = `
/* Modal sizing */
#adminPanelModal .modal-content {
max-width: 1100px;
width: 50%;
background: #fff !important;
color: #000 !important;
border: 1px solid #ccc !important;
}
@media (max-width: 900px) {
#adminPanelModal .modal-content {
width: 100%;
max-width: 100%;
}
}
@media (max-width: 768px) {
#adminPanelModal .modal-content {
width: 100%;
max-width: 100%;
border-radius: 0;
height: 100%;
}
}
/* Modal header */
#adminPanelModal .modal-header {
border-bottom: 1px solid rgba(0,0,0,0.15);
padding: 0.75rem 1rem;
align-items: center;
}
#adminPanelModal .modal-title {
font-size: 1rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
#adminPanelModal .modal-title .admin-title-badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.12);
background: rgba(0,0,0,0.03);
}
/* Modal body layout */
#adminPanelModal .modal-body {
display: flex;
gap: 1rem;
padding: 0.75rem 1rem 1rem;
align-items: flex-start;
}
@media (max-width: 768px) {
#adminPanelModal .modal-body {
flex-direction: column;
}
}
/* Sidebar nav */
#adminPanelSidebar {
width: 220px;
max-width: 220px;
padding-right: 0.75rem;
border-right: 1px solid rgba(0,0,0,0.08);
}
@media (max-width: 768px) {
#adminPanelSidebar {
width: 100%;
max-width: 100%;
border-right: none;
border-bottom: 1px solid rgba(0,0,0,0.08);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}
}
#adminPanelSidebar .nav {
flex-direction: column;
gap: 0.25rem;
}
#adminPanelSidebar .nav-link {
border-radius: 0.5rem;
padding: 0.35rem 0.6rem;
font-size: 0.85rem;
display: flex;
align-items: center;
gap: 0.4rem;
border: 1px solid transparent;
color: #333;
}
#adminPanelSidebar .nav-link .material-icons {
font-size: 1rem;
}
#adminPanelSidebar .nav-link.active {
background: rgba(0, 123, 255, 0.08);
border-color: rgba(0, 123, 255, 0.3);
color: #0056b3;
}
#adminPanelSidebar .nav-link:hover {
background: rgba(0,0,0,0.03);
}
/* Content area */
#adminPanelContent {
flex: 1;
min-width: 0;
}
.admin-section-title {
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 0.35rem;
display: flex;
align-items: center;
gap: 0.35rem;
}
.admin-section-title .material-icons {
font-size: 1rem;
}
.admin-section-subtitle {
font-size: 0.8rem;
color: rgba(0,0,0,0.6);
margin-bottom: 0.75rem;
}
.admin-field-group {
margin-bottom: 0.9rem;
}
.admin-field-group label {
font-size: 0.8rem;
font-weight: 500;
margin-bottom: 0.2rem;
}
.admin-field-group small {
font-size: 0.75rem;
color: rgba(0,0,0,0.6);
}
.admin-inline-actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
align-items: center;
margin-top: 0.25rem;
}
.admin-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 999px;
padding: 0.1rem 0.5rem;
font-size: 0.7rem;
background: rgba(0,0,0,0.03);
border: 1px solid rgba(0,0,0,0.08);
}
.admin-badge .material-icons {
font-size: 0.9rem;
}
/* Tables */
.admin-table-sm {
font-size: 0.8rem;
margin-bottom: 0.75rem;
}
.admin-table-sm th,
.admin-table-sm td {
padding: 0.35rem 0.4rem !important;
vertical-align: middle;
}
/* Switch alignment */
.form-check.form-switch .form-check-input {
cursor: pointer;
}
/* Pro license textarea */
#proLicenseInput {
font-family: var(--filr-font-mono, monospace);
font-size: 0.75rem;
min-height: 80px;
resize: vertical;
}
/* Pro info alert */
#proLicenseStatus {
font-size: 0.8rem;
padding: 0.4rem 0.6rem;
margin-bottom: 0.4rem;
}
/* Client portals */
#clientPortalsBody .portal-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.35rem 0;
border-bottom: 1px solid rgba(0,0,0,0.04);
}
#clientPortalsBody .portal-row:last-child {
border-bottom: none;
}
#clientPortalsBody .portal-meta {
font-size: 0.75rem;
color: rgba(0,0,0,0.7);
}
#clientPortalsBody .portal-actions {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
justify-content: flex-end;
}
/* Submissions list */
#clientPortalsBody .portal-submissions {
margin-top: 0.25rem;
padding-top: 0.25rem;
border-top: 1px dashed rgba(0,0,0,0.08);
}
#clientPortalsBody .portal-submissions-title {
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.1rem;
opacity: 0.8;
}
#clientPortalsBody .portal-submissions-empty {
font-size: 0.75rem;
font-style: italic;
opacity: 0.6;
}
#clientPortalsBody .portal-submissions-item {
font-size: 0.75rem;
padding: 0.15rem 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
#clientPortalsBody .portal-submissions-item:last-child {
border-bottom: none;
}
#clientPortalsBody .portal-submissions-meta {
opacity: 0.75;
font-size: 0.75rem;
}
/* Dark mode overrides */
.dark-mode #adminPanelModal .modal-content {
background: #121212 !important;
color: #f5f5f5 !important;
border-color: rgba(255,255,255,0.15) !important;
}
.dark-mode #adminPanelModal .modal-header {
border-bottom-color: rgba(255,255,255,0.15);
}
.dark-mode #adminPanelSidebar {
border-right-color: rgba(255,255,255,0.12);
}
.dark-mode #adminPanelSidebar .nav-link {
color: #f5f5f5;
}
.dark-mode #adminPanelSidebar .nav-link:hover {
background: rgba(255,255,255,0.04);
}
.dark-mode #adminPanelSidebar .nav-link.active {
background: rgba(13,110,253,0.3);
border-color: rgba(13,110,253,0.7);
color: #fff;
}
.dark-mode .admin-section-subtitle {
color: rgba(255,255,255,0.6);
}
.dark-mode .admin-field-group small {
color: rgba(255,255,255,0.6);
}
.dark-mode .admin-badge {
background: rgba(255,255,255,0.04);
border-color: rgba(255,255,255,0.12);
}
.dark-mode .admin-table-sm tbody tr:hover td {
background: rgba(255,255,255,0.02);
}
.dark-mode #clientPortalsBody .portal-row {
border-bottom-color: rgba(255,255,255,0.08);
}
.dark-mode #clientPortalsBody .portal-meta {
color: rgba(255,255,255,0.7);
}
.dark-mode #clientPortalsBody .portal-submissions {
border-top-color: rgba(255,255,255,0.12);
}
.dark-mode #clientPortalsBody .portal-submissions-empty {
color: rgba(255,255,255,0.5);
}
`;
document.head.appendChild(style);
})();

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

@@ -230,23 +230,47 @@ function showNoAccessEmptyState() {
function renderBreadcrumbFragment(folderPath) {
const frag = document.createDocumentFragment();
const path = (typeof folderPath === 'string' && folderPath.length) ? folderPath : 'root';
// --- Always start with "Root" crumb ---
const rootSpan = document.createElement('span');
rootSpan.className = 'breadcrumb-link';
rootSpan.dataset.folder = 'root';
rootSpan.textContent = 'root';
frag.appendChild(rootSpan);
if (path === 'root') {
// You are in root: just "Root"
return frag;
}
// Separator after Root
let sep = document.createElement('span');
sep.className = 'file-breadcrumb-sep';
sep.textContent = '';
frag.appendChild(sep);
// Now add the rest of the path normally (folder1, folder1/subA, etc.)
const crumbs = path.split('/').filter(Boolean);
let acc = '';
for (let i = 0; i < crumbs.length; i++) {
const part = crumbs[i];
acc = (i === 0) ? part : (acc + '/' + part);
const span = document.createElement('span');
span.className = 'breadcrumb-link';
span.dataset.folder = acc;
span.textContent = part;
frag.appendChild(span);
if (i < crumbs.length - 1) {
const sep = document.createElement('span');
sep = document.createElement('span');
sep.className = 'file-breadcrumb-sep';
sep.textContent = '';
frag.appendChild(sep);
}
}
return frag;
}
export function updateBreadcrumbTitle(folder) {
@@ -928,7 +952,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

@@ -225,6 +225,32 @@ window.__FR_FLAGS.entryStarted = window.__FR_FLAGS.entryStarted || false;
return p.then(r => r.clone());
};
// ---- Safe redirect helper (prevents open redirects) ----
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
if (!raw) return fallback;
try {
const str = String(raw).trim();
if (!str) return fallback;
const candidate = new URL(str, window.location.origin);
// Enforce same-origin
if (candidate.origin !== window.location.origin) {
return fallback;
}
// Limit to http/https
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
return fallback;
}
// Return relative URL
return candidate.pathname + candidate.search + candidate.hash;
} catch {
return fallback;
}
}
// Gentle toast normalizer (compatible with showToast(message, duration))
const origToast = window.showToast;
if (typeof origToast === 'function' && !origToast.__frWrapped) {
@@ -883,6 +909,19 @@ function bindDarkMode() {
});
}
function afterLogin() {
// If index.html was opened with ?redirect=<url>, honor that first
try {
const url = new URL(window.location.href);
const raw = url.searchParams.get('redirect');
const safe = sanitizeRedirect(raw, { fallback: null });
if (safe) {
window.location.href = safe;
return;
}
} catch {
// ignore URL/param issues and fall back to normal behavior
}
const start = Date.now();
(function poll() {
checkAuth().then(({ authed }) => {

382
public/js/portal-login.js Normal file
View File

@@ -0,0 +1,382 @@
// public/js/portal-login.js
// -------- URL helpers --------
function sanitizeRedirect(raw, { fallback = '/' } = {}) {
if (!raw) return fallback;
try {
const str = String(raw).trim();
if (!str) return fallback;
// Resolve against current origin so relative URLs work
const candidate = new URL(str, window.location.origin);
// 1) Must stay on the same origin
if (candidate.origin !== window.location.origin) {
return fallback;
}
// 2) Only allow http/https
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') {
return fallback;
}
// Return a relative URL (prevents host changes)
return candidate.pathname + candidate.search + candidate.hash;
} catch {
return fallback;
}
}
function getRedirectTarget() {
try {
const url = new URL(window.location.href);
const raw = url.searchParams.get('redirect');
// Default fallback: root
let target = sanitizeRedirect(raw, { fallback: '/' });
// If there was no *usable* redirect but we have a portal slug,
// send them back to that portal by default.
if (!target || target === '/') {
const slug = getPortalSlugFromUrl();
if (slug) {
target = sanitizeRedirect('/portal/' + encodeURIComponent(slug), { fallback: '/' });
}
}
return target || '/';
} catch {
return '/';
}
}
function getPortalSlugFromUrl() {
try {
const url = new URL(window.location.href);
// 1) Direct ?slug=portal-xxxxx on login page (if ever used)
let slug = url.searchParams.get('slug');
if (slug && slug.trim()) {
console.log('portal-login: slug from top-level param =', slug.trim());
return slug.trim();
}
// 2) From redirect param: may be portal.html?slug=... or /portal/<slug>
const redirect = url.searchParams.get('redirect');
if (redirect) {
console.log('portal-login: raw redirect param =', redirect);
try {
const redirectUrl = new URL(redirect, window.location.origin);
// 2a) ?slug=... in redirect
const innerSlug = redirectUrl.searchParams.get('slug');
if (innerSlug && innerSlug.trim()) {
console.log('portal-login: slug from redirect URL =', innerSlug.trim());
return innerSlug.trim();
}
// 2b) Pretty path /portal/<slug> in redirect
const pathMatch = redirectUrl.pathname.match(/\/portal\/([^\/?#]+)/i);
if (pathMatch && pathMatch[1]) {
const fromPath = pathMatch[1].trim();
console.log('portal-login: slug from redirect path =', fromPath);
return fromPath;
}
} catch (err) {
console.warn('portal-login: failed to parse redirect URL', err);
}
// 2c) Fallback regex on redirect string
const m = redirect.match(/[?&]slug=([^&]+)/);
if (m && m[1]) {
const decoded = decodeURIComponent(m[1]).trim();
console.log('portal-login: slug from redirect regex =', decoded);
return decoded;
}
}
// 3) Legacy fallback on current query string
const qs = window.location.search || '';
const m2 = qs.match(/[?&]slug=([^&]+)/);
if (m2 && m2[1]) {
const decoded2 = decodeURIComponent(m2[1]).trim();
console.log('portal-login: slug from own query regex =', decoded2);
return decoded2;
}
console.log('portal-login: no slug found');
return '';
} catch (err) {
console.warn('portal-login: getPortalSlugFromUrl error', err);
const qs = window.location.search || '';
const m = qs.match(/[?&]slug=([^&]+)/);
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
}
}
// --- CSRF helpers (same pattern as portal.js) ---
function setCsrfToken(token) {
if (!token) return;
window.csrfToken = token;
try {
localStorage.setItem('csrf', token);
} catch { /* ignore */ }
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
}
meta.content = token;
}
function getCsrfToken() {
return (
window.csrfToken ||
(document.querySelector('meta[name="csrf-token"]')?.content) ||
''
);
}
async function loadCsrfToken() {
try {
const res = await fetch('/api/auth/token.php', {
method: 'GET',
credentials: 'include'
});
const hdr = res.headers.get('X-CSRF-Token');
if (hdr) setCsrfToken(hdr);
let body = {};
try {
body = await res.json();
} catch {
body = {};
}
const token = body.csrf_token || getCsrfToken();
setCsrfToken(token);
} catch (e) {
console.warn('portal-login: failed to load CSRF token', e);
}
}
// --- UI helpers ---
function showError(msg) {
const box = document.getElementById('portalLoginError');
if (!box) return;
box.textContent = msg || 'Login failed.';
box.classList.add('show');
}
function clearError() {
const box = document.getElementById('portalLoginError');
if (!box) return;
box.textContent = '';
box.classList.remove('show');
}
// -------- Portal meta (title + accent) --------
async function fetchPortalMeta(slug) {
if (!slug) return null;
console.log('portal-login: calling publicMeta.php for slug', slug);
try {
const res = await fetch(
'/api/pro/portals/publicMeta.php?slug=' + encodeURIComponent(slug),
{ method: 'GET', credentials: 'include' }
);
const text = await res.text();
let data = {};
try {
data = text ? JSON.parse(text) : {};
} catch {
data = {};
}
if (!res.ok || !data || !data.success || !data.portal) {
console.warn('portal-login: publicMeta not ok', res.status, data);
return null;
}
return data.portal;
} catch (e) {
console.warn('portal-login: failed to load portal meta', e);
return null;
}
}
function applyPortalBranding(portal) {
if (!portal) return;
const title =
(portal.title && portal.title.trim()) ||
portal.label ||
portal.slug ||
'Client portal';
const headingEl = document.getElementById('portalLoginTitle');
const subtitleEl = document.getElementById('portalLoginSubtitle');
const footerEl = document.getElementById('portalLoginFooter');
if (headingEl) {
headingEl.textContent = 'Sign in to ' + title;
}
if (subtitleEl) {
subtitleEl.textContent = 'to access this client portal';
}
// Footer text from portal metadata, if provided
if (footerEl) {
const ft = (portal.footerText && portal.footerText.trim()) || '';
if (ft) {
footerEl.textContent = ft;
footerEl.style.display = 'block';
} else {
footerEl.textContent = '';
footerEl.style.display = 'none';
}
}
// Document title
try {
document.title = 'Sign in ' + title;
} catch { /* ignore */ }
// Accent: portal brandColor -> CSS var
const brand = portal.brandColor && portal.brandColor.trim();
if (brand) {
document.documentElement.style.setProperty('--portal-accent', brand);
}
// Reapply card/button accent after we know portal color
applyAccentFromTheme();
}
// --- Accent (card + button) ---
function applyAccentFromTheme() {
const card = document.querySelector('.portal-login-card');
const btn = document.getElementById('portalLoginSubmit');
const rootStyles = getComputedStyle(document.documentElement);
// Prefer per-portal accent if present
let accent = rootStyles.getPropertyValue('--portal-accent').trim();
if (!accent) {
accent = rootStyles.getPropertyValue('--filr-accent-500').trim() || '#0b5ed7';
}
if (card) {
card.style.borderTop = `3px solid ${accent}`;
}
if (btn) {
btn.style.backgroundColor = accent;
btn.style.borderColor = accent;
}
const metaTheme = document.querySelector('meta[name="theme-color"]');
if (metaTheme) {
metaTheme.setAttribute('content', accent);
}
}
// --- Login call (JSON -> auth.php) ---
async function doLogin(username, password) {
const csrf = getCsrfToken() || '';
const payload = {
username,
password
};
if (csrf) {
payload.csrf_token = csrf;
}
const res = await fetch('/api/auth/auth.php', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrf,
'Content-Type': 'application/json'
},
body: JSON.stringify(payload)
});
const text = await res.text();
let body = {};
try {
body = text ? JSON.parse(text) : {};
} catch {
body = {};
}
if (!res.ok) {
const msg = body.error || body.message || text || 'Login failed.';
const err = new Error(msg);
err.status = res.status;
throw err;
}
if (body.success === false || body.error || body.logged_in === false) {
throw new Error(body.error || 'Invalid username or password.');
}
return body;
}
// --- Init ---
document.addEventListener('DOMContentLoaded', async () => {
const form = document.getElementById('portalLoginForm');
const userEl = document.getElementById('portalLoginUser');
const passEl = document.getElementById('portalLoginPass');
const btn = document.getElementById('portalLoginSubmit');
// Accent first (fallback to global accent)
applyAccentFromTheme();
// Try to load portal meta (title + brand color) using slug
const slug = getPortalSlugFromUrl();
console.log('portal-login: computed slug =', slug);
if (slug) {
fetchPortalMeta(slug).then(portal => {
if (portal) {
console.log('portal-login: got portal meta for', slug, portal);
applyPortalBranding(portal);
}
});
}
// Pre-load CSRF (for auth.php)
loadCsrfToken().catch(() => {});
if (!form || !userEl || !passEl || !btn) return;
// Focus username
userEl.focus();
form.addEventListener('submit', async (e) => {
e.preventDefault();
clearError();
const username = userEl.value.trim();
const password = passEl.value;
if (!username || !password) {
showError('Username and password are required');
return;
}
btn.disabled = true;
btn.textContent = 'Signing in…';
try {
await doLogin(username, password);
const target = getRedirectTarget();
window.location.href = target;
} catch (err) {
console.error('portal-login: auth failed', err);
showError(err.message || 'Login failed. Please try again.');
btn.disabled = false;
btn.textContent = 'Sign in';
}
});
});

716
public/js/portal.js Normal file
View File

@@ -0,0 +1,716 @@
// public/js/portal.js
// Standalone client portal logic no imports from main app JS to avoid DOM coupling.
let portal = null;
let portalFormDone = false;
// --- Portal helpers: folder + download flag -----------------
function portalFolder() {
if (!portal) return 'root';
return portal.folder || portal.targetFolder || portal.path || 'root';
}
function portalCanDownload() {
if (!portal) return false;
// Prefer explicit flags if present
if (typeof portal.allowDownload !== 'undefined') {
return !!portal.allowDownload;
}
if (typeof portal.allowDownloads !== 'undefined') {
return !!portal.allowDownloads;
}
// Fallback: uploadOnly = true => no downloads
if (typeof portal.uploadOnly !== 'undefined') {
return !portal.uploadOnly;
}
// Default: allow downloads
return true;
}
// ----------------- DOM helpers / status -----------------
function qs(id) {
return document.getElementById(id);
}
function setStatus(msg, isError = false) {
const el = qs('portalStatus');
if (!el) return;
el.textContent = msg || '';
el.classList.toggle('text-danger', !!isError);
if (!isError) {
el.classList.add('text-muted');
}
}
// ----------------- Form submit -----------------
async function submitPortalForm(slug, formData) {
const payload = {
slug,
form: formData
};
const headers = { 'X-CSRF-Token': getCsrfToken() || '' };
const res = await sendRequest('/api/pro/portals/submitForm.php', 'POST', payload, headers);
if (!res || !res.success) {
throw new Error((res && res.error) || 'Error saving form.');
}
}
// ----------------- Toast -----------------
function showToast(message) {
const toast = document.getElementById('customToast');
if (!toast) {
console.warn('Toast:', message);
return;
}
toast.textContent = message;
toast.style.display = 'block';
// Force reflow
void toast.offsetWidth;
toast.classList.add('show');
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => {
toast.style.display = 'none';
}, 200);
}, 2500);
}
// ----------------- Fetch wrapper -----------------
async function sendRequest(url, method = 'GET', data = null, customHeaders = {}) {
const options = {
method,
credentials: 'include',
headers: { ...customHeaders }
};
if (data && !(data instanceof FormData)) {
options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json';
options.body = JSON.stringify(data);
} else if (data instanceof FormData) {
options.body = data;
}
const res = await fetch(url, options);
const text = await res.text();
let payload;
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
if (!res.ok) {
throw payload;
}
return payload;
}
// ----------------- Portal form wiring -----------------
function setupPortalForm(slug) {
const formSection = qs('portalFormSection');
const uploadSection = qs('portalUploadSection');
if (!portal || !portal.requireForm) {
if (formSection) formSection.style.display = 'none';
if (uploadSection) uploadSection.style.opacity = '1';
return;
}
const key = 'portalFormDone:' + slug;
if (sessionStorage.getItem(key) === '1') {
portalFormDone = true;
if (formSection) formSection.style.display = 'none';
if (uploadSection) uploadSection.style.opacity = '1';
return;
}
portalFormDone = false;
if (formSection) formSection.style.display = 'block';
if (uploadSection) uploadSection.style.opacity = '0.5';
const nameEl = qs('portalFormName');
const emailEl = qs('portalFormEmail');
const refEl = qs('portalFormReference');
const notesEl = qs('portalFormNotes');
const submitBtn = qs('portalFormSubmit');
const fd = portal.formDefaults || {};
if (nameEl && fd.name && !nameEl.value) {
nameEl.value = fd.name;
}
if (emailEl && fd.email && !emailEl.value) {
emailEl.value = fd.email;
} else if (emailEl && portal.clientEmail && !emailEl.value) {
// fallback to clientEmail
emailEl.value = portal.clientEmail;
}
if (refEl && fd.reference && !refEl.value) {
refEl.value = fd.reference;
}
if (notesEl && fd.notes && !notesEl.value) {
notesEl.value = fd.notes;
}
if (!submitBtn) return;
submitBtn.onclick = async () => {
const name = nameEl ? nameEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const reference = refEl ? refEl.value.trim() : '';
const notes = notesEl ? notesEl.value.trim() : '';
const req = portal.formRequired || {};
const missing = [];
if (req.name && !name) missing.push('name');
if (req.email && !email) missing.push('email');
if (req.reference && !reference) missing.push('reference');
if (req.notes && !notes) missing.push('notes');
if (missing.length) {
showToast('Please fill in: ' + missing.join(', ') + '.');
return;
}
// default behavior when no specific required flags:
if (!req.name && !req.email && !req.reference && !req.notes) {
if (!name && !email) {
showToast('Please provide at least a name or email.');
return;
}
}
try {
await submitPortalForm(slug, { name, email, reference, notes });
portalFormDone = true;
sessionStorage.setItem(key, '1');
if (formSection) formSection.style.display = 'none';
if (uploadSection) uploadSection.style.opacity = '1';
showToast('Thank you. You can now upload files.');
} catch (e) {
console.error(e);
showToast('Error saving your info. Please try again.');
}
};
}
// ----------------- CSRF helpers -----------------
function setCsrfToken(token) {
if (!token) return;
window.csrfToken = token;
try {
localStorage.setItem('csrf', token);
} catch {
// ignore
}
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) {
meta = document.createElement('meta');
meta.name = 'csrf-token';
document.head.appendChild(meta);
}
meta.content = token;
}
function getCsrfToken() {
return window.csrfToken || (document.querySelector('meta[name="csrf-token"]')?.content) || '';
}
async function loadCsrfToken() {
const res = await fetch('/api/auth/token.php', { method: 'GET', credentials: 'include' });
const hdr = res.headers.get('X-CSRF-Token');
if (hdr) setCsrfToken(hdr);
let body = {};
try {
body = await res.json();
} catch {
body = {};
}
const token = body.csrf_token || getCsrfToken();
setCsrfToken(token);
}
// ----------------- Auth -----------------
async function ensureAuthenticated() {
try {
const data = await sendRequest('/api/auth/checkAuth.php', 'GET');
if (!data || !data.username) {
// redirect to main UI/login; after login, user can re-open portal link
const target = encodeURIComponent(window.location.href);
window.location.href = '/portal-login.html?redirect=' + target;
return null;
}
const lbl = qs('portalUserLabel');
if (lbl) {
lbl.textContent = data.username || '';
}
return data;
} catch (e) {
const target = encodeURIComponent(window.location.href);
window.location.href = '/portal-login.html?redirect=' + target;
return null;
}
}
// ----------------- Portal fetch + render -----------------
async function fetchPortal(slug) {
setStatus('Loading portal details…');
try {
const data = await sendRequest('/api/pro/portals/get.php?slug=' + encodeURIComponent(slug), 'GET');
if (!data || !data.success || !data.portal) {
throw new Error((data && data.error) || 'Portal not found.');
}
portal = data.portal;
return portal;
} catch (e) {
console.error(e);
setStatus('This portal could not be found or is no longer available.', true);
showToast('Portal not found or expired.');
return null;
}
}
function renderPortalInfo() {
if (!portal) return;
const titleEl = qs('portalTitle');
const descEl = qs('portalDescription');
const subtitleEl = qs('portalSubtitle');
const brandEl = document.getElementById('portalBrandHeading');
const footerEl = document.getElementById('portalFooter');
const drop = qs('portalDropzone');
const card = document.querySelector('.portal-card');
const formBtn = qs('portalFormSubmit');
const refreshBtn = qs('portalRefreshBtn');
const filesSection = qs('portalFilesSection');
const heading = portal.title && portal.title.trim()
? portal.title.trim()
: (portal.label || portal.slug || 'Client portal');
if (titleEl) titleEl.textContent = heading;
if (brandEl) brandEl.textContent = heading;
if (descEl) {
if (portal.introText && portal.introText.trim()) {
descEl.textContent = portal.introText.trim();
} else {
const folder = portalFolder();
descEl.textContent = 'Files you upload here go directly into: ' + folder;
}
}
if (subtitleEl) {
const parts = [];
if (portal.uploadOnly) parts.push('upload only');
if (portalCanDownload()) parts.push('download allowed');
subtitleEl.textContent = parts.length ? parts.join(' • ') : '';
}
if (footerEl) {
footerEl.textContent = portal.footerText && portal.footerText.trim()
? portal.footerText.trim()
: '';
}
const color = portal.brandColor && portal.brandColor.trim();
if (color) {
// expose brand color as a CSS variable for gallery styling
document.documentElement.style.setProperty('--portal-accent', color);
if (drop) {
drop.style.borderColor = color;
}
if (card) {
card.style.borderTop = '3px solid ' + color;
}
if (formBtn) {
formBtn.style.backgroundColor = color;
formBtn.style.borderColor = color;
}
if (refreshBtn) {
refreshBtn.style.borderColor = color;
refreshBtn.style.color = color;
}
}
// Show/hide files section based on download capability
if (filesSection) {
filesSection.style.display = portalCanDownload() ? 'block' : 'none';
}
}
// ----------------- File helpers for gallery -----------------
function formatFileSizeLabel(f) {
// API currently returns f.size as a human-readable string, so prefer that
if (f && f.size) return f.size;
return '';
}
function fileExtLabel(name) {
if (!name) return 'FILE';
const parts = name.split('.');
if (parts.length < 2) return 'FILE';
const ext = parts.pop().trim().toUpperCase();
if (!ext) return 'FILE';
return ext.length <= 4 ? ext : ext.slice(0, 4);
}
function isImageName(name) {
if (!name) return false;
return /\.(jpe?g|png|gif|bmp|webp|svg)$/i.test(name);
}
// ----------------- Load files for portal gallery -----------------
async function loadPortalFiles() {
if (!portal || !portalCanDownload()) return;
const listEl = qs('portalFilesList');
if (!listEl) return;
listEl.innerHTML = '<div class="text-muted" style="padding:4px 0;">Loading files…</div>';
try {
const folder = portalFolder();
const data = await sendRequest('/api/file/getFileList.php?folder=' + encodeURIComponent(folder), 'GET');
if (!data || data.error) {
const msg = (data && data.error) ? data.error : 'Error loading files.';
listEl.innerHTML = '<div class="text-danger" style="padding:4px 0;">' + msg + '</div>';
return;
}
// Normalize files: handle both array and object-return shapes
let files = [];
if (Array.isArray(data.files)) {
files = data.files;
} else if (data.files && typeof data.files === 'object') {
files = Object.entries(data.files).map(([name, meta]) => {
const f = meta || {};
f.name = name;
return f;
});
}
if (!files.length) {
listEl.innerHTML = '<div class="text-muted" style="padding:4px 0;">No files in this portal yet.</div>';
return;
}
const accent = portal.brandColor && portal.brandColor.trim();
listEl.innerHTML = '';
listEl.classList.add('portal-files-grid'); // gallery layout
const MAX = 24;
const slice = files.slice(0, MAX);
slice.forEach(f => {
const card = document.createElement('div');
card.className = 'portal-file-card';
const icon = document.createElement('div');
icon.className = 'portal-file-card-icon';
const main = document.createElement('div');
main.className = 'portal-file-card-main';
const nameEl = document.createElement('div');
nameEl.className = 'portal-file-card-name';
nameEl.textContent = f.name || 'Unnamed file';
const metaEl = document.createElement('div');
metaEl.className = 'portal-file-card-meta text-muted';
metaEl.textContent = formatFileSizeLabel(f);
main.appendChild(nameEl);
main.appendChild(metaEl);
const actions = document.createElement('div');
actions.className = 'portal-file-card-actions';
// Thumbnail vs extension badge
const fname = f.name || '';
const folder = portalFolder();
if (isImageName(fname)) {
const thumbUrl =
'/api/file/download.php?folder=' +
encodeURIComponent(folder) +
'&file=' + encodeURIComponent(fname) +
'&inline=1&t=' + Date.now();
const img = document.createElement('img');
img.src = thumbUrl;
img.alt = fname;
// 🔧 constrain image so it doesn't fill the whole list
img.style.maxWidth = '100%';
img.style.maxHeight = '120px';
img.style.objectFit = 'cover';
img.style.display = 'block';
img.style.borderRadius = '6px';
icon.appendChild(img);
} else {
icon.textContent = fileExtLabel(fname);
}
if (accent) {
icon.style.borderColor = accent;
}
if (portalCanDownload()) {
const a = document.createElement('a');
a.href = '/api/file/download.php?folder=' +
encodeURIComponent(folder) +
'&file=' + encodeURIComponent(fname);
a.textContent = 'Download';
a.className = 'portal-file-card-download';
a.target = '_blank';
a.rel = 'noopener';
actions.appendChild(a);
}
card.appendChild(icon);
card.appendChild(main);
card.appendChild(actions);
listEl.appendChild(card);
});
if (files.length > MAX) {
const more = document.createElement('div');
more.className = 'portal-files-more text-muted';
more.textContent = 'And ' + (files.length - MAX) + ' more…';
listEl.appendChild(more);
}
} catch (e) {
console.error(e);
listEl.innerHTML = '<div class="text-danger" style="padding:4px 0;">Error loading files.</div>';
}
}
// ----------------- Upload -----------------
async function uploadFiles(fileList) {
if (!portal || !fileList || !fileList.length) return;
if (portal.requireForm && !portalFormDone) {
showToast('Please fill in your details before uploading.');
return;
}
const files = Array.from(fileList);
const folder = portalFolder();
setStatus('Uploading ' + files.length + ' file(s)…');
let successCount = 0;
let failureCount = 0;
for (const file of files) {
const form = new FormData();
const csrf = getCsrfToken() || '';
// Match main upload.js
form.append('file[]', file);
form.append('folder', folder);
if (csrf) {
form.append('upload_token', csrf); // legacy alias, but your controller supports it
}
let retried = false;
while (true) {
try {
const resp = await fetch('/api/upload/upload.php', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRF-Token': csrf || ''
},
body: form
});
const text = await resp.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = {};
}
if (data && data.csrf_expired && data.csrf_token) {
setCsrfToken(data.csrf_token);
if (!retried) {
retried = true;
continue;
}
}
if (!resp.ok || (data && data.error)) {
failureCount++;
console.error('Upload error:', data || text);
} else {
successCount++;
}
break;
} catch (e) {
console.error('Upload error:', e);
failureCount++;
break;
}
}
}
if (successCount && !failureCount) {
setStatus('Uploaded ' + successCount + ' file(s).');
showToast('Upload complete.');
} else if (successCount && failureCount) {
setStatus('Uploaded ' + successCount + ' file(s), ' + failureCount + ' failed.', true);
showToast('Some files failed to upload.');
} else {
setStatus('Upload failed.', true);
showToast('Upload failed.');
}
if (portalCanDownload()) {
loadPortalFiles();
}
}
// ----------------- Upload UI wiring -----------------
function wireUploadUI() {
const drop = qs('portalDropzone');
const input = qs('portalFileInput');
const refreshBtn = qs('portalRefreshBtn');
if (drop && input) {
drop.addEventListener('click', () => input.click());
input.addEventListener('change', (e) => {
const files = e.target.files;
if (files && files.length) {
uploadFiles(files);
input.value = '';
}
});
['dragenter', 'dragover'].forEach(ev => {
drop.addEventListener(ev, e => {
e.preventDefault();
e.stopPropagation();
drop.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(ev => {
drop.addEventListener(ev, e => {
e.preventDefault();
e.stopPropagation();
drop.classList.remove('dragover');
});
});
drop.addEventListener('drop', e => {
const dt = e.dataTransfer;
if (!dt || !dt.files || !dt.files.length) return;
uploadFiles(dt.files);
});
}
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
loadPortalFiles();
});
}
}
// ----------------- Slug + init -----------------
function getPortalSlugFromUrl() {
try {
const url = new URL(window.location.href);
// 1) Normal case: slug is directly in query (?slug=portal-xxxxx)
let slug = url.searchParams.get('slug');
if (slug && slug.trim()) {
return slug.trim();
}
// 2) Pretty URL: /portal/<slug>
// e.g. /portal/portal-h46ozd
const pathMatch = url.pathname.match(/\/portal\/([^\/?#]+)/i);
if (pathMatch && pathMatch[1]) {
return pathMatch[1].trim();
}
// 3) Fallback: slug inside redirect param
// e.g. ?redirect=/portal.html?slug=portal-h46ozd
const redirect = url.searchParams.get('redirect');
if (redirect) {
try {
const redirectUrl = new URL(redirect, window.location.origin);
const innerSlug = redirectUrl.searchParams.get('slug');
if (innerSlug && innerSlug.trim()) {
return innerSlug.trim();
}
} catch {
// ignore parse errors
}
const m = redirect.match(/[?&]slug=([^&]+)/);
if (m && m[1]) {
return decodeURIComponent(m[1]).trim();
}
}
// 4) Final fallback: old regex on our own query string
const qs = window.location.search || '';
const m2 = qs.match(/[?&]slug=([^&]+)/);
return m2 && m2[1] ? decodeURIComponent(m2[1]).trim() : '';
} catch {
const qs = window.location.search || '';
const m = qs.match(/[?&]slug=([^&]+)/);
return m && m[1] ? decodeURIComponent(m[1]).trim() : '';
}
}
async function initPortal() {
const slug = getPortalSlugFromUrl();
if (!slug) {
setStatus('Missing portal slug.', true);
showToast('Portal slug missing in URL.');
return;
}
try {
await loadCsrfToken();
} catch (e) {
console.warn('CSRF load failed (may be fine if unauthenticated yet).', e);
}
const auth = await ensureAuthenticated();
if (!auth) return;
const p = await fetchPortal(slug);
if (!p) return;
renderPortalInfo();
setupPortalForm(slug);
wireUploadUI();
if (portalCanDownload()) {
loadPortalFiles();
}
setStatus('Ready.');
}
document.addEventListener('DOMContentLoaded', () => {
initPortal().catch(err => {
console.error(err);
setStatus('Unexpected error initializing portal.', true);
showToast('Unexpected error loading portal.');
});
});

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v1.9.13';
window.APP_VERSION = 'v2.0.1';

146
public/portal-login.html Normal file
View File

@@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Sign in Client Portal</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="">
<meta name="color-scheme" content="light dark">
<!-- Favicons / assets -->
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
<!-- CSS (reuse main app look) -->
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<!-- Version stamp -->
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
<!-- Portal login JS -->
<script type="module" src="/js/portal-login.js?v={{APP_QVER}}"></script>
<style>
html, body {
height: 100%;
}
body {
display: flex;
align-items: center;
justify-content: center;
background: var(--pre-bg, #f4f4f7);
}
.portal-login-wrapper {
width: 100%;
max-width: 420px;
padding: 16px;
}
.portal-login-card {
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
padding: 20px 22px 18px;
background: #fff;
}
[data-theme="dark"] .portal-login-card {
background: #1f2933;
color: #e5e7eb;
}
.portal-login-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.portal-login-header img {
width: 32px;
height: 32px;
}
.portal-login-title {
font-weight: 600;
font-size: 1rem;
line-height: 1.2;
}
.portal-login-subtitle {
font-size: 0.8rem;
color: #6c757d;
}
[data-theme="dark"] .portal-login-subtitle {
color: #9ca3af;
}
#portalLoginError {
font-size: 0.85rem;
margin-bottom: 8px;
display: none;
}
#portalLoginError.show {
display: block;
}
.portal-login-card {
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
padding: 20px 22px 18px;
background: #fff;
border-top: 3px solid var(--filr-accent-500, #0b5ed7);
}
</style>
</head>
<body data-theme="light">
<div class="portal-login-wrapper">
<div class="portal-login-card">
<div class="portal-login-header">
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
<div>
<div id="portalLoginTitle" class="portal-login-title">
Sign in to Client Portal
</div>
<div id="portalLoginSubtitle" class="portal-login-subtitle">
to access this client portal
</div>
</div>
</div>
<div id="portalLoginError" class="alert alert-danger"></div>
<form id="portalLoginForm" novalidate>
<div class="form-group">
<label for="portalLoginUser">Username or email</label>
<input type="text"
class="form-control form-control-sm"
id="portalLoginUser"
autocomplete="username"
required>
</div>
<div class="form-group">
<label for="portalLoginPass">Password</label>
<input type="password"
class="form-control form-control-sm"
id="portalLoginPass"
autocomplete="current-password"
required>
</div>
<button type="submit"
id="portalLoginSubmit"
class="btn btn-primary btn-sm btn-block">
Sign in
</button>
</form>
<small id="portalLoginHint"
class="text-muted d-block mt-2"
style="font-size:0.75rem;">
Youll be sent back to the portal automatically after signing in.
</small>
<small id="portalLoginFooter"
class="text-muted d-block mt-1"
style="font-size:0.7rem; display:none;">
</small>
</div>
</div>
</body>
</html>

362
public/portal.html Normal file
View File

@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="en">
<style id="pretheme-css">
html, body, #portalRoot { background: var(--pre-bg,#ffffff) !important; }
</style>
<head>
<style>
:root {
--portal-accent: #0b5ed7;
}
.portal-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.portal-card {
max-width: 640px;
width: 100%;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
padding: 20px 20px 16px;
}
.portal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.portal-logo {
display: flex;
align-items: center;
gap: 8px;
}
.portal-logo img {
width: 32px;
height: 32px;
}
.portal-dropzone {
border: 2px dashed rgba(0,0,0,0.2);
border-radius: 10px;
padding: 18px;
text-align: center;
margin-top: 10px;
transition: background 0.15s, border-color 0.15s;
cursor: pointer;
}
.portal-dropzone.dragover {
border-color: var(--portal-accent);
background: rgba(11,94,215,0.06);
}
/* Files list container (scrollable) */
.portal-files-list {
margin-top: 14px;
max-height: 260px;
overflow-y: auto;
padding-right: 4px;
}
/* NEW: grid-style gallery inside the list */
.portal-files-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-auto-rows: minmax(48px, auto);
gap: 8px;
}
.portal-file-card {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.08);
background: rgba(0,0,0,0.01);
font-size: 0.85rem;
}
.portal-file-card:hover {
background: rgba(0,0,0,0.04);
}
.portal-file-card-icon {
flex: 0 0 auto;
width: 34px;
height: 34px;
border-radius: 10px;
border: 2px solid var(--portal-accent, #0b5ed7);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.75rem;
}
.portal-file-card-main {
flex: 1;
min-width: 0;
}
.portal-file-card-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.portal-file-card-meta {
font-size: 0.78rem;
}
.portal-file-card-actions {
flex: 0 0 auto;
margin-left: auto;
}
.portal-file-card-download {
font-size: 0.78rem;
text-decoration: none;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.16);
background: transparent;
white-space: nowrap;
}
.portal-file-card-download:hover {
background: var(--portal-accent, #0b5ed7);
color: #fff;
border-color: var(--portal-accent, #0b5ed7);
text-decoration: none;
}
.portal-status {
margin-top: 8px;
font-size: 0.85rem;
}
#customToast {
position: fixed;
right: 16px;
bottom: 16px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.9rem;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 4000;
display: none;
}
#customToast.show {
opacity: 1;
transform: translateY(0);
}
/* (Optional) keep old row style around if anything else uses it */
.portal-file-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
font-size: 0.9rem;
}
.portal-file-row:last-child {
border-bottom: none;
}
</style>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Client Portal FileRise</title>
<meta name="theme-color" content="#0b5ed7">
<style id="pretheme-css">
html, body, #portalRoot { background: var(--pre-bg,#ffffff) !important; }
</style>
<!-- Favicons / assets -->
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
<meta name="csrf-token" content="">
<meta name="color-scheme" content="light dark">
<!-- CSS (reuse main app CSS for look) -->
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
<!-- Version stamp -->
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
<!-- Portal entry -->
<script type="module" src="/js/portal.js?v={{APP_QVER}}"></script>
<style>
.portal-wrapper {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.portal-card {
max-width: min(960px, 100%);
width: 100%;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.15);
padding: 20px 20px 16px;
}
.portal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.portal-logo {
display: flex;
align-items: center;
gap: 8px;
}
.portal-logo img {
width: 32px;
height: 32px;
}
.portal-dropzone {
border: 2px dashed rgba(0,0,0,0.2);
border-radius: 10px;
padding: 18px;
text-align: center;
margin-top: 10px;
transition: background 0.15s, border-color 0.15s;
cursor: pointer;
}
.portal-dropzone.dragover {
border-color: #0b5ed7;
background: rgba(11,94,215,0.06);
}
.portal-files-list {
margin-top: 14px;
max-height: 260px;
overflow-y: auto;
}
.portal-file-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
border-bottom: 1px solid rgba(0,0,0,0.06);
font-size: 0.9rem;
}
.portal-file-row:last-child {
border-bottom: none;
}
.portal-status {
margin-top: 8px;
font-size: 0.85rem;
}
#customToast {
position: fixed;
right: 16px;
bottom: 16px;
background: rgba(0, 0, 0, 0.85);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.9rem;
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: opacity 0.18s ease, transform 0.18s ease;
z-index: 4000;
display: none;
}
#customToast.show {
opacity: 1;
transform: translateY(0);
}
</style>
</head>
<body>
<div id="portalRoot" class="portal-wrapper">
<div class="portal-card">
<div class="portal-header">
<div class="portal-logo">
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise">
<div>
<div id="portalBrandHeading" style="font-weight:600; font-size:1rem;">Client Portal</div>
<div id="portalSubtitle" class="text-muted" style="font-size:0.8rem;"></div>
</div>
</div>
<small id="portalUserLabel" class="text-muted"></small>
</div>
<h3 id="portalTitle" style="margin-bottom:4px;">Loading…</h3>
<p id="portalDescription" class="text-muted" style="margin-bottom:10px;"></p>
<div id="portalFormSection" style="margin-bottom:12px; display:none;">
<h5 style="font-size:0.95rem; margin-bottom:4px;">Your details</h5>
<p class="text-muted" style="font-size:0.8rem; margin-bottom:8px;">
Please fill in your information before uploading files.
</p>
<div class="form-group" style="margin-bottom:6px;">
<label for="portalFormName">Name</label>
<input type="text" id="portalFormName" class="form-control form-control-sm">
</div>
<div class="form-group" style="margin-bottom:6px;">
<label for="portalFormEmail">Email</label>
<input type="email" id="portalFormEmail" class="form-control form-control-sm">
</div>
<div class="form-group" style="margin-bottom:6px;">
<label for="portalFormReference">Reference / Case / Order #</label>
<input type="text" id="portalFormReference" class="form-control form-control-sm">
</div>
<div class="form-group" style="margin-bottom:8px;">
<label for="portalFormNotes">Notes</label>
<textarea id="portalFormNotes" class="form-control form-control-sm" rows="3"></textarea>
</div>
<button type="button" id="portalFormSubmit" class="btn btn-primary btn-sm">
Continue
</button>
</div>
<div id="portalUploadSection">
<div id="portalDropzone" class="portal-dropzone">
<div><strong>Drop files here</strong> or click to browse.</div>
<div style="font-size:0.8rem;" class="text-muted">
Files will be uploaded to this portal only.
</div>
</div>
<input type="file" id="portalFileInput" multiple style="display:none;">
<div id="portalStatus" class="portal-status text-muted"></div>
</div>
<div id="portalFilesSection" style="margin-top:12px; display:none;">
<div class="d-flex justify-content-between align-items-center">
<strong style="font-size:0.95rem;">Files in this portal</strong>
<button type="button" id="portalRefreshBtn" class="btn btn-sm btn-outline-secondary">
Refresh
</button>
</div>
<div id="portalFilesList" class="portal-files-list"></div>
</div>
<div id="portalFooter" class="text-muted"
style="margin-top:12px; font-size:0.75rem; text-align:center;"></div>
</div>
</div>
<div id="customToast"></div>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 KiB

After

Width:  |  Height:  |  Size: 871 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 KiB

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 698 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 501 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 KiB

BIN
resources/portal-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

View File

@@ -272,6 +272,126 @@ public function setLicense(): void
}
}
public function getProPortals(): array
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
if (!is_file($proPortalsPath)) {
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
}
require_once $proPortalsPath;
// ProPortals is implemented in the Pro bundle and handles JSON storage.
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
$portals = $store->listPortals();
return $portals;
}
/**
* @param array $portalsPayload Raw "portals" array from JSON body
*/
public function saveProPortals(array $portalsPayload): void
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
if (!is_file($proPortalsPath)) {
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
}
require_once $proPortalsPath;
if (!is_array($portalsPayload)) {
throw new InvalidArgumentException('Invalid portals format.');
}
// Minimal normalization; deeper validation can live inside ProPortals
$data = ['portals' => []];
foreach ($portalsPayload as $slug => $info) {
$slug = trim((string)$slug);
if ($slug === '') {
continue;
}
if (!is_array($info)) {
$info = [];
}
$label = trim((string)($info['label'] ?? $slug));
$folder = trim((string)($info['folder'] ?? ''));
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
$uploadOnly = !empty($info['uploadOnly']);
$allowDownload = array_key_exists('allowDownload', $info)
? !empty($info['allowDownload'])
: true;
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
// Optional branding + form behavior
$title = trim((string)($info['title'] ?? ''));
$introText = trim((string)($info['introText'] ?? ''));
$requireForm = !empty($info['requireForm']);
$brandColor = trim((string)($info['brandColor'] ?? ''));
$footerText = trim((string)($info['footerText'] ?? ''));
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
? $info['formDefaults']
: [];
// Normalize defaults for known keys
$formDefaults = [
'name' => trim((string)($formDefaults['name'] ?? '')),
'email' => trim((string)($formDefaults['email'] ?? '')),
'reference' => trim((string)($formDefaults['reference'] ?? '')),
'notes' => trim((string)($formDefaults['notes'] ?? '')),
];
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
? $info['formRequired']
: [];
$formRequired = [
'name' => !empty($formRequired['name']),
'email' => !empty($formRequired['email']),
'reference' => !empty($formRequired['reference']),
'notes' => !empty($formRequired['notes']),
];
if ($folder === '') {
continue;
}
$data['portals'][$slug] = [
'label' => $label,
'folder' => $folder,
'clientEmail' => $clientEmail,
'uploadOnly' => $uploadOnly,
'allowDownload' => $allowDownload,
'expiresAt' => $expiresAt,
// NEW
'title' => $title,
'introText' => $introText,
'requireForm' => $requireForm,
'brandColor' => $brandColor,
'footerText' => $footerText,
'formDefaults' => $formDefaults,
'formRequired' => $formRequired,
];
}
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
$ok = $store->savePortals($data);
if (!$ok) {
throw new RuntimeException('Could not write portals.json');
}
}
public function getProGroups(): array
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {

View File

@@ -0,0 +1,123 @@
<?php
// src/controllers/PortalController.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
final class PortalController
{
/**
* Look up a portal by slug from the Pro bundle.
*
* Returns:
* [
* 'slug' => string,
* 'label' => string,
* 'folder' => string,
* 'clientEmail' => string,
* 'uploadOnly' => bool,
* 'allowDownload' => bool,
* 'expiresAt' => string,
* 'title' => string,
* 'introText' => string,
* 'requireForm' => bool
* ]
*/
public static function getPortalBySlug(string $slug): array
{
$slug = trim($slug);
if ($slug === '') {
throw new InvalidArgumentException('Missing portal slug.');
}
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE) {
throw new RuntimeException('FileRise Pro is not active.');
}
if (!defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('Pro bundle directory not configured.');
}
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
if (!is_file($proPortalsPath)) {
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
}
require_once $proPortalsPath;
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
$portals = $store->listPortals();
if (!isset($portals[$slug]) || !is_array($portals[$slug])) {
throw new RuntimeException('Portal not found.');
}
$p = $portals[$slug];
$label = trim((string)($p['label'] ?? $slug));
$folder = trim((string)($p['folder'] ?? ''));
$clientEmail = trim((string)($p['clientEmail'] ?? ''));
$uploadOnly = !empty($p['uploadOnly']);
$allowDownload = array_key_exists('allowDownload', $p)
? !empty($p['allowDownload'])
: true;
$expiresAt = trim((string)($p['expiresAt'] ?? ''));
// NEW: optional branding + intake behavior
$title = trim((string)($p['title'] ?? ''));
$introText = trim((string)($p['introText'] ?? ''));
$requireForm = !empty($p['requireForm']);
$brandColor = trim((string)($p['brandColor'] ?? ''));
$footerText = trim((string)($p['footerText'] ?? ''));
$fd = isset($p['formDefaults']) && is_array($p['formDefaults'])
? $p['formDefaults']
: [];
$formDefaults = [
'name' => trim((string)($fd['name'] ?? '')),
'email' => trim((string)($fd['email'] ?? '')),
'reference' => trim((string)($fd['reference'] ?? '')),
'notes' => trim((string)($fd['notes'] ?? '')),
];
$fr = isset($p['formRequired']) && is_array($p['formRequired'])
? $p['formRequired']
: [];
$formRequired = [
'name' => !empty($fr['name']),
'email' => !empty($fr['email']),
'reference' => !empty($fr['reference']),
'notes' => !empty($fr['notes']),
];
if ($folder === '') {
throw new RuntimeException('Portal misconfigured: empty folder.');
}
// Expiry check
if ($expiresAt !== '') {
$ts = strtotime($expiresAt . ' 23:59:59');
if ($ts !== false && $ts < time()) {
throw new RuntimeException('This portal has expired.');
}
}
return [
'slug' => $slug,
'label' => $label,
'folder' => $folder,
'clientEmail' => $clientEmail,
'uploadOnly' => $uploadOnly,
'allowDownload' => $allowDownload,
'expiresAt' => $expiresAt,
'title' => $title,
'introText' => $introText,
'requireForm' => $requireForm,
'brandColor' => $brandColor,
'footerText' => $footerText,
'formDefaults' => $formDefaults,
'formRequired' => $formRequired,
];
}
}

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