@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.
Files changed (36) hide show
  1. package/dist/{ArchiveViewer-CYrKPEbE.js → ArchiveViewer-Dgvl43gn.js} +2 -2
  2. package/dist/{ArchiveViewer-CYrKPEbE.js.map → ArchiveViewer-Dgvl43gn.js.map} +1 -1
  3. package/dist/{CsvViewer-C3ZU4Uua.js → CsvViewer-gWO5pc8F.js} +2 -2
  4. package/dist/{CsvViewer-C3ZU4Uua.js.map → CsvViewer-gWO5pc8F.js.map} +1 -1
  5. package/dist/{DrawioViewer-15ZL4ken.js → DrawioViewer-DUAlRIAb.js} +2 -2
  6. package/dist/{DrawioViewer-15ZL4ken.js.map → DrawioViewer-DUAlRIAb.js.map} +1 -1
  7. package/dist/{EpubViewer-CrXcDgmo.js → EpubViewer-DIXzuQWK.js} +2 -2
  8. package/dist/{EpubViewer-CrXcDgmo.js.map → EpubViewer-DIXzuQWK.js.map} +1 -1
  9. package/dist/{IpynbViewer-BAb9rV2Q.js → IpynbViewer-CJ-6pU_Z.js} +2 -2
  10. package/dist/{IpynbViewer-BAb9rV2Q.js.map → IpynbViewer-CJ-6pU_Z.js.map} +1 -1
  11. package/dist/{MermaidViewer-BN8GN4FY.js → MermaidViewer-CyQQDyRb.js} +2 -2
  12. package/dist/{MermaidViewer-BN8GN4FY.js.map → MermaidViewer-CyQQDyRb.js.map} +1 -1
  13. package/dist/{PsdViewer-o-kWQExw.js → PsdViewer-ChfaXVgN.js} +2 -2
  14. package/dist/{PsdViewer-o-kWQExw.js.map → PsdViewer-ChfaXVgN.js.map} +1 -1
  15. package/dist/{TiffViewer-AjMVBzxp.js → TiffViewer-CsfD-NG6.js} +2 -2
  16. package/dist/{TiffViewer-AjMVBzxp.js.map → TiffViewer-CsfD-NG6.js.map} +1 -1
  17. package/dist/{Viewer3D-BR3WDbxQ.js → Viewer3D-BQ2XYmkN.js} +2 -2
  18. package/dist/{Viewer3D-BR3WDbxQ.js.map → Viewer3D-BQ2XYmkN.js.map} +1 -1
  19. package/dist/filex-core.js +1 -1
  20. package/dist/filex-core.umd.cjs +39 -39
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-PnhGunSA.js +7972 -0
  23. package/dist/index-PnhGunSA.js.map +1 -0
  24. package/dist/index.d.ts +20 -0
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +84 -0
  28. package/src/components/InspectorPanel.vue +455 -0
  29. package/src/components/Toolbar.vue +34 -0
  30. package/src/composables/useFileApi.ts +59 -0
  31. package/src/composables/useKeyboardShortcuts.ts +11 -0
  32. package/src/locales/en.ts +39 -0
  33. package/src/locales/tr.ts +39 -0
  34. package/src/styles/base.css +321 -0
  35. package/dist/index-BG5upYlH.js +0 -7392
  36. 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
  };