@brftech/filex-core 0.42.1 → 0.42.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{ArchiveViewer-CLNevfPE.js → ArchiveViewer-BUQWLhwX.js} +2 -2
- package/dist/{ArchiveViewer-CLNevfPE.js.map → ArchiveViewer-BUQWLhwX.js.map} +1 -1
- package/dist/{CsvViewer-K8XA3z7N.js → CsvViewer-Bg2_aqzc.js} +2 -2
- package/dist/{CsvViewer-K8XA3z7N.js.map → CsvViewer-Bg2_aqzc.js.map} +1 -1
- package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-CC6rJXeP.js} +2 -2
- package/dist/{DrawioViewer-BuhZOAx6.js.map → DrawioViewer-CC6rJXeP.js.map} +1 -1
- package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-CAoTSXTO.js} +2 -2
- package/dist/{EpubViewer-DS3mb3k8.js.map → EpubViewer-CAoTSXTO.js.map} +1 -1
- package/dist/{IpynbViewer-BTk7p4Dn.js → IpynbViewer-D0GwGpnE.js} +2 -2
- package/dist/{IpynbViewer-BTk7p4Dn.js.map → IpynbViewer-D0GwGpnE.js.map} +1 -1
- package/dist/{MermaidViewer-DqucH_io.js → MermaidViewer-DGwAvRZg.js} +2 -2
- package/dist/{MermaidViewer-DqucH_io.js.map → MermaidViewer-DGwAvRZg.js.map} +1 -1
- package/dist/{PsdViewer-CwiVENmq.js → PsdViewer-DhGweA0m.js} +2 -2
- package/dist/{PsdViewer-CwiVENmq.js.map → PsdViewer-DhGweA0m.js.map} +1 -1
- package/dist/{TiffViewer-CpewSVmQ.js → TiffViewer-D0AurvAh.js} +2 -2
- package/dist/{TiffViewer-CpewSVmQ.js.map → TiffViewer-D0AurvAh.js.map} +1 -1
- package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-CdZeK4j4.js} +2 -2
- package/dist/{Viewer3D-CIrOn63I.js.map → Viewer3D-CdZeK4j4.js.map} +1 -1
- package/dist/filex-core.js +1 -1
- package/dist/filex-core.umd.cjs +47 -47
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/{index-Dx0IbBcI.js → index-BArI-B1M.js} +9261 -9181
- package/dist/index-BArI-B1M.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +152 -90
- package/src/components/RecentlyOpened.vue +16 -2
- package/src/components/S3KeysPanel.vue +33 -27
- package/src/components/SSHKeysPanel.vue +29 -25
- package/src/components/TokensPanel.vue +26 -22
- package/src/composables/useScrolledX.ts +21 -0
- package/src/lib/nodeRow.ts +106 -0
- package/src/locales/en.ts +7 -2
- package/src/locales/tr.ts +3 -3
- package/src/styles/base.css +24 -0
- package/src/types/FileNode.ts +8 -0
- package/dist/index-Dx0IbBcI.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brftech/filex-core",
|
|
3
|
-
"version": "0.42.
|
|
3
|
+
"version": "0.42.2",
|
|
4
4
|
"description": "filex core — Vue 3 source of truth for the filex file manager (FileExplorer + ConnectionsPanel SFCs, composables, types)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/filex-core.umd.cjs",
|
package/src/FileExplorer.vue
CHANGED
|
@@ -161,6 +161,7 @@ import {
|
|
|
161
161
|
injectTrashRow,
|
|
162
162
|
hydrateTrashRow as hydrateTrashRowShared,
|
|
163
163
|
} from './lib/listing';
|
|
164
|
+
import { nodeRowToFileNode as nodeRowToFileNodePure } from './lib/nodeRow'; /* Recent / Starred / tag / Home rows — one shape, with `perm` + `read_only` */
|
|
164
165
|
import { iconFamilyFor, isStorageRow } from './lib/fileIcons'; /* pane:p1 — the storage-row predicate's one home */
|
|
165
166
|
import { actionIconSvg } from './lib/actionIcons'; /* inceleme:r1 — the drop overlay's mark, off the emoji font */
|
|
166
167
|
import { setNodeStarred } from './lib/star';
|
|
@@ -999,19 +1000,42 @@ function onNodeTagsChanged() {
|
|
|
999
1000
|
}
|
|
1000
1001
|
|
|
1001
1002
|
function onRecentOpen(entry: { id: number; storage_id?: number; path: string; name: string }) {
|
|
1002
|
-
// RecentlyOpened emits the bare row
|
|
1003
|
-
//
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1003
|
+
// RecentlyOpened emits the bare row. The same converter Recent / Starred /
|
|
1004
|
+
// a tag / Home use gives openNode a row with its storage-qualified path AND
|
|
1005
|
+
// its own `perm` + `read_only`, so the editor-vs-preview split reads the
|
|
1006
|
+
// row's level, not the folder's. The bare synthesis stays as the fallback
|
|
1007
|
+
// for a row the converter cannot address (no storage name at a
|
|
1008
|
+
// multi-storage root) — it was the only shape until 2026-09-19.
|
|
1009
|
+
const node =
|
|
1010
|
+
nodeRowToFileNode(entry as unknown as Record<string, unknown>) ??
|
|
1011
|
+
({
|
|
1012
|
+
type: 'file',
|
|
1013
|
+
path: entry.path,
|
|
1014
|
+
basename: entry.name,
|
|
1015
|
+
extension: (entry.name.split('.').pop() || '').toLowerCase(),
|
|
1016
|
+
id: entry.id,
|
|
1017
|
+
} as unknown as FileNode);
|
|
1011
1018
|
showRecents.value = false;
|
|
1012
1019
|
openNode(node);
|
|
1013
1020
|
}
|
|
1014
1021
|
|
|
1022
|
+
/**
|
|
1023
|
+
* Right-click on a row of the Recently-opened tray: the SAME menu a listing
|
|
1024
|
+
* row gets (owner: "CONTEXT MENÜ HER YERDE AYNI OLMALI"). The tray's row is
|
|
1025
|
+
* the raw node row the endpoint answers with, so it goes through
|
|
1026
|
+
* `nodeRowToFileNode` like Recent / Starred / a tag / Home do and arrives
|
|
1027
|
+
* with its own `perm` + `read_only`; `onContextTarget` then treats it like a
|
|
1028
|
+
* Home card — a target outside this pane's listing. The tray closes first,
|
|
1029
|
+
* as it does on open: it is a modal over the listing, and the verb the menu
|
|
1030
|
+
* runs (Rename, Move to…) opens a dialog of its own.
|
|
1031
|
+
*/
|
|
1032
|
+
function onRecentContext(row: Record<string, unknown>, ev: MouseEvent) {
|
|
1033
|
+
const node = nodeRowToFileNode(row);
|
|
1034
|
+
if (!node) return;
|
|
1035
|
+
showRecents.value = false;
|
|
1036
|
+
void onContextTarget(node, ev);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1015
1039
|
// Resolution order for each external viewer: explicit config override → live
|
|
1016
1040
|
// backend probe. The probe is the source of truth: an operator can flip the
|
|
1017
1041
|
// service "on" but if last_check failed (state='error') we still hide the
|
|
@@ -1593,15 +1617,22 @@ function onToolbarSearch(v: string) {
|
|
|
1593
1617
|
* searches what is on screen.
|
|
1594
1618
|
*/
|
|
1595
1619
|
const driveScopeLabel = computed(() => {
|
|
1596
|
-
/*
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1620
|
+
/* ⚠ The field searches the WHOLE storage (`?action=search` filters by
|
|
1621
|
+
storage id only — see handlers/manager.go vfSearch), and at the virtual
|
|
1622
|
+
root every storage. Naming the open FOLDER here ("Search in Photos") was
|
|
1623
|
+
a lie the user found: results came from the whole storage. The scope is
|
|
1624
|
+
the storage; the folder-scoped box is FilterBar's "Filter in this
|
|
1625
|
+
folder…". Home, the virtual root and the cross-storage views (Recent,
|
|
1626
|
+
Starred, Shared, tags) have no single storage → the everywhere wording.
|
|
1627
|
+
⚠ `multiStorageRoot` is the MODE (the admin explorer is always in it), not
|
|
1628
|
+
the place: inside a storage the scope is that storage. Gating on the mode
|
|
1629
|
+
printed "Search all storages" on every admin folder (caught by
|
|
1630
|
+
e2e/tests/107 on the v0.42.2 chain). */
|
|
1631
|
+
if (navView.value || atVirtualRoot.value) return '';
|
|
1632
|
+
const name = adapter.value;
|
|
1633
|
+
if (!name) return '';
|
|
1634
|
+
const st = (props.config.storages ?? []).find((s) => s.name === name);
|
|
1635
|
+
return st?.label || name;
|
|
1605
1636
|
});
|
|
1606
1637
|
|
|
1607
1638
|
/**
|
|
@@ -1680,70 +1711,19 @@ async function loadQuota() {
|
|
|
1680
1711
|
}
|
|
1681
1712
|
|
|
1682
1713
|
/**
|
|
1683
|
-
* nodeRowToFileNode — the starred / recently-opened endpoints answer
|
|
1684
|
-
* node rows (relative `path`, numeric `storage_id`), not the listing
|
|
1685
|
-
*
|
|
1686
|
-
*
|
|
1687
|
-
*
|
|
1688
|
-
*
|
|
1689
|
-
*
|
|
1690
|
-
* install sends the user to a path in somebody else's drive.
|
|
1714
|
+
* nodeRowToFileNode — the starred / recently-opened / tag endpoints answer
|
|
1715
|
+
* with raw node rows (relative `path`, numeric `storage_id`), not the listing
|
|
1716
|
+
* shape. The conversion itself lives in `lib/nodeRow` (pure, unit-tested);
|
|
1717
|
+
* this binds it to the explorer's config. Every view that lists such rows —
|
|
1718
|
+
* Recent, Starred, a tag, the Home cards, the Recently-opened tray — goes
|
|
1719
|
+
* through here, so they all carry the same `perm` + `read_only` the context
|
|
1720
|
+
* menu gates its write verbs on.
|
|
1691
1721
|
*/
|
|
1692
|
-
/** tablo:t1 — an RFC3339 stamp from a node row as unix ms, or undefined. A
|
|
1693
|
-
* string we cannot parse is left undefined rather than turned into `NaN`,
|
|
1694
|
-
* which would print as "Invalid Date" and sort unpredictably. */
|
|
1695
|
-
function rowMillis(v: unknown): number | undefined {
|
|
1696
|
-
if (typeof v !== 'string' || !v) return undefined;
|
|
1697
|
-
const ms = Date.parse(v);
|
|
1698
|
-
return Number.isFinite(ms) ? ms : undefined;
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
1722
|
function nodeRowToFileNode(row: Record<string, unknown>): FileNode | null {
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
typeof row.storage === 'string' && row.storage
|
|
1707
|
-
? row.storage
|
|
1708
|
-
: configured.length === 1
|
|
1709
|
-
? configured[0].name
|
|
1710
|
-
: '';
|
|
1711
|
-
if (multiStorageRoot.value && !storageName) return null;
|
|
1712
|
-
const name = String(row.name ?? rel.split('/').pop() ?? '');
|
|
1713
|
-
const isDir = row.type === 'dir';
|
|
1714
|
-
const size = typeof row.size === 'number' ? row.size : 0;
|
|
1715
|
-
const id = typeof row.id === 'number' ? row.id : undefined;
|
|
1716
|
-
return {
|
|
1717
|
-
type: isDir ? 'dir' : 'file',
|
|
1718
|
-
id,
|
|
1719
|
-
/* tablo:t1 — ⚠⚠ THE DATE. A node row carries `backend_mtime` (what the
|
|
1720
|
-
storage says) and `db_mtime` (what our last scan recorded) as RFC3339
|
|
1721
|
-
strings; `FileNode.last_modified` is unix MILLISECONDS. Nothing mapped
|
|
1722
|
-
between the two, so every row from Recent, Starred and a tag view
|
|
1723
|
-
arrived with no date at all — measured on Recent: eleven rows, eleven em
|
|
1724
|
-
dashes in the Modified column, and a Modified column header you could
|
|
1725
|
-
click that then sorted nothing. It also made the date grouping this view
|
|
1726
|
-
is supposed to show impossible, because every row fell in the "No date"
|
|
1727
|
-
bucket. Storage first: `backend_mtime` is the file's own truth and
|
|
1728
|
-
`db_mtime` only says when we last looked at it. */
|
|
1729
|
-
last_modified: rowMillis(row.backend_mtime) ?? rowMillis(row.db_mtime),
|
|
1730
|
-
path: storageName ? `${storageName}://${rel}` : rel,
|
|
1731
|
-
basename: name,
|
|
1732
|
-
extension: isDir
|
|
1733
|
-
? ''
|
|
1734
|
-
: name.includes('.')
|
|
1735
|
-
? (name.split('.').pop() || '').toLowerCase()
|
|
1736
|
-
: '',
|
|
1737
|
-
storage: storageName,
|
|
1738
|
-
visibility: 'private',
|
|
1739
|
-
size,
|
|
1740
|
-
file_size: size,
|
|
1741
|
-
mime_type: typeof row.mime === 'string' ? row.mime : '',
|
|
1742
|
-
// Keyed by node id. A file with no rendered thumbnail 404s here and the
|
|
1743
|
-
// view falls back to its icon — the contract the ordinary listing has too.
|
|
1744
|
-
thumb_url: !isDir && id !== undefined ? `/api/files/thumb/${id}` : undefined,
|
|
1745
|
-
extra_metadata: {},
|
|
1746
|
-
} as unknown as FileNode;
|
|
1723
|
+
return nodeRowToFileNodePure(row, {
|
|
1724
|
+
storages: props.config.storages ?? [],
|
|
1725
|
+
multiStorageRoot: multiStorageRoot.value,
|
|
1726
|
+
});
|
|
1747
1727
|
}
|
|
1748
1728
|
|
|
1749
1729
|
/** GET one of the view endpoints. Returns rows already in listing shape. */
|
|
@@ -1833,6 +1813,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
1833
1813
|
navTag.value = '';
|
|
1834
1814
|
trashMode.value = false;
|
|
1835
1815
|
e2eRoot.value = '';
|
|
1816
|
+
forgetFolderPerm();
|
|
1836
1817
|
selection.clear();
|
|
1837
1818
|
files.value = [];
|
|
1838
1819
|
dirname.value = NAV_VIEW_DIRNAME.home;
|
|
@@ -1866,6 +1847,7 @@ async function loadNavView(kind: Exclude<NavView, ''>) {
|
|
|
1866
1847
|
navTag.value = '';
|
|
1867
1848
|
trashMode.value = false;
|
|
1868
1849
|
e2eRoot.value = '';
|
|
1850
|
+
forgetFolderPerm();
|
|
1869
1851
|
selection.clear();
|
|
1870
1852
|
try {
|
|
1871
1853
|
files.value = await fetchNavRows(kind);
|
|
@@ -1906,6 +1888,7 @@ async function loadTagView(tag: string) {
|
|
|
1906
1888
|
navTag.value = name;
|
|
1907
1889
|
trashMode.value = false;
|
|
1908
1890
|
e2eRoot.value = '';
|
|
1891
|
+
forgetFolderPerm();
|
|
1909
1892
|
selection.clear();
|
|
1910
1893
|
try {
|
|
1911
1894
|
const rows = await fetchTaggedRows(name, {
|
|
@@ -2054,11 +2037,83 @@ function permCanEdit(p: string | undefined): boolean {
|
|
|
2054
2037
|
function permIsOwner(p: string | undefined): boolean {
|
|
2055
2038
|
return p === 'owner';
|
|
2056
2039
|
}
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2040
|
+
/* ⚠⚠ THE CONTEXT MENU MUST BE THE SAME EVERYWHERE (owner, 2026-09-19: "son
|
|
2041
|
+
* kullanılanlar, ana sayfa gibi sayfalarda context menu eksik kalıyor.
|
|
2042
|
+
* CONTEXT MENÜ HER YERDE AYNI OLMALI").
|
|
2043
|
+
*
|
|
2044
|
+
* `dirPerm` / `dirReadOnly` describe the FOLDER being listed, and only
|
|
2045
|
+
* `load()` sets them. Recent, Starred, Shared, a tag view and Home list rows
|
|
2046
|
+
* from every storage and have no folder — so whatever folder was open LAST
|
|
2047
|
+
* answered for them. On the landing page that is no folder at all
|
|
2048
|
+
* (`dirPerm === ''`), and `permCanEdit('')` is false, so the menu on a Home
|
|
2049
|
+
* card or a Recent row came up without Rename / Delete / Move to / Share.
|
|
2050
|
+
* After a writable folder had been visited the stale level said yes to
|
|
2051
|
+
* everything, including rows on a read-only mount. Intermittent, therefore
|
|
2052
|
+
* reported as "eksik kalıyor" rather than "yok".
|
|
2053
|
+
*
|
|
2054
|
+
* Three rules restore one menu:
|
|
2055
|
+
* 1. entering a virtual view forgets the folder (`forgetFolderPerm`), so
|
|
2056
|
+
* nothing stale can leak into it;
|
|
2057
|
+
* 2. a row answers for itself — its own `perm` (every listing carries it
|
|
2058
|
+
* now, `handlers/meta.go`) and its own storage's `read_only`;
|
|
2059
|
+
* 3. a multi-selection is as weak as its weakest row, because the server
|
|
2060
|
+
* refuses the whole batch on the one row the caller may not touch.
|
|
2061
|
+
* What stays hidden in a virtual view is only what is structurally
|
|
2062
|
+
* impossible there — New folder, Upload, Paste need a destination folder
|
|
2063
|
+
* (`atVirtualRoot`).
|
|
2064
|
+
*/
|
|
2065
|
+
function forgetFolderPerm() {
|
|
2066
|
+
dirPerm.value = '';
|
|
2067
|
+
dirReadOnly.value = false;
|
|
2068
|
+
}
|
|
2069
|
+
/** A view whose rows span every storage: no folder is behind it. */
|
|
2070
|
+
const inVirtualView = computed(() => !!navView.value && navView.value !== 'trash');
|
|
2071
|
+
/** The row's own level; the folder's when the row has none. In a virtual view
|
|
2072
|
+
* a row without a level (a server older than `handlers/meta.go`'s `perm`)
|
|
2073
|
+
* is ungated — the server enforces, this only shapes the menu — rather than
|
|
2074
|
+
* gated by an empty folder level that would hide every write verb. */
|
|
2075
|
+
function rowPerm(n: FileNode): string | undefined {
|
|
2076
|
+
if (typeof n.perm === 'string') return n.perm;
|
|
2077
|
+
return inVirtualView.value ? undefined : dirPerm.value;
|
|
2078
|
+
}
|
|
2079
|
+
/** The row sits on a read-only storage: its own `read_only` (nav/tag rows),
|
|
2080
|
+
* else the host's storage list — a folder listing's rows carry no flag of
|
|
2081
|
+
* their own because `dirReadOnly` already answers for the whole folder. */
|
|
2082
|
+
function nodeReadOnly(n: FileNode): boolean {
|
|
2083
|
+
if (n.read_only === true) return true;
|
|
2084
|
+
const storage = typeof n.storage === 'string' ? n.storage : '';
|
|
2085
|
+
if (!storage) return false;
|
|
2086
|
+
return (props.config.storages ?? []).some((s) => s.name === storage && s.readOnly === true);
|
|
2087
|
+
}
|
|
2088
|
+
/** May the caller write THIS row — level and read-only mount folded together.
|
|
2089
|
+
* The one predicate behind the editor/preview split and the menu's write
|
|
2090
|
+
* verbs, so a Home card and a listing row cannot disagree. */
|
|
2091
|
+
function nodeCanEdit(n: FileNode): boolean {
|
|
2092
|
+
return permCanEdit(rowPerm(n)) && !nodeReadOnly(n);
|
|
2093
|
+
}
|
|
2094
|
+
const PERM_RANK: Record<string, number> = { none: 0, viewer: 1, editor: 2, owner: 3 };
|
|
2095
|
+
// Effective perm for a selection: a single entry's own perm, the WEAKEST of a
|
|
2096
|
+
// multi-selection's own perms, else the directory perm (background / rows
|
|
2097
|
+
// that carry no level).
|
|
2098
|
+
function selPerm(sel: FileNode[]): string | undefined {
|
|
2099
|
+
if (sel.length === 0) return dirPerm.value;
|
|
2100
|
+
let weakest: string | undefined;
|
|
2101
|
+
let ungated = false;
|
|
2102
|
+
for (const n of sel) {
|
|
2103
|
+
const p = rowPerm(n);
|
|
2104
|
+
if (p === undefined) {
|
|
2105
|
+
ungated = true;
|
|
2106
|
+
continue;
|
|
2107
|
+
}
|
|
2108
|
+
if (weakest === undefined || (PERM_RANK[p] ?? 0) < (PERM_RANK[weakest] ?? 0)) weakest = p;
|
|
2109
|
+
}
|
|
2110
|
+
// An ungated row (no level, unwired ACL) never strengthens a selection
|
|
2111
|
+
// that also holds a gated one; alone, it stays ungated.
|
|
2112
|
+
return weakest ?? (ungated ? undefined : dirPerm.value);
|
|
2113
|
+
}
|
|
2114
|
+
/** Any row of the selection on a read-only storage → the selection is. */
|
|
2115
|
+
function selReadOnly(sel: FileNode[]): boolean {
|
|
2116
|
+
return sel.some(nodeReadOnly);
|
|
2062
2117
|
}
|
|
2063
2118
|
// Can the current user write into the directory being viewed? Gates the
|
|
2064
2119
|
// toolbar New Folder / Upload / Paste + drag-drop upload.
|
|
@@ -3134,7 +3189,7 @@ function openNode(n: FileNode) {
|
|
|
3134
3189
|
const ext = (n.extension || '').toLowerCase();
|
|
3135
3190
|
// RBAC: viewers (no edit on this item) always get the read-only preview
|
|
3136
3191
|
// modal — never the editable surface. This is the "view vs edit" split.
|
|
3137
|
-
previewMode.value =
|
|
3192
|
+
previewMode.value = nodeCanEdit(n)
|
|
3138
3193
|
? previewModeForExt(ext)
|
|
3139
3194
|
: 'view';
|
|
3140
3195
|
previewTarget.value = n;
|
|
@@ -3236,7 +3291,7 @@ function openNodeInNewTab(n: FileNode) {
|
|
|
3236
3291
|
}
|
|
3237
3292
|
// RBAC: a viewer (no edit on this item) can't use the editable "Aç"
|
|
3238
3293
|
// surface — drop to the read-only in-page preview instead.
|
|
3239
|
-
if (!
|
|
3294
|
+
if (!nodeCanEdit(n)) {
|
|
3240
3295
|
previewNode(n);
|
|
3241
3296
|
return;
|
|
3242
3297
|
}
|
|
@@ -3663,7 +3718,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
3663
3718
|
// RBAC: gate mutating actions when the caller lacks edit on the target. The
|
|
3664
3719
|
// "İzinler" (permissions) action shows only for owners on RBAC-on storages.
|
|
3665
3720
|
const p = selPerm(sel);
|
|
3666
|
-
|
|
3721
|
+
// May write to the selection: the weakest row's level, and no row on a
|
|
3722
|
+
// read-only mount — `selReadOnly` is what `dirReadOnly` cannot answer in a
|
|
3723
|
+
// view whose rows come from several storages (Recent, Starred, a tag, Home).
|
|
3724
|
+
const w = permCanEdit(p) && !selReadOnly(sel);
|
|
3667
3725
|
// Unified "Paylaş / İzinler" popup: public share link (editor+) + per-user
|
|
3668
3726
|
// permissions (owner-only, decided inside the modal).
|
|
3669
3727
|
// Unified "Paylaş / İzinler" popup carries the public share link, per-user
|
|
@@ -3716,7 +3774,10 @@ function selectionActionList(sel: FileNode[]): ContextAction[] {
|
|
|
3716
3774
|
They borrow the clipboard verbs' marks, which is what they are. */
|
|
3717
3775
|
{ key: 'move-to', label: t('ctx.move_to'), icon: 'cut', hidden: !any || !w, disabled: !any },
|
|
3718
3776
|
{ key: 'copy-to', label: t('ctx.copy_to'), icon: 'copy', hidden: !any, disabled: !any },
|
|
3719
|
-
|
|
3777
|
+
/* Paste needs a destination folder; a virtual view (Recent / Starred /
|
|
3778
|
+
Shared / a tag / Home) has none, so it is the one write verb the row
|
|
3779
|
+
menu does not carry there — every per-row verb above and below does. */
|
|
3780
|
+
{ key: 'paste', label: t('ctx.paste'), hidden: !w || atVirtualRoot.value, disabled: !clipboard.value.mode },
|
|
3720
3781
|
{ divider: true, key: 'sep-meta', label: '', hidden: !singleHasId && !canStar },
|
|
3721
3782
|
/* yildiz:s1 — "star must be an action, like a tag" (owner, v0.30.0).
|
|
3722
3783
|
Beside Tags on purpose: they are the same kind of verb, and this is the
|
|
@@ -4121,7 +4182,7 @@ async function onDocumentCreated(file: { path: string; name: string; ext: string
|
|
|
4121
4182
|
previewTarget.value = node;
|
|
4122
4183
|
// ⚠ NOT previewModeForExt: that sends office types to 'view', which is right
|
|
4123
4184
|
// for a peek at somebody else's file and wrong for the one you just made.
|
|
4124
|
-
previewMode.value =
|
|
4185
|
+
previewMode.value = nodeCanEdit(node) ? 'edit' : 'view';
|
|
4125
4186
|
showPreview.value = true;
|
|
4126
4187
|
emit('file-opened', { path: node.path, basename: node.basename });
|
|
4127
4188
|
void markRecent(node);
|
|
@@ -7294,6 +7355,7 @@ function closeRecoveryKey() {
|
|
|
7294
7355
|
:limit="20"
|
|
7295
7356
|
:refresh-key="recentRefreshKey"
|
|
7296
7357
|
@open="onRecentOpen"
|
|
7358
|
+
@context="onRecentContext"
|
|
7297
7359
|
@error="(msg: string) => emit('error', { message: msg, context: { op: 'recents' } })"
|
|
7298
7360
|
/>
|
|
7299
7361
|
</div>
|
|
@@ -13,6 +13,10 @@ interface RecentNode {
|
|
|
13
13
|
name: string;
|
|
14
14
|
mime?: string;
|
|
15
15
|
last_opened?: string;
|
|
16
|
+
/** The raw row carries more (`storage`, `perm`, `read_only`, `type`,
|
|
17
|
+
* the mtimes…) — the explorer turns it into a FileNode for the context
|
|
18
|
+
* menu with the same converter Recent / Starred / a tag view use. */
|
|
19
|
+
[k: string]: unknown;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
const props = defineProps<{
|
|
@@ -30,6 +34,11 @@ const props = defineProps<{
|
|
|
30
34
|
|
|
31
35
|
const emit = defineEmits<{
|
|
32
36
|
(e: 'open', node: RecentNode): void;
|
|
37
|
+
/** Right-click on a row. The tray does not build a menu of its own — the
|
|
38
|
+
* explorer opens the ONE menu every listing row gets (owner: "CONTEXT
|
|
39
|
+
* MENÜ HER YERDE AYNI OLMALI"), so a file here offers exactly what the
|
|
40
|
+
* same file offers in its folder. */
|
|
41
|
+
(e: 'context', node: RecentNode, ev: MouseEvent): void;
|
|
33
42
|
(e: 'error', message: string): void;
|
|
34
43
|
}>();
|
|
35
44
|
|
|
@@ -101,8 +110,13 @@ watch(() => props.refreshKey, load);
|
|
|
101
110
|
</header>
|
|
102
111
|
|
|
103
112
|
<ul v-if="items.length">
|
|
104
|
-
<li v-for="n in items" :key="n.id">
|
|
105
|
-
<button
|
|
113
|
+
<li v-for="n in items" :key="n.id" data-testid="recent-tray-item" :data-fe-path="n.path">
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
class="filex-recent-item"
|
|
117
|
+
@click="emit('open', n)"
|
|
118
|
+
@contextmenu.prevent="emit('context', n, $event)"
|
|
119
|
+
>
|
|
106
120
|
<span class="filex-recent-name">{{ n.name }}</span>
|
|
107
121
|
<span class="filex-recent-meta">{{ fmtTime(n.last_opened) }}</span>
|
|
108
122
|
</button>
|
|
@@ -22,6 +22,7 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
|
|
|
22
22
|
import type { S3AccessKey } from '../types/S3Keys';
|
|
23
23
|
import { useLocale } from '../composables/useLocale';
|
|
24
24
|
import { useS3Keys } from '../composables/useS3Keys';
|
|
25
|
+
import { useScrolledX } from '../composables/useScrolledX';
|
|
25
26
|
import { resolveLocale } from '../locales/resolve';
|
|
26
27
|
|
|
27
28
|
const props = defineProps<{
|
|
@@ -37,6 +38,7 @@ const emit = defineEmits<{
|
|
|
37
38
|
|
|
38
39
|
const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
|
|
39
40
|
const { t, formatDate } = useLocale(locale);
|
|
41
|
+
const { scrolledX, onScroll } = useScrolledX();
|
|
40
42
|
|
|
41
43
|
const {
|
|
42
44
|
keys,
|
|
@@ -231,33 +233,35 @@ function scopeOf(k: S3AccessKey): string {
|
|
|
231
233
|
|
|
232
234
|
<!-- ── the keys ─────────────────────────────────────────────── -->
|
|
233
235
|
<p v-if="loading" class="fe-s3keys__muted">…</p>
|
|
234
|
-
<
|
|
235
|
-
<
|
|
236
|
-
<
|
|
237
|
-
<
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
<
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
<
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
236
|
+
<div v-else-if="keys.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
|
|
237
|
+
<table class="fe-s3keys__table">
|
|
238
|
+
<thead>
|
|
239
|
+
<tr>
|
|
240
|
+
<th>{{ t('conn.s3keys.col.label') }}</th>
|
|
241
|
+
<th>{{ t('conn.s3keys.col.key') }}</th>
|
|
242
|
+
<th>{{ t('conn.s3keys.col.scope') }}</th>
|
|
243
|
+
<th>{{ t('conn.s3keys.col.lastUsed') }}</th>
|
|
244
|
+
<th></th>
|
|
245
|
+
</tr>
|
|
246
|
+
</thead>
|
|
247
|
+
<tbody>
|
|
248
|
+
<tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
|
|
249
|
+
<td>{{ k.label || t('conn.s3keys.noLabel') }}</td>
|
|
250
|
+
<td><code>{{ k.access_key_id }}</code></td>
|
|
251
|
+
<td>{{ scopeOf(k) }}</td>
|
|
252
|
+
<td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.s3keys.neverUsed') }}</td>
|
|
253
|
+
<td class="fe-s3keys__actions">
|
|
254
|
+
<button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
|
|
255
|
+
{{ k.disabled_at ? t('conn.s3keys.enable') : t('conn.s3keys.disable') }}
|
|
256
|
+
</button>
|
|
257
|
+
<button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(k)">
|
|
258
|
+
{{ confirmRevoke === k.id ? t('conn.s3keys.confirm') : t('conn.s3keys.revoke') }}
|
|
259
|
+
</button>
|
|
260
|
+
</td>
|
|
261
|
+
</tr>
|
|
262
|
+
</tbody>
|
|
263
|
+
</table>
|
|
264
|
+
</div>
|
|
261
265
|
<p v-else-if="canMint" class="fe-s3keys__muted">{{ t('conn.s3keys.empty') }}</p>
|
|
262
266
|
</section>
|
|
263
267
|
</template>
|
|
@@ -389,4 +393,6 @@ function scopeOf(k: S3AccessKey): string {
|
|
|
389
393
|
white-space: nowrap;
|
|
390
394
|
text-align: right;
|
|
391
395
|
}
|
|
396
|
+
/* Its pinning (sticky right, ground, scroll divider) is in styles/base.css
|
|
397
|
+
next to the list view's `.fe-list__col--menu`, so the two stay one model. */
|
|
392
398
|
</style>
|
|
@@ -18,6 +18,7 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
|
|
|
18
18
|
import type { FTPSFacts, SSHPublicKey } from '../types/SSHKeys';
|
|
19
19
|
import { useLocale } from '../composables/useLocale';
|
|
20
20
|
import { useSSHKeys } from '../composables/useSSHKeys';
|
|
21
|
+
import { useScrolledX } from '../composables/useScrolledX';
|
|
21
22
|
import { resolveLocale } from '../locales/resolve';
|
|
22
23
|
|
|
23
24
|
const props = defineProps<{
|
|
@@ -50,6 +51,7 @@ const emit = defineEmits<{
|
|
|
50
51
|
|
|
51
52
|
const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
|
|
52
53
|
const { t, formatDate } = useLocale(locale);
|
|
54
|
+
const { scrolledX, onScroll } = useScrolledX();
|
|
53
55
|
|
|
54
56
|
const { keys, connection, loading, error, canAdd, hasUsableKey, load, add, setDisabled, remove } =
|
|
55
57
|
useSSHKeys(props.config);
|
|
@@ -171,31 +173,33 @@ function fingerprintOf(k: SSHPublicKey): string {
|
|
|
171
173
|
</div>
|
|
172
174
|
|
|
173
175
|
<p v-if="loading" class="fe-s3keys__muted">…</p>
|
|
174
|
-
<
|
|
175
|
-
<
|
|
176
|
-
<
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
<
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
<
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
176
|
+
<div v-else-if="keys.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
|
|
177
|
+
<table class="fe-s3keys__table">
|
|
178
|
+
<thead>
|
|
179
|
+
<tr>
|
|
180
|
+
<th>{{ t('conn.sshkeys.col.name') }}</th>
|
|
181
|
+
<th>{{ t('conn.sshkeys.col.fingerprint') }}</th>
|
|
182
|
+
<th>{{ t('conn.sshkeys.col.lastUsed') }}</th>
|
|
183
|
+
<th></th>
|
|
184
|
+
</tr>
|
|
185
|
+
</thead>
|
|
186
|
+
<tbody>
|
|
187
|
+
<tr v-for="k in keys" :key="k.id" :class="{ 'is-off': !!k.disabled_at }">
|
|
188
|
+
<td>{{ k.name || t('conn.sshkeys.noName') }}</td>
|
|
189
|
+
<td><code>{{ fingerprintOf(k) }}</code></td>
|
|
190
|
+
<td>{{ k.last_used_at ? shortDate(k.last_used_at) : t('conn.sshkeys.neverUsed') }}</td>
|
|
191
|
+
<td class="fe-s3keys__actions">
|
|
192
|
+
<button class="fe-s3keys__link" :disabled="busy" @click="toggle(k)">
|
|
193
|
+
{{ k.disabled_at ? t('conn.sshkeys.enable') : t('conn.sshkeys.disable') }}
|
|
194
|
+
</button>
|
|
195
|
+
<button class="fe-s3keys__link is-danger" :disabled="busy" @click="drop(k)">
|
|
196
|
+
{{ confirmRemove === k.id ? t('conn.sshkeys.confirm') : t('conn.sshkeys.remove') }}
|
|
197
|
+
</button>
|
|
198
|
+
</td>
|
|
199
|
+
</tr>
|
|
200
|
+
</tbody>
|
|
201
|
+
</table>
|
|
202
|
+
</div>
|
|
199
203
|
<p v-else-if="canAdd" class="fe-s3keys__muted">{{ t('conn.sshkeys.empty') }}</p>
|
|
200
204
|
</section>
|
|
201
205
|
</template>
|
|
@@ -30,6 +30,7 @@ import type { ExplorerConfig, LocaleCode } from '../types/ExplorerConfig';
|
|
|
30
30
|
import type { ApiToken } from '../types/Tokens';
|
|
31
31
|
import { useLocale } from '../composables/useLocale';
|
|
32
32
|
import { useTokens } from '../composables/useTokens';
|
|
33
|
+
import { useScrolledX } from '../composables/useScrolledX';
|
|
33
34
|
import { resolveLocale } from '../locales/resolve';
|
|
34
35
|
|
|
35
36
|
const props = defineProps<{
|
|
@@ -61,6 +62,7 @@ const emit = defineEmits<{
|
|
|
61
62
|
|
|
62
63
|
const locale = computed<LocaleCode>(() => resolveLocale(props.config.locale));
|
|
63
64
|
const { t, formatDate } = useLocale(locale);
|
|
65
|
+
const { scrolledX, onScroll } = useScrolledX();
|
|
64
66
|
|
|
65
67
|
const { tokens, loading, error, canMint, revealed, load, create, remove, dismiss } = useTokens(
|
|
66
68
|
props.config,
|
|
@@ -288,28 +290,30 @@ function usedLabel(row: ApiToken): string {
|
|
|
288
290
|
</div>
|
|
289
291
|
|
|
290
292
|
<p v-if="loading" class="fe-s3keys__muted">…</p>
|
|
291
|
-
<
|
|
292
|
-
<
|
|
293
|
-
<
|
|
294
|
-
<
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
<
|
|
302
|
-
<
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
<
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
293
|
+
<div v-else-if="tokens.length" class="fe-s3keys__scroll" :class="{ 'is-scrolled-x': scrolledX }" @scroll.passive="onScroll">
|
|
294
|
+
<table class="fe-s3keys__table">
|
|
295
|
+
<thead>
|
|
296
|
+
<tr>
|
|
297
|
+
<th>{{ t('conn.tokens.col.label') }}</th>
|
|
298
|
+
<th>{{ t('conn.tokens.col.scopes') }}</th>
|
|
299
|
+
<th>{{ t('conn.tokens.col.used') }}</th>
|
|
300
|
+
<th></th>
|
|
301
|
+
</tr>
|
|
302
|
+
</thead>
|
|
303
|
+
<tbody>
|
|
304
|
+
<tr v-for="row in tokens" :key="row.id">
|
|
305
|
+
<td>{{ row.label || '—' }}</td>
|
|
306
|
+
<td><code>{{ row.scopes }}</code></td>
|
|
307
|
+
<td>{{ usedLabel(row) }}</td>
|
|
308
|
+
<td class="fe-s3keys__actions">
|
|
309
|
+
<button class="fe-s3keys__link is-danger" :disabled="busy" @click="revoke(row)">
|
|
310
|
+
{{ confirming === row.id ? t('conn.tokens.confirm') : t('conn.tokens.revoke') }}
|
|
311
|
+
</button>
|
|
312
|
+
</td>
|
|
313
|
+
</tr>
|
|
314
|
+
</tbody>
|
|
315
|
+
</table>
|
|
316
|
+
</div>
|
|
313
317
|
<p v-else-if="canMint" class="fe-s3keys__muted">{{ t('conn.tokens.empty') }}</p>
|
|
314
318
|
|
|
315
319
|
<!-- ⚠ Said next to the button rather than in a document nobody opens: a
|