@brftech/filex-core 0.3.0 → 0.4.0
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-CYrKPEbE.js → ArchiveViewer-Dgvl43gn.js} +2 -2
- package/dist/{ArchiveViewer-CYrKPEbE.js.map → ArchiveViewer-Dgvl43gn.js.map} +1 -1
- package/dist/{CsvViewer-C3ZU4Uua.js → CsvViewer-gWO5pc8F.js} +2 -2
- package/dist/{CsvViewer-C3ZU4Uua.js.map → CsvViewer-gWO5pc8F.js.map} +1 -1
- package/dist/{DrawioViewer-15ZL4ken.js → DrawioViewer-DUAlRIAb.js} +2 -2
- package/dist/{DrawioViewer-15ZL4ken.js.map → DrawioViewer-DUAlRIAb.js.map} +1 -1
- package/dist/{EpubViewer-CrXcDgmo.js → EpubViewer-DIXzuQWK.js} +2 -2
- package/dist/{EpubViewer-CrXcDgmo.js.map → EpubViewer-DIXzuQWK.js.map} +1 -1
- package/dist/{IpynbViewer-BAb9rV2Q.js → IpynbViewer-CJ-6pU_Z.js} +2 -2
- package/dist/{IpynbViewer-BAb9rV2Q.js.map → IpynbViewer-CJ-6pU_Z.js.map} +1 -1
- package/dist/{MermaidViewer-BN8GN4FY.js → MermaidViewer-CyQQDyRb.js} +2 -2
- package/dist/{MermaidViewer-BN8GN4FY.js.map → MermaidViewer-CyQQDyRb.js.map} +1 -1
- package/dist/{PsdViewer-o-kWQExw.js → PsdViewer-ChfaXVgN.js} +2 -2
- package/dist/{PsdViewer-o-kWQExw.js.map → PsdViewer-ChfaXVgN.js.map} +1 -1
- package/dist/{TiffViewer-AjMVBzxp.js → TiffViewer-CsfD-NG6.js} +2 -2
- package/dist/{TiffViewer-AjMVBzxp.js.map → TiffViewer-CsfD-NG6.js.map} +1 -1
- package/dist/{Viewer3D-BR3WDbxQ.js → Viewer3D-BQ2XYmkN.js} +2 -2
- package/dist/{Viewer3D-BR3WDbxQ.js.map → Viewer3D-BQ2XYmkN.js.map} +1 -1
- package/dist/filex-core.js +1 -1
- package/dist/filex-core.umd.cjs +39 -39
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-PnhGunSA.js +7972 -0
- package/dist/index-PnhGunSA.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +84 -0
- package/src/components/InspectorPanel.vue +455 -0
- package/src/components/Toolbar.vue +34 -0
- package/src/composables/useFileApi.ts +59 -0
- package/src/composables/useKeyboardShortcuts.ts +11 -0
- package/src/locales/en.ts +39 -0
- package/src/locales/tr.ts +39 -0
- package/src/styles/base.css +321 -0
- package/dist/index-BG5upYlH.js +0 -7392
- package/dist/index-BG5upYlH.js.map +0 -1
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* InspectorPanel — koru:k1 details ("Ayrıntılar") side panel.
|
|
4
|
+
*
|
|
5
|
+
* Rendered as a flex sibling of `.fe__body` (right side, ~300px). In
|
|
6
|
+
* `fe--narrow` embeds it becomes a full-size overlay with its own close
|
|
7
|
+
* button (Esc handled by FileExplorer's shortcut chain).
|
|
8
|
+
*
|
|
9
|
+
* Sections:
|
|
10
|
+
* - Genel : icon/thumb, name, path (copy), size, modified, mime, etag.
|
|
11
|
+
* Multi-select → "N items, total X" summary; no selection →
|
|
12
|
+
* current-folder summary.
|
|
13
|
+
* - Sürümler : version history via GET /api/files/versions?node_id=…
|
|
14
|
+
* + restore (with optional snapshot-current) + snapshot-now.
|
|
15
|
+
* The section hides silently when the backend gates the
|
|
16
|
+
* endpoint (401/403) or doesn't ship it (404).
|
|
17
|
+
* - İzinler : effective RBAC level badge + "manage" (opens the existing
|
|
18
|
+
* PermissionsModal through the host). Only when ACL is
|
|
19
|
+
* enforced on the storage (perm is a non-empty string).
|
|
20
|
+
* - Paylaşımlar: existing share links (GET /api/files/share?path=…) with
|
|
21
|
+
* copy buttons. Hidden silently when the list call fails
|
|
22
|
+
* (viewer-level users get 403 by design).
|
|
23
|
+
*
|
|
24
|
+
* The panel is mounted with v-if by the host — closed state leaves zero DOM.
|
|
25
|
+
*/
|
|
26
|
+
import { computed, ref, watch } from 'vue';
|
|
27
|
+
import type { FileApi, NodeVersion } from '../composables/useFileApi';
|
|
28
|
+
import type { FileNode, ShareInfo } from '../types/FileNode';
|
|
29
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
30
|
+
import { useLocale } from '../composables/useLocale';
|
|
31
|
+
import { fileIconSvg } from '../lib/fileIcons';
|
|
32
|
+
|
|
33
|
+
const props = defineProps<{
|
|
34
|
+
api: FileApi;
|
|
35
|
+
/** Current selection (empty array → current-folder summary). */
|
|
36
|
+
nodes: FileNode[];
|
|
37
|
+
/** Display label of the folder being viewed (for the no-selection state). */
|
|
38
|
+
dirLabel: string;
|
|
39
|
+
/** Number of entries in the folder being viewed. */
|
|
40
|
+
dirCount: number;
|
|
41
|
+
/** RBAC effective level of the current dir ('' = ACL not enforced). */
|
|
42
|
+
dirPerm?: string;
|
|
43
|
+
locale: LocaleCode;
|
|
44
|
+
/** Narrow/embed mode → full-size overlay presentation. */
|
|
45
|
+
narrow?: boolean;
|
|
46
|
+
/** Authenticated thumbnail resolver (useThumbs.src). Optional. */
|
|
47
|
+
thumbSrc?: (n: FileNode) => string | null;
|
|
48
|
+
}>();
|
|
49
|
+
|
|
50
|
+
const emit = defineEmits<{
|
|
51
|
+
(e: 'close'): void;
|
|
52
|
+
(e: 'manage-permissions', node: FileNode): void;
|
|
53
|
+
(e: 'toast', message: string): void;
|
|
54
|
+
/** Fired after a successful restore/snapshot so the host can reload. */
|
|
55
|
+
(e: 'changed'): void;
|
|
56
|
+
}>();
|
|
57
|
+
|
|
58
|
+
const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
|
|
59
|
+
|
|
60
|
+
// ── selection shape ──────────────────────────────────────────────────
|
|
61
|
+
const single = computed<FileNode | null>(() =>
|
|
62
|
+
props.nodes.length === 1 ? props.nodes[0] : null,
|
|
63
|
+
);
|
|
64
|
+
const isMulti = computed(() => props.nodes.length > 1);
|
|
65
|
+
const isFile = computed(() => single.value?.type === 'file');
|
|
66
|
+
const nodeId = computed<number | null>(() =>
|
|
67
|
+
typeof single.value?.id === 'number' ? (single.value.id as number) : null,
|
|
68
|
+
);
|
|
69
|
+
const multiTotal = computed(() =>
|
|
70
|
+
props.nodes.reduce((acc, n) => acc + (typeof n.size === 'number' ? n.size : 0), 0),
|
|
71
|
+
);
|
|
72
|
+
const etag = computed<string | null>(() => {
|
|
73
|
+
const v = single.value?.etag;
|
|
74
|
+
return typeof v === 'string' && v !== '' ? v : null;
|
|
75
|
+
});
|
|
76
|
+
const thumb = computed<string | null>(() =>
|
|
77
|
+
single.value && isFile.value && props.thumbSrc ? props.thumbSrc(single.value) : null,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
function shortHash(h: string): string {
|
|
81
|
+
return h.length > 12 ? `${h.slice(0, 12)}…` : h;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function formatDate(ms: number | undefined): string {
|
|
85
|
+
if (!ms) return '—';
|
|
86
|
+
try {
|
|
87
|
+
return new Date(ms).toLocaleString(props.locale === 'en' ? 'en-GB' : 'tr-TR', {
|
|
88
|
+
dateStyle: 'medium',
|
|
89
|
+
timeStyle: 'short',
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
return new Date(ms).toISOString();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatDateStr(s: string | undefined | null): string {
|
|
97
|
+
if (!s) return '—';
|
|
98
|
+
const ms = Date.parse(s);
|
|
99
|
+
return Number.isNaN(ms) ? s : formatDate(ms);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function copyText(text: string): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
await navigator.clipboard.writeText(text);
|
|
105
|
+
emit('toast', t('inspector.copied'));
|
|
106
|
+
} catch {
|
|
107
|
+
emit('toast', text);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── RBAC (İzinler) ───────────────────────────────────────────────────
|
|
112
|
+
// Effective level for the selected item: its own perm, else the dir's.
|
|
113
|
+
// A non-empty string means ACL is enforced → section shows.
|
|
114
|
+
const effectivePerm = computed<string>(() => {
|
|
115
|
+
if (!single.value) return '';
|
|
116
|
+
// Backends may send '' when ACL is off — widen past the declared union.
|
|
117
|
+
const own = single.value.perm as string | undefined;
|
|
118
|
+
if (typeof own === 'string' && own !== '') return own;
|
|
119
|
+
return props.dirPerm || '';
|
|
120
|
+
});
|
|
121
|
+
const canManagePerms = computed(
|
|
122
|
+
() => effectivePerm.value === 'editor' || effectivePerm.value === 'owner',
|
|
123
|
+
);
|
|
124
|
+
function permLabel(level: string): string {
|
|
125
|
+
return t(`inspector.perm.${level}`) === `inspector.perm.${level}`
|
|
126
|
+
? level
|
|
127
|
+
: t(`inspector.perm.${level}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── versions state ───────────────────────────────────────────────────
|
|
131
|
+
type SectionState = 'idle' | 'loading' | 'ok' | 'hidden' | 'error';
|
|
132
|
+
const versionsState = ref<SectionState>('idle');
|
|
133
|
+
const versions = ref<NodeVersion[]>([]);
|
|
134
|
+
const confirmVersionId = ref<number | null>(null);
|
|
135
|
+
const snapshotFirst = ref(true);
|
|
136
|
+
const versionBusy = ref(false);
|
|
137
|
+
|
|
138
|
+
// ── shares state ─────────────────────────────────────────────────────
|
|
139
|
+
const sharesState = ref<SectionState>('idle');
|
|
140
|
+
const shares = ref<ShareInfo[]>([]);
|
|
141
|
+
|
|
142
|
+
// Race guard: only the latest refresh may write state.
|
|
143
|
+
let refreshSeq = 0;
|
|
144
|
+
|
|
145
|
+
async function refresh(): Promise<void> {
|
|
146
|
+
const seq = ++refreshSeq;
|
|
147
|
+
confirmVersionId.value = null;
|
|
148
|
+
versions.value = [];
|
|
149
|
+
shares.value = [];
|
|
150
|
+
|
|
151
|
+
// Versions — single file with a backend node id only.
|
|
152
|
+
if (single.value && isFile.value && nodeId.value != null) {
|
|
153
|
+
versionsState.value = 'loading';
|
|
154
|
+
void loadVersions(seq);
|
|
155
|
+
} else {
|
|
156
|
+
versionsState.value = 'hidden';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Shares — any single selection (files and folders both shareable).
|
|
160
|
+
if (single.value) {
|
|
161
|
+
sharesState.value = 'loading';
|
|
162
|
+
const path = single.value.path;
|
|
163
|
+
try {
|
|
164
|
+
const { shares: list } = await props.api.listShares(path);
|
|
165
|
+
if (seq !== refreshSeq) return;
|
|
166
|
+
shares.value = Array.isArray(list) ? list : [];
|
|
167
|
+
sharesState.value = 'ok';
|
|
168
|
+
} catch {
|
|
169
|
+
if (seq !== refreshSeq) return;
|
|
170
|
+
// 403 = viewer-level user (by design), anything else — auxiliary
|
|
171
|
+
// info, hide silently rather than alarm.
|
|
172
|
+
sharesState.value = 'hidden';
|
|
173
|
+
}
|
|
174
|
+
} else {
|
|
175
|
+
sharesState.value = 'hidden';
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function loadVersions(seq: number): Promise<void> {
|
|
180
|
+
const id = nodeId.value;
|
|
181
|
+
if (id == null) return;
|
|
182
|
+
try {
|
|
183
|
+
const list = await props.api.listVersions(id);
|
|
184
|
+
if (seq !== refreshSeq) return;
|
|
185
|
+
versions.value = list;
|
|
186
|
+
versionsState.value = 'ok';
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (seq !== refreshSeq) return;
|
|
189
|
+
const status = (err as { status?: number }).status;
|
|
190
|
+
// Gated (401/403) or absent (404) endpoint → section silently hidden.
|
|
191
|
+
if (status === 401 || status === 403 || status === 404) {
|
|
192
|
+
versionsState.value = 'hidden';
|
|
193
|
+
} else {
|
|
194
|
+
versionsState.value = 'error';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function askRestore(v: NodeVersion): void {
|
|
200
|
+
confirmVersionId.value = v.id;
|
|
201
|
+
snapshotFirst.value = true;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function confirmRestore(v: NodeVersion): Promise<void> {
|
|
205
|
+
const id = nodeId.value;
|
|
206
|
+
if (id == null || versionBusy.value) return;
|
|
207
|
+
versionBusy.value = true;
|
|
208
|
+
try {
|
|
209
|
+
await props.api.restoreVersion(id, v.id, snapshotFirst.value);
|
|
210
|
+
emit('toast', t('inspector.versions.restored'));
|
|
211
|
+
confirmVersionId.value = null;
|
|
212
|
+
emit('changed');
|
|
213
|
+
await loadVersions(++refreshSeq);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
emit('toast', (err as Error).message);
|
|
216
|
+
} finally {
|
|
217
|
+
versionBusy.value = false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function takeSnapshot(): Promise<void> {
|
|
222
|
+
const id = nodeId.value;
|
|
223
|
+
if (id == null || versionBusy.value) return;
|
|
224
|
+
versionBusy.value = true;
|
|
225
|
+
try {
|
|
226
|
+
await props.api.snapshotVersion(id);
|
|
227
|
+
emit('toast', t('inspector.versions.snapshotted'));
|
|
228
|
+
await loadVersions(++refreshSeq);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const status = (err as { status?: number }).status;
|
|
231
|
+
// Older backends don't ship POST /versions/snapshot yet.
|
|
232
|
+
if (status === 404 || status === 405 || status === 501) {
|
|
233
|
+
emit('toast', t('inspector.versions.unsupported'));
|
|
234
|
+
} else {
|
|
235
|
+
emit('toast', (err as Error).message);
|
|
236
|
+
}
|
|
237
|
+
} finally {
|
|
238
|
+
versionBusy.value = false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
watch(
|
|
243
|
+
() => props.nodes.map((n) => n.path).join(''),
|
|
244
|
+
() => void refresh(),
|
|
245
|
+
{ immediate: true },
|
|
246
|
+
);
|
|
247
|
+
</script>
|
|
248
|
+
|
|
249
|
+
<template>
|
|
250
|
+
<aside
|
|
251
|
+
class="fe-inspector"
|
|
252
|
+
:class="{ 'fe-inspector--overlay': narrow }"
|
|
253
|
+
role="complementary"
|
|
254
|
+
:aria-label="t('inspector.title')"
|
|
255
|
+
>
|
|
256
|
+
<header class="fe-inspector__head">
|
|
257
|
+
<h2 class="fe-inspector__title">{{ t('inspector.title') }}</h2>
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
class="fe-inspector__close"
|
|
261
|
+
:title="t('inspector.close')"
|
|
262
|
+
:aria-label="t('inspector.close')"
|
|
263
|
+
@click="emit('close')"
|
|
264
|
+
>×</button>
|
|
265
|
+
</header>
|
|
266
|
+
|
|
267
|
+
<div class="fe-inspector__scroll">
|
|
268
|
+
<!-- ══ Genel ══ -->
|
|
269
|
+
<section class="fe-inspector__section">
|
|
270
|
+
<h3 class="fe-inspector__heading">{{ t('inspector.section.general') }}</h3>
|
|
271
|
+
|
|
272
|
+
<!-- Multi selection → summary -->
|
|
273
|
+
<div v-if="isMulti" class="fe-inspector__hero">
|
|
274
|
+
<span class="fe-inspector__bigicon" v-html="fileIconSvg({ type: 'dir' })"></span>
|
|
275
|
+
<p class="fe-inspector__name">
|
|
276
|
+
{{ t('inspector.items_summary', { n: nodes.length, size: formatSize(multiTotal) }) }}
|
|
277
|
+
</p>
|
|
278
|
+
</div>
|
|
279
|
+
|
|
280
|
+
<!-- Single selection → full meta -->
|
|
281
|
+
<template v-else-if="single">
|
|
282
|
+
<div class="fe-inspector__hero">
|
|
283
|
+
<img
|
|
284
|
+
v-if="thumb"
|
|
285
|
+
class="fe-inspector__thumb"
|
|
286
|
+
:src="thumb"
|
|
287
|
+
alt=""
|
|
288
|
+
aria-hidden="true"
|
|
289
|
+
/>
|
|
290
|
+
<span
|
|
291
|
+
v-else
|
|
292
|
+
class="fe-inspector__bigicon"
|
|
293
|
+
v-html="fileIconSvg(single)"
|
|
294
|
+
></span>
|
|
295
|
+
<p class="fe-inspector__name" :title="single.basename">
|
|
296
|
+
{{ nodeDisplayName(single) }}
|
|
297
|
+
</p>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
<dl class="fe-inspector__meta">
|
|
301
|
+
<div class="fe-inspector__row">
|
|
302
|
+
<dt>{{ t('inspector.path') }}</dt>
|
|
303
|
+
<dd class="fe-inspector__pathcell">
|
|
304
|
+
<span class="fe-inspector__path" :title="single.path">{{ single.path }}</span>
|
|
305
|
+
<button
|
|
306
|
+
type="button"
|
|
307
|
+
class="fe-inspector__copy"
|
|
308
|
+
:title="t('inspector.copy')"
|
|
309
|
+
:aria-label="t('inspector.copy')"
|
|
310
|
+
@click="copyText(single.path)"
|
|
311
|
+
>⧉</button>
|
|
312
|
+
</dd>
|
|
313
|
+
</div>
|
|
314
|
+
<div v-if="isFile" class="fe-inspector__row">
|
|
315
|
+
<dt>{{ t('inspector.size') }}</dt>
|
|
316
|
+
<dd>{{ formatSize(typeof single.size === 'number' ? single.size : null) }}</dd>
|
|
317
|
+
</div>
|
|
318
|
+
<div class="fe-inspector__row">
|
|
319
|
+
<dt>{{ t('inspector.modified') }}</dt>
|
|
320
|
+
<dd>{{ formatDate(single.last_modified) }}</dd>
|
|
321
|
+
</div>
|
|
322
|
+
<div v-if="isFile && single.mime_type" class="fe-inspector__row">
|
|
323
|
+
<dt>{{ t('inspector.mime') }}</dt>
|
|
324
|
+
<dd class="fe-inspector__mime">{{ single.mime_type }}</dd>
|
|
325
|
+
</div>
|
|
326
|
+
<div v-if="etag" class="fe-inspector__row">
|
|
327
|
+
<dt>{{ t('inspector.etag') }}</dt>
|
|
328
|
+
<dd class="fe-inspector__pathcell">
|
|
329
|
+
<span class="fe-inspector__path" :title="etag">{{ shortHash(etag) }}</span>
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
class="fe-inspector__copy"
|
|
333
|
+
:title="t('inspector.copy')"
|
|
334
|
+
:aria-label="t('inspector.copy')"
|
|
335
|
+
@click="copyText(etag)"
|
|
336
|
+
>⧉</button>
|
|
337
|
+
</dd>
|
|
338
|
+
</div>
|
|
339
|
+
</dl>
|
|
340
|
+
</template>
|
|
341
|
+
|
|
342
|
+
<!-- No selection → current folder summary -->
|
|
343
|
+
<div v-else class="fe-inspector__hero">
|
|
344
|
+
<span class="fe-inspector__bigicon" v-html="fileIconSvg({ type: 'dir' })"></span>
|
|
345
|
+
<p class="fe-inspector__name" :title="dirLabel">{{ dirLabel }}</p>
|
|
346
|
+
<p class="fe-inspector__sub">{{ t('inspector.folder_items', { n: dirCount }) }}</p>
|
|
347
|
+
</div>
|
|
348
|
+
</section>
|
|
349
|
+
|
|
350
|
+
<!-- ══ Sürümler ══ -->
|
|
351
|
+
<section
|
|
352
|
+
v-if="versionsState === 'ok' || versionsState === 'error'"
|
|
353
|
+
class="fe-inspector__section"
|
|
354
|
+
>
|
|
355
|
+
<h3 class="fe-inspector__heading">{{ t('inspector.section.versions') }}</h3>
|
|
356
|
+
|
|
357
|
+
<p v-if="versionsState === 'error'" class="fe-inspector__empty">
|
|
358
|
+
{{ t('inspector.error') }}
|
|
359
|
+
</p>
|
|
360
|
+
<template v-else>
|
|
361
|
+
<p v-if="versions.length === 0" class="fe-inspector__empty">
|
|
362
|
+
{{ t('inspector.versions.empty') }}
|
|
363
|
+
</p>
|
|
364
|
+
<ul v-else class="fe-inspector__versions">
|
|
365
|
+
<li v-for="v in versions" :key="v.id" class="fe-inspector__version">
|
|
366
|
+
<div class="fe-inspector__version-main">
|
|
367
|
+
<span class="fe-inspector__version-n">
|
|
368
|
+
{{ t('inspector.versions.v', { n: v.version_n }) }}
|
|
369
|
+
</span>
|
|
370
|
+
<span class="fe-inspector__version-meta">
|
|
371
|
+
{{ formatDateStr(v.created_at) }} · {{ formatSize(v.size) }}
|
|
372
|
+
</span>
|
|
373
|
+
</div>
|
|
374
|
+
<div v-if="confirmVersionId === v.id" class="fe-inspector__confirm">
|
|
375
|
+
<p class="fe-inspector__confirm-q">{{ t('inspector.versions.restore_confirm') }}</p>
|
|
376
|
+
<label class="fe-inspector__check">
|
|
377
|
+
<input v-model="snapshotFirst" type="checkbox" />
|
|
378
|
+
{{ t('inspector.versions.snapshot_current') }}
|
|
379
|
+
</label>
|
|
380
|
+
<div class="fe-inspector__confirm-actions">
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
class="fe-btn fe-btn--primary fe-btn--sm"
|
|
384
|
+
:disabled="versionBusy"
|
|
385
|
+
@click="confirmRestore(v)"
|
|
386
|
+
>{{ t('inspector.versions.confirm') }}</button>
|
|
387
|
+
<button
|
|
388
|
+
type="button"
|
|
389
|
+
class="fe-btn fe-btn--sm"
|
|
390
|
+
:disabled="versionBusy"
|
|
391
|
+
@click="confirmVersionId = null"
|
|
392
|
+
>{{ t('inspector.versions.cancel') }}</button>
|
|
393
|
+
</div>
|
|
394
|
+
</div>
|
|
395
|
+
<button
|
|
396
|
+
v-else
|
|
397
|
+
type="button"
|
|
398
|
+
class="fe-btn fe-btn--sm"
|
|
399
|
+
:disabled="versionBusy"
|
|
400
|
+
@click="askRestore(v)"
|
|
401
|
+
>{{ t('inspector.versions.restore') }}</button>
|
|
402
|
+
</li>
|
|
403
|
+
</ul>
|
|
404
|
+
<button
|
|
405
|
+
type="button"
|
|
406
|
+
class="fe-btn fe-btn--sm fe-inspector__snapshot"
|
|
407
|
+
:disabled="versionBusy"
|
|
408
|
+
@click="takeSnapshot"
|
|
409
|
+
>{{ t('inspector.versions.take_snapshot') }}</button>
|
|
410
|
+
</template>
|
|
411
|
+
</section>
|
|
412
|
+
|
|
413
|
+
<!-- ══ İzinler ══ -->
|
|
414
|
+
<section v-if="single && effectivePerm" class="fe-inspector__section">
|
|
415
|
+
<h3 class="fe-inspector__heading">{{ t('inspector.section.permissions') }}</h3>
|
|
416
|
+
<div class="fe-inspector__permrow">
|
|
417
|
+
<span
|
|
418
|
+
class="fe-inspector__badge"
|
|
419
|
+
:class="`fe-inspector__badge--${effectivePerm}`"
|
|
420
|
+
>{{ permLabel(effectivePerm) }}</span>
|
|
421
|
+
<button
|
|
422
|
+
v-if="canManagePerms"
|
|
423
|
+
type="button"
|
|
424
|
+
class="fe-btn fe-btn--sm"
|
|
425
|
+
@click="emit('manage-permissions', single)"
|
|
426
|
+
>{{ t('inspector.perm.manage') }}</button>
|
|
427
|
+
</div>
|
|
428
|
+
</section>
|
|
429
|
+
|
|
430
|
+
<!-- ══ Paylaşımlar ══ -->
|
|
431
|
+
<section v-if="sharesState === 'ok'" class="fe-inspector__section">
|
|
432
|
+
<h3 class="fe-inspector__heading">{{ t('inspector.section.shares') }}</h3>
|
|
433
|
+
<p v-if="shares.length === 0" class="fe-inspector__empty">
|
|
434
|
+
{{ t('inspector.shares.empty') }}
|
|
435
|
+
</p>
|
|
436
|
+
<ul v-else class="fe-inspector__shares">
|
|
437
|
+
<li v-for="s in shares" :key="s.uuid" class="fe-inspector__share">
|
|
438
|
+
<span class="fe-inspector__share-url" :title="s.url">{{ s.url }}</span>
|
|
439
|
+
<span v-if="s.password_pin" class="fe-inspector__badge fe-inspector__badge--pin">PIN</span>
|
|
440
|
+
<span v-if="s.expires_at" class="fe-inspector__share-exp">
|
|
441
|
+
{{ formatDateStr(s.expires_at) }}
|
|
442
|
+
</span>
|
|
443
|
+
<button
|
|
444
|
+
type="button"
|
|
445
|
+
class="fe-inspector__copy"
|
|
446
|
+
:title="t('inspector.shares.copy')"
|
|
447
|
+
:aria-label="t('inspector.shares.copy')"
|
|
448
|
+
@click="copyText(s.url)"
|
|
449
|
+
>⧉</button>
|
|
450
|
+
</li>
|
|
451
|
+
</ul>
|
|
452
|
+
</section>
|
|
453
|
+
</div>
|
|
454
|
+
</aside>
|
|
455
|
+
</template>
|
|
@@ -71,6 +71,8 @@ const props = defineProps<{
|
|
|
71
71
|
* (which loses the `.fe` variable scope under <body>).
|
|
72
72
|
*/
|
|
73
73
|
theme?: ThemeMode;
|
|
74
|
+
/* koru:k1 — inspector (details panel) toggle state, for the pressed style. */
|
|
75
|
+
inspectorOpen?: boolean;
|
|
74
76
|
}>();
|
|
75
77
|
|
|
76
78
|
const emit = defineEmits<{
|
|
@@ -83,6 +85,7 @@ const emit = defineEmits<{
|
|
|
83
85
|
(e: 'action', key: string): void;
|
|
84
86
|
(e: 'open-recents'): void;
|
|
85
87
|
(e: 'update:density', v: Density): void;
|
|
88
|
+
(e: 'toggle-inspector'): void /* koru:k1 */;
|
|
86
89
|
}>();
|
|
87
90
|
|
|
88
91
|
// Density toggle — the toolbar owns the persisted preference; the parent
|
|
@@ -221,6 +224,8 @@ const moreActions = computed<ContextAction[]>(() => {
|
|
|
221
224
|
? { key: 'view-grid', label: t('toolbar.view.grid'), icon: '▦' }
|
|
222
225
|
: { key: 'view-list', label: t('toolbar.view.list'), icon: '☰' },
|
|
223
226
|
);
|
|
227
|
+
/* koru:k1 — inspector toggle also reachable from the narrow overflow menu */
|
|
228
|
+
list.push({ key: 'inspector', label: t('toolbar.inspector'), icon: 'ℹ' });
|
|
224
229
|
return list;
|
|
225
230
|
});
|
|
226
231
|
|
|
@@ -246,6 +251,9 @@ function onMoreSelect(a: ContextAction) {
|
|
|
246
251
|
case 'view-grid':
|
|
247
252
|
emit('update:viewMode', 'grid');
|
|
248
253
|
break;
|
|
254
|
+
case 'inspector' /* koru:k1 */:
|
|
255
|
+
emit('toggle-inspector');
|
|
256
|
+
break;
|
|
249
257
|
default:
|
|
250
258
|
fire(a.key);
|
|
251
259
|
}
|
|
@@ -384,6 +392,32 @@ function onMoreSelect(a: ContextAction) {
|
|
|
384
392
|
</svg>
|
|
385
393
|
</button>
|
|
386
394
|
|
|
395
|
+
<!-- koru:k1 — inspector (details panel) toggle -->
|
|
396
|
+
<button
|
|
397
|
+
type="button"
|
|
398
|
+
class="fe-btn fe-btn--icon-only fe-toolbar__inspector"
|
|
399
|
+
:class="{ 'is-active': inspectorOpen }"
|
|
400
|
+
:aria-pressed="!!inspectorOpen"
|
|
401
|
+
:title="t('toolbar.inspector')"
|
|
402
|
+
:aria-label="t('toolbar.inspector')"
|
|
403
|
+
@click="emit('toggle-inspector')"
|
|
404
|
+
>
|
|
405
|
+
<svg
|
|
406
|
+
class="fe-ficon"
|
|
407
|
+
viewBox="0 0 24 24"
|
|
408
|
+
fill="none"
|
|
409
|
+
stroke="currentColor"
|
|
410
|
+
stroke-width="1.8"
|
|
411
|
+
stroke-linecap="round"
|
|
412
|
+
aria-hidden="true"
|
|
413
|
+
focusable="false"
|
|
414
|
+
>
|
|
415
|
+
<circle cx="12" cy="12" r="9" />
|
|
416
|
+
<path d="M12 11v5" />
|
|
417
|
+
<circle cx="12" cy="7.6" r="1" fill="currentColor" stroke="none" />
|
|
418
|
+
</svg>
|
|
419
|
+
</button>
|
|
420
|
+
|
|
387
421
|
<div class="fe-toolbar__view" role="tablist" aria-label="View">
|
|
388
422
|
<button
|
|
389
423
|
type="button"
|
|
@@ -89,6 +89,19 @@ export interface UserSuggestion {
|
|
|
89
89
|
display_name: string;
|
|
90
90
|
role: string;
|
|
91
91
|
}
|
|
92
|
+
|
|
93
|
+
/* === koru:k1 — version history (inspector panel) === */
|
|
94
|
+
|
|
95
|
+
/** Mirrors backend `model.NodeVersion` (GET /api/files/versions?node_id=…). */
|
|
96
|
+
export interface NodeVersion {
|
|
97
|
+
id: number;
|
|
98
|
+
node_id: number;
|
|
99
|
+
version_n: number;
|
|
100
|
+
storage_key?: string;
|
|
101
|
+
size: number;
|
|
102
|
+
etag?: string;
|
|
103
|
+
created_at: string;
|
|
104
|
+
}
|
|
92
105
|
export interface UserSearchResponse {
|
|
93
106
|
users: UserSuggestion[];
|
|
94
107
|
}
|
|
@@ -686,6 +699,48 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
686
699
|
});
|
|
687
700
|
}
|
|
688
701
|
|
|
702
|
+
/* === koru:k1 — version history (inspector panel) ===
|
|
703
|
+
* Derived from the manager endpoint by swapping `/manager` for `/versions`
|
|
704
|
+
* (same trick permissionsUrl/globalSearch use) so embedded proxies that
|
|
705
|
+
* forward the whole /api/files/* subtree keep working.
|
|
706
|
+
* GET /api/files/versions?node_id=N → {versions, node_id}
|
|
707
|
+
* POST /api/files/versions/restore → {node_id, version_id, snapshot_current}
|
|
708
|
+
* POST /api/files/versions/snapshot → {node_id} (may not exist on older backends)
|
|
709
|
+
*/
|
|
710
|
+
function versionsUrl(sub = ''): string {
|
|
711
|
+
const base = endpoints.manager.replace(/\/manager(\?.*)?$/, '/versions');
|
|
712
|
+
return base + sub;
|
|
713
|
+
}
|
|
714
|
+
async function listVersions(nodeId: number): Promise<NodeVersion[]> {
|
|
715
|
+
const data = await jsonFetch<{ versions?: NodeVersion[] | null }>(
|
|
716
|
+
versionsUrl() + '?node_id=' + encodeURIComponent(String(nodeId)),
|
|
717
|
+
);
|
|
718
|
+
return Array.isArray(data?.versions) ? data.versions : [];
|
|
719
|
+
}
|
|
720
|
+
async function restoreVersion(
|
|
721
|
+
nodeId: number,
|
|
722
|
+
versionId: number,
|
|
723
|
+
snapshotCurrent = true,
|
|
724
|
+
): Promise<void> {
|
|
725
|
+
await jsonFetch(versionsUrl('/restore'), {
|
|
726
|
+
method: 'POST',
|
|
727
|
+
headers: { 'Content-Type': 'application/json' },
|
|
728
|
+
body: JSON.stringify({
|
|
729
|
+
node_id: nodeId,
|
|
730
|
+
version_id: versionId,
|
|
731
|
+
snapshot_current: snapshotCurrent,
|
|
732
|
+
}),
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
async function snapshotVersion(nodeId: number): Promise<void> {
|
|
736
|
+
await jsonFetch(versionsUrl('/snapshot'), {
|
|
737
|
+
method: 'POST',
|
|
738
|
+
headers: { 'Content-Type': 'application/json' },
|
|
739
|
+
body: JSON.stringify({ node_id: nodeId }),
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
/* === /koru:k1 === */
|
|
743
|
+
|
|
689
744
|
// Mint a short-lived WebSocket auth ticket for the realtime layer. Derived
|
|
690
745
|
// from the manager URL (so it flows through the same host proxy) and uses the
|
|
691
746
|
// same auth/creds as every other call. Returns null on any failure (a backend
|
|
@@ -731,6 +786,10 @@ export function useFileApi(config: ExplorerConfig) {
|
|
|
731
786
|
archiveList,
|
|
732
787
|
archiveExtract,
|
|
733
788
|
archiveAdd,
|
|
789
|
+
// Version history (koru:k1 inspector)
|
|
790
|
+
listVersions,
|
|
791
|
+
restoreVersion,
|
|
792
|
+
snapshotVersion,
|
|
734
793
|
// Permissions (RBAC panel)
|
|
735
794
|
listPermissions,
|
|
736
795
|
resolveEmail,
|
|
@@ -22,6 +22,7 @@ export interface ShortcutHandlers {
|
|
|
22
22
|
onPathJump?: () => void; // Cmd+K / Ctrl+K
|
|
23
23
|
onGoUp?: () => void; // Alt+Up / Backspace (when nothing selected)
|
|
24
24
|
onShowHelp?: () => void; // ? (Shift+/ on most layouts)
|
|
25
|
+
onToggleInspector?: () => void; // i (koru:k1 details panel)
|
|
25
26
|
hasSelection?: () => boolean; // disambiguates Backspace
|
|
26
27
|
}
|
|
27
28
|
|
|
@@ -45,6 +46,7 @@ export const SHORTCUTS: ShortcutDef[] = [
|
|
|
45
46
|
{ keys: ['Alt+↑', 'Backspace'], labelKey: 'shortcuts.go_up', groupKey: 'shortcuts.group.nav' },
|
|
46
47
|
{ keys: ['Enter'], labelKey: 'shortcuts.open', groupKey: 'shortcuts.group.nav' },
|
|
47
48
|
{ keys: ['Esc'], labelKey: 'shortcuts.close', groupKey: 'shortcuts.group.nav' },
|
|
49
|
+
{ keys: ['I'], labelKey: 'shortcuts.inspector', groupKey: 'shortcuts.group.nav' } /* koru:k1 */,
|
|
48
50
|
{ keys: ['?'], labelKey: 'shortcuts.help', groupKey: 'shortcuts.group.nav' },
|
|
49
51
|
// Selection
|
|
50
52
|
{ keys: ['Ctrl+A'], labelKey: 'shortcuts.select_all', groupKey: 'shortcuts.group.selection' },
|
|
@@ -160,6 +162,15 @@ export function useKeyboardShortcuts(rootEl: Ref<HTMLElement | null>, handlers:
|
|
|
160
162
|
handlers.onDuplicate();
|
|
161
163
|
}
|
|
162
164
|
break;
|
|
165
|
+
/* koru:k1 — plain `i` toggles the inspector (details) panel. Ctrl+I /
|
|
166
|
+
Alt+I stay untouched so browser/OS combos keep working. */
|
|
167
|
+
case 'i':
|
|
168
|
+
case 'I':
|
|
169
|
+
if (!ctrl && !e.altKey && handlers.onToggleInspector) {
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
handlers.onToggleInspector();
|
|
172
|
+
}
|
|
173
|
+
break;
|
|
163
174
|
case 'k':
|
|
164
175
|
case 'K':
|
|
165
176
|
if (ctrl && handlers.onPathJump) {
|
package/src/locales/en.ts
CHANGED
|
@@ -201,4 +201,43 @@ export const en: Record<string, string> = {
|
|
|
201
201
|
'toolbar.search.close': 'Close search',
|
|
202
202
|
'sheet.menu': 'Actions',
|
|
203
203
|
'sheet.close': 'Close',
|
|
204
|
+
|
|
205
|
+
/* === koru:k1 === */
|
|
206
|
+
'toolbar.inspector': 'Details',
|
|
207
|
+
'ctx.details': 'Details',
|
|
208
|
+
'shortcuts.inspector': 'Toggle details panel',
|
|
209
|
+
'inspector.title': 'Details',
|
|
210
|
+
'inspector.close': 'Close',
|
|
211
|
+
'inspector.section.general': 'General',
|
|
212
|
+
'inspector.section.versions': 'Versions',
|
|
213
|
+
'inspector.section.permissions': 'Permissions',
|
|
214
|
+
'inspector.section.shares': 'Shares',
|
|
215
|
+
'inspector.path': 'Path',
|
|
216
|
+
'inspector.size': 'Size',
|
|
217
|
+
'inspector.modified': 'Modified',
|
|
218
|
+
'inspector.mime': 'MIME type',
|
|
219
|
+
'inspector.etag': 'ETag',
|
|
220
|
+
'inspector.copy': 'Copy',
|
|
221
|
+
'inspector.copied': 'Copied',
|
|
222
|
+
'inspector.error': 'Could not load',
|
|
223
|
+
'inspector.items_summary': '{n} items, {size} total',
|
|
224
|
+
'inspector.folder_items': '{n} items',
|
|
225
|
+
'inspector.versions.empty': 'No version history',
|
|
226
|
+
'inspector.versions.v': 'Version {n}',
|
|
227
|
+
'inspector.versions.restore': 'Restore',
|
|
228
|
+
'inspector.versions.restore_confirm': 'Restore this version?',
|
|
229
|
+
'inspector.versions.snapshot_current': 'Snapshot the current state first',
|
|
230
|
+
'inspector.versions.confirm': 'Confirm',
|
|
231
|
+
'inspector.versions.cancel': 'Cancel',
|
|
232
|
+
'inspector.versions.take_snapshot': 'Snapshot current state',
|
|
233
|
+
'inspector.versions.restored': 'Version restored',
|
|
234
|
+
'inspector.versions.snapshotted': 'Snapshot taken',
|
|
235
|
+
'inspector.versions.unsupported': 'The server does not support this operation',
|
|
236
|
+
'inspector.perm.none': 'No access',
|
|
237
|
+
'inspector.perm.viewer': 'Viewer',
|
|
238
|
+
'inspector.perm.editor': 'Editor',
|
|
239
|
+
'inspector.perm.owner': 'Owner',
|
|
240
|
+
'inspector.perm.manage': 'Manage permissions',
|
|
241
|
+
'inspector.shares.empty': 'No share links',
|
|
242
|
+
'inspector.shares.copy': 'Copy link',
|
|
204
243
|
};
|