release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more”
- Lazy folder tree via /api/folder/listChildren.php with cursor pagination - ACL-safe chevrons using hasSubfolders from server; no file-count leaks - BFS smart initial folder selection + respect lastOpenedFolder - Locked nodes are expandable but not selectable - “Load more” UX (light & dark) for huge directories Closes #66
This commit is contained in:
@@ -1,245 +1,18 @@
|
||||
<?php
|
||||
// public/api/folder/capabilities.php
|
||||
|
||||
/**
|
||||
* @OA\Get(
|
||||
* path="/api/folder/capabilities.php",
|
||||
* summary="Get effective capabilities for the current user in a folder",
|
||||
* description="Computes the caller's capabilities for a given folder by combining account flags (readOnly/disableUpload), ACL grants (read/write/share), and the user-folder-only scope. Returns booleans indicating what the user can do.",
|
||||
* operationId="getFolderCapabilities",
|
||||
* tags={"Folders"},
|
||||
* security={{"cookieAuth": {}}},
|
||||
*
|
||||
* @OA\Parameter(
|
||||
* name="folder",
|
||||
* in="query",
|
||||
* required=false,
|
||||
* description="Target folder path. Defaults to 'root'. Supports nested paths like 'team/reports'.",
|
||||
* @OA\Schema(type="string"),
|
||||
* example="projects/acme"
|
||||
* ),
|
||||
*
|
||||
* @OA\Response(
|
||||
* response=200,
|
||||
* description="Capabilities computed successfully.",
|
||||
* @OA\JsonContent(
|
||||
* type="object",
|
||||
* required={"user","folder","isAdmin","flags","canView","canUpload","canCreate","canRename","canDelete","canMoveIn","canShare"},
|
||||
* @OA\Property(property="user", type="string", example="alice"),
|
||||
* @OA\Property(property="folder", type="string", example="projects/acme"),
|
||||
* @OA\Property(property="isAdmin", type="boolean", example=false),
|
||||
* @OA\Property(
|
||||
* property="flags",
|
||||
* type="object",
|
||||
* required={"folderOnly","readOnly","disableUpload"},
|
||||
* @OA\Property(property="folderOnly", type="boolean", example=false),
|
||||
* @OA\Property(property="readOnly", type="boolean", example=false),
|
||||
* @OA\Property(property="disableUpload", type="boolean", example=false)
|
||||
* ),
|
||||
* @OA\Property(property="owner", type="string", nullable=true, example="alice"),
|
||||
* @OA\Property(property="canView", type="boolean", example=true, description="User can view items in this folder."),
|
||||
* @OA\Property(property="canUpload", type="boolean", example=true, description="User can upload/edit/rename/move/delete items (i.e., WRITE)."),
|
||||
* @OA\Property(property="canCreate", type="boolean", example=true, description="User can create subfolders here."),
|
||||
* @OA\Property(property="canRename", type="boolean", example=true, description="User can rename items here."),
|
||||
* @OA\Property(property="canDelete", type="boolean", example=true, description="User can delete items here."),
|
||||
* @OA\Property(property="canMoveIn", type="boolean", example=true, description="User can move items into this folder."),
|
||||
* @OA\Property(property="canShare", type="boolean", example=false, description="User can create share links for this folder.")
|
||||
* )
|
||||
* ),
|
||||
* @OA\Response(response=400, description="Invalid folder name."),
|
||||
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
|
||||
* )
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/lib/ACL.php';
|
||||
require_once PROJECT_ROOT . '/src/models/UserModel.php';
|
||||
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
header('Content-Type: application/json');
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
$username = (string)($_SESSION['username'] ?? '');
|
||||
if ($username === '') { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||
@session_write_close();
|
||||
|
||||
// --- auth ---
|
||||
$username = $_SESSION['username'] ?? '';
|
||||
if ($username === '') {
|
||||
http_response_code(401);
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||
$folder = str_replace('\\', '/', trim($folder));
|
||||
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||
|
||||
// --- helpers ---
|
||||
function loadPermsFor(string $u): array {
|
||||
try {
|
||||
if (function_exists('loadUserPermissions')) {
|
||||
$p = loadUserPermissions($u);
|
||||
return is_array($p) ? $p : [];
|
||||
}
|
||||
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
|
||||
$all = userModel::getUserPermissions();
|
||||
if (is_array($all)) {
|
||||
if (isset($all[$u])) return (array)$all[$u];
|
||||
$lk = strtolower($u);
|
||||
if (isset($all[$lk])) return (array)$all[$lk];
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
|
||||
$f = ACL::normalizeFolder($folder);
|
||||
// direct owner
|
||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||
// ancestor owner
|
||||
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
|
||||
$pos = strrpos($f, '/');
|
||||
if ($pos === false) break;
|
||||
$f = substr($f, 0, $pos);
|
||||
if ($f === '' || strcasecmp($f, 'root') === 0) break;
|
||||
if (ACL::isOwner($user, $perms, $f)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* folder-only scope:
|
||||
* - Admins: always in scope
|
||||
* - Non folder-only accounts: always in scope
|
||||
* - Folder-only accounts: in scope iff:
|
||||
* - folder == username OR subpath of username, OR
|
||||
* - user is owner of this folder (or any ancestor)
|
||||
*/
|
||||
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
|
||||
if ($isAdmin) return true;
|
||||
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
|
||||
//if (!$folderOnly) return true;
|
||||
|
||||
$f = ACL::normalizeFolder($folder);
|
||||
if ($f === 'root' || $f === '') {
|
||||
// folder-only users cannot act on root unless they own a subfolder (handled below)
|
||||
return isOwnerOrAncestorOwner($u, $perms, $f);
|
||||
}
|
||||
|
||||
if ($f === $u || str_starts_with($f, $u . '/')) return true;
|
||||
|
||||
// Treat ownership as in-scope
|
||||
return isOwnerOrAncestorOwner($u, $perms, $f);
|
||||
}
|
||||
|
||||
// --- inputs ---
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
|
||||
// validate folder path
|
||||
if ($folder !== 'root') {
|
||||
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
|
||||
if (empty($parts)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
exit;
|
||||
}
|
||||
foreach ($parts as $seg) {
|
||||
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['error' => 'Invalid folder name.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
$folder = implode('/', $parts);
|
||||
}
|
||||
|
||||
// --- user + flags ---
|
||||
$perms = loadPermsFor($username);
|
||||
$isAdmin = ACL::isAdmin($perms);
|
||||
$readOnly = !empty($perms['readOnly']);
|
||||
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
|
||||
|
||||
// --- ACL base abilities ---
|
||||
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
|
||||
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
|
||||
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
|
||||
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
|
||||
|
||||
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
|
||||
|
||||
// granular base
|
||||
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
|
||||
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
|
||||
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
|
||||
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
|
||||
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
|
||||
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
|
||||
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
|
||||
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
|
||||
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
|
||||
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
|
||||
|
||||
// --- Apply scope + flags to effective UI actions ---
|
||||
$canView = $canViewBase && $inScope; // keep scope for folder-only
|
||||
$canUpload = $gUploadBase && !$readOnly && $inScope;
|
||||
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
|
||||
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
|
||||
$canDelete = $gDeleteBase && !$readOnly && $inScope;
|
||||
// Destination can receive items if user can create/write (or manage) here
|
||||
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
|
||||
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
|
||||
$canMoveIn = $canReceive;
|
||||
$canMoveAlias = $canMoveIn;
|
||||
$canEdit = $gEditBase && !$readOnly && $inScope;
|
||||
$canCopy = $gCopyBase && !$readOnly && $inScope;
|
||||
$canExtract = $gExtractBase && !$readOnly && $inScope;
|
||||
|
||||
// Sharing respects scope; optionally also gate on readOnly
|
||||
$canShare = $canShareBase && $inScope; // legacy umbrella
|
||||
$canShareFileEff = $gShareFile && $inScope;
|
||||
$canShareFoldEff = $gShareFolder && $inScope;
|
||||
|
||||
// never allow destructive ops on root
|
||||
$isRoot = ($folder === 'root');
|
||||
if ($isRoot) {
|
||||
$canRename = false;
|
||||
$canDelete = false;
|
||||
$canShareFoldEff = false;
|
||||
$canMoveFolder = false;
|
||||
}
|
||||
|
||||
if (!$isRoot) {
|
||||
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
|
||||
&& !$readOnly;
|
||||
}
|
||||
|
||||
$owner = null;
|
||||
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
|
||||
|
||||
echo json_encode([
|
||||
'user' => $username,
|
||||
'folder' => $folder,
|
||||
'isAdmin' => $isAdmin,
|
||||
'flags' => [
|
||||
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
|
||||
'readOnly' => $readOnly,
|
||||
],
|
||||
'owner' => $owner,
|
||||
|
||||
// viewing
|
||||
'canView' => $canView,
|
||||
'canViewOwn' => $canViewOwn,
|
||||
|
||||
// write-ish
|
||||
'canUpload' => $canUpload,
|
||||
'canCreate' => $canCreate,
|
||||
'canRename' => $canRename,
|
||||
'canDelete' => $canDelete,
|
||||
'canMoveIn' => $canMoveIn,
|
||||
'canMove' => $canMoveAlias,
|
||||
'canMoveFolder'=> $canMoveFolder,
|
||||
'canEdit' => $canEdit,
|
||||
'canCopy' => $canCopy,
|
||||
'canExtract' => $canExtract,
|
||||
|
||||
// sharing
|
||||
'canShare' => $canShare, // legacy
|
||||
'canShareFile' => $canShareFileEff,
|
||||
'canShareFolder' => $canShareFoldEff,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@@ -1,30 +1,28 @@
|
||||
<?php
|
||||
// public/api/folder/isEmpty.php
|
||||
// Fast ACL-aware peek for tree icons/chevrons
|
||||
declare(strict_types=1);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
// Snapshot then release session lock so parallel requests don’t block
|
||||
$user = (string)($_SESSION['username'] ?? '');
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||
|
||||
$username = (string)($_SESSION['username'] ?? '');
|
||||
$perms = [
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
'folderOnly' => $_SESSION['folderOnly'] ?? null,
|
||||
'readOnly' => $_SESSION['readOnly'] ?? null,
|
||||
];
|
||||
@session_write_close();
|
||||
|
||||
// Input
|
||||
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||
$folder = str_replace('\\', '/', trim($folder));
|
||||
$folder = ($folder === '' || $folder === 'root') ? 'root' : trim($folder, '/');
|
||||
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||
|
||||
// Delegate to controller (model handles ACL + path safety)
|
||||
$result = FolderController::stats($folder, $user, $perms);
|
||||
|
||||
// Always return a compact JSON object like before
|
||||
echo json_encode([
|
||||
'folders' => (int)($result['folders'] ?? 0),
|
||||
'files' => (int)($result['files'] ?? 0),
|
||||
]);
|
||||
echo json_encode(FolderController::stats($folder, $username, $perms), JSON_UNESCAPED_SLASHES);
|
||||
31
public/api/folder/listChildren.php
Normal file
31
public/api/folder/listChildren.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
|
||||
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
|
||||
|
||||
$username = (string)($_SESSION['username'] ?? '');
|
||||
$perms = [
|
||||
'role' => $_SESSION['role'] ?? null,
|
||||
'admin' => $_SESSION['admin'] ?? null,
|
||||
'isAdmin' => $_SESSION['isAdmin'] ?? null,
|
||||
'folderOnly' => $_SESSION['folderOnly'] ?? null,
|
||||
'readOnly' => $_SESSION['readOnly'] ?? null,
|
||||
];
|
||||
@session_write_close();
|
||||
|
||||
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
|
||||
$folder = str_replace('\\', '/', trim($folder));
|
||||
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
|
||||
|
||||
$limit = max(1, min(2000, (int)($_GET['limit'] ?? 500)));
|
||||
$cursor = isset($_GET['cursor']) && $_GET['cursor'] !== '' ? (string)$_GET['cursor'] : null;
|
||||
|
||||
$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit);
|
||||
echo json_encode($res, JSON_UNESCAPED_SLASHES);
|
||||
@@ -2413,3 +2413,67 @@ body.dark-mode .folder-strip-container .folder-item:hover {
|
||||
.folder-strip-container .folder-svg .paper-line,
|
||||
.folder-strip-container .folder-svg .paper-ink,
|
||||
.folder-strip-container .folder-svg .lip-highlight { stroke-width: 1.1px; }
|
||||
/* Locked folder look (keep subtle but clear) */
|
||||
#folderTreeContainer .folder-option.locked {
|
||||
opacity: .9;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Padlock styling inside the SVG */
|
||||
#folderTreeContainer .folder-icon .lock-body {
|
||||
fill: var(--filr-folder-stroke, #6b6b6b);
|
||||
opacity: .95;
|
||||
}
|
||||
#folderTreeContainer .folder-icon .lock-shackle {
|
||||
fill: none;
|
||||
stroke: var(--filr-folder-stroke, #6b6b6b);
|
||||
stroke-width: 1.4;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
#folderTreeContainer .folder-icon .lock-keyhole {
|
||||
fill: rgba(0,0,0,.28);
|
||||
}
|
||||
body.dark-mode #folderTreeContainer .folder-icon .lock-keyhole {
|
||||
fill: rgba(255,255,255,.28);
|
||||
}
|
||||
|
||||
#folderTreeContainer li.load-more {
|
||||
padding: 4px 0 4px 28px; /* indent to line up with rows */
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
#folderTreeContainer li.load-more > .btn.btn-ghost {
|
||||
width: calc(100% - 8px);
|
||||
margin: 0 4px;
|
||||
display: flex;
|
||||
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);
|
||||
padding: 6px 10px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
#folderTreeContainer li.load-more > .btn.btn-ghost:hover {
|
||||
background: var(--tree-ghost-hover-bg);
|
||||
}
|
||||
|
||||
#folderTreeContainer li.load-more > .btn.btn-ghost:focus-visible {
|
||||
outline: 2px solid #8ab4f8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* tiny spinner when busy */
|
||||
#folderTreeContainer li.load-more > .btn[aria-busy="true"]::before {
|
||||
content: "";
|
||||
width: 12px; height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
display: inline-block;
|
||||
animation: filr-spin .8s linear infinite;
|
||||
}
|
||||
@keyframes filr-spin { to { transform: rotate(360deg); } }
|
||||
@@ -277,14 +277,24 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="folderHelpTooltip" class="folder-help-tooltip"
|
||||
style="display: none; position: absolute; top: 50px; right: 15px; background: #fff; border: 1px solid #ccc; padding: 10px; z-index: 1000; box-shadow: 2px 2px 6px rgba(0,0,0,0.2);">
|
||||
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
|
||||
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
|
||||
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
|
||||
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
|
||||
subfolder.</li>
|
||||
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
|
||||
the appropriate button.</li>
|
||||
style="display:none;position:absolute;top:50px;right:15px;background:#fff;border:1px solid #ccc;padding:10px;z-index:1000;box-shadow:2px 2px 6px rgba(0,0,0,0.2);border-radius:8px;max-width:320px;line-height:1.35;">
|
||||
<style>
|
||||
/* Dark mode polish */
|
||||
body.dark-mode #folderHelpTooltip {
|
||||
background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
|
||||
}
|
||||
#folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
|
||||
#folderHelpTooltip .folder-help-list li { margin:6px 0; }
|
||||
</style>
|
||||
<ul class="folder-help-list">
|
||||
<li data-i18n-key="folder_help_click_view">Click a folder in the tree to view its files.</li>
|
||||
<li data-i18n-key="folder_help_expand_chevrons">Use chevrons to expand/collapse. Locked folders (padlock) can expand but can’t be opened.</li>
|
||||
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
|
||||
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
|
||||
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
|
||||
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
|
||||
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
|
||||
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -318,7 +318,19 @@ const translations = {
|
||||
"reset_default": "Reset",
|
||||
"save_color": "Save",
|
||||
"folder_color_saved": "Folder color saved.",
|
||||
"folder_color_cleared": "Folder color reset."
|
||||
"folder_color_cleared": "Folder color reset.",
|
||||
"load_more": "Load more",
|
||||
"loading": "Loading...",
|
||||
"no_access": "You do not have access to this resource.",
|
||||
"please_select_valid_folder": "Please select a valid folder.",
|
||||
"folder_help_click_view": "Click a folder in the tree to view its files.",
|
||||
"folder_help_expand_chevrons": "Use chevrons to expand/collapse. Locked folders (padlock) can expand but can’t be opened.",
|
||||
"folder_help_context_menu": "Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.",
|
||||
"folder_help_drag_drop": "Drag a folder onto another folder or a breadcrumb to move it.",
|
||||
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
|
||||
"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."
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
Reference in New Issue
Block a user