@brftech/filex-core 0.5.0 → 0.7.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 (49) hide show
  1. package/dist/{ArchiveViewer-DKW-HyXT.js → ArchiveViewer-w6GDJJt9.js} +2 -2
  2. package/dist/{ArchiveViewer-DKW-HyXT.js.map → ArchiveViewer-w6GDJJt9.js.map} +1 -1
  3. package/dist/{CsvViewer-D2HmP2yr.js → CsvViewer-Ci5-mke8.js} +2 -2
  4. package/dist/{CsvViewer-D2HmP2yr.js.map → CsvViewer-Ci5-mke8.js.map} +1 -1
  5. package/dist/{DrawioViewer-DPNvOFCs.js → DrawioViewer-D2iMuQfD.js} +2 -2
  6. package/dist/{DrawioViewer-DPNvOFCs.js.map → DrawioViewer-D2iMuQfD.js.map} +1 -1
  7. package/dist/{EpubViewer-ByqjNuNL.js → EpubViewer-D4ydsa9X.js} +2 -2
  8. package/dist/{EpubViewer-ByqjNuNL.js.map → EpubViewer-D4ydsa9X.js.map} +1 -1
  9. package/dist/{IpynbViewer-2iONhbuJ.js → IpynbViewer-CWBwiFKX.js} +2 -2
  10. package/dist/{IpynbViewer-2iONhbuJ.js.map → IpynbViewer-CWBwiFKX.js.map} +1 -1
  11. package/dist/{MermaidViewer-CmiU7hIT.js → MermaidViewer-CDhuqQli.js} +2 -2
  12. package/dist/{MermaidViewer-CmiU7hIT.js.map → MermaidViewer-CDhuqQli.js.map} +1 -1
  13. package/dist/{PsdViewer-Inhl2yN9.js → PsdViewer-C_TkFb2H.js} +2 -2
  14. package/dist/{PsdViewer-Inhl2yN9.js.map → PsdViewer-C_TkFb2H.js.map} +1 -1
  15. package/dist/{TiffViewer-C2AFFEze.js → TiffViewer-BmWCXR7e.js} +2 -2
  16. package/dist/{TiffViewer-C2AFFEze.js.map → TiffViewer-BmWCXR7e.js.map} +1 -1
  17. package/dist/{Viewer3D-YdW14q_n.js → Viewer3D-5kPT1QsY.js} +2 -2
  18. package/dist/{Viewer3D-YdW14q_n.js.map → Viewer3D-5kPT1QsY.js.map} +1 -1
  19. package/dist/filex-core.js +66 -47
  20. package/dist/filex-core.umd.cjs +41 -40
  21. package/dist/filex-core.umd.cjs.map +1 -1
  22. package/dist/index-nc0-oI_l.js +11723 -0
  23. package/dist/index-nc0-oI_l.js.map +1 -0
  24. package/dist/index.d.ts +364 -1
  25. package/dist/style.css +1 -1
  26. package/package.json +1 -1
  27. package/src/FileExplorer.vue +756 -23
  28. package/src/components/CommandPalette.vue +12 -1
  29. package/src/components/EncryptedFolderModal.vue +125 -0
  30. package/src/components/GalleryView.vue +215 -0
  31. package/src/components/GridView.vue +2 -0
  32. package/src/components/InspectorPanel.vue +170 -0
  33. package/src/components/ListView.vue +2 -0
  34. package/src/components/SecondaryPane.vue +411 -0
  35. package/src/components/TabBar.vue +174 -0
  36. package/src/components/Toolbar.vue +23 -5
  37. package/src/composables/useFileApi.ts +60 -0
  38. package/src/composables/useKeyboardShortcuts.ts +19 -0
  39. package/src/composables/useTabs.ts +222 -0
  40. package/src/index.ts +27 -0
  41. package/src/lib/e2ecrypto.ts +299 -0
  42. package/src/locales/en.ts +67 -0
  43. package/src/locales/tr.ts +67 -0
  44. package/src/modals/NewFolderModal.vue +18 -0
  45. package/src/modals/PreviewModal.vue +7 -2
  46. package/src/styles/base.css +631 -0
  47. package/src/types/FileNode.ts +4 -1
  48. package/dist/index-eARevpz0.js +0 -9952
  49. package/dist/index-eARevpz0.js.map +0 -1
@@ -39,6 +39,8 @@ const props = defineProps<{
39
39
  canGoUp?: boolean;
40
40
  /* bul:s3 — global search callback; absent = older host, group hidden. */
41
41
  globalSearch?: (q: string) => Promise<GlobalSearchHit[]>;
42
+ /* wiring:d1 — false hides the split command (narrow embeds). */
43
+ splitEnabled?: boolean;
42
44
  }>();
43
45
 
44
46
  const emit = defineEmits<{
@@ -57,6 +59,9 @@ const emit = defineEmits<{
57
59
  (e: 'open-theme'): void;
58
60
  (e: 'open-shortcut-settings'): void;
59
61
  (e: 'start-tour'): void;
62
+ /* wiring:d1 — tab / split commands */
63
+ (e: 'tab-new'): void;
64
+ (e: 'split-toggle'): void;
60
65
  }>();
61
66
 
62
67
  const { t, nodeDisplayName } = useLocale(() => props.locale);
@@ -126,7 +131,7 @@ const commandItems = computed<PaletteItem[]>(() => {
126
131
  const defs: Array<{ command: string; label: string; icon: string; enabled: boolean }> = [
127
132
  { command: 'new-folder', label: t('toolbar.new_folder'), icon: '📁', enabled: props.canWrite !== false },
128
133
  { command: 'upload', label: t('toolbar.upload'), icon: '⬆', enabled: props.canWrite !== false },
129
- { command: 'toggle-view', label: t('cmd.view_toggle'), icon: props.viewMode === 'list' ? '▦' : '☰', enabled: true },
134
+ { command: 'toggle-view', label: t('cmd.view_toggle'), icon: props.viewMode === 'list' ? '▦' : props.viewMode === 'grid' ? '▣' : '☰', enabled: true /* wiring:d2 — sıradaki mod ikonu (list→grid→galeri→list) */ },
130
135
  { command: 'open-trash', label: t('cmd.trash'), icon: '🗑', enabled: true },
131
136
  { command: 'refresh', label: t('toolbar.refresh'), icon: '⟳', enabled: true },
132
137
  { command: 'go-up', label: t('toolbar.go_up'), icon: '↑', enabled: props.canGoUp !== false },
@@ -134,6 +139,9 @@ const commandItems = computed<PaletteItem[]>(() => {
134
139
  { command: 'open-theme', label: t('theme.menu'), icon: '🎨', enabled: true },
135
140
  { command: 'open-shortcut-settings', label: t('shortcuts.settings.menu'), icon: '⌨', enabled: true },
136
141
  { command: 'start-tour', label: t('tour.restart'), icon: '🎓', enabled: true },
142
+ /* wiring:d1 — sekme + split */
143
+ { command: 'tab-new', label: t('cmd.tab_new'), icon: '⧉', enabled: true },
144
+ { command: 'split-toggle', label: t('cmd.split_toggle'), icon: '◫', enabled: props.splitEnabled !== false },
137
145
  ];
138
146
  return defs
139
147
  .filter((d) => d.enabled && matchScore(d.label, q) >= 0)
@@ -322,6 +330,9 @@ function choose(item?: PaletteItem) {
322
330
  case 'open-theme': emit('open-theme'); break;
323
331
  case 'open-shortcut-settings': emit('open-shortcut-settings'); break;
324
332
  case 'start-tour': emit('start-tour'); break;
333
+ /* wiring:d1 */
334
+ case 'tab-new': emit('tab-new'); break;
335
+ case 'split-toggle': emit('split-toggle'); break;
325
336
  }
326
337
  }
327
338
  }
@@ -0,0 +1,125 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * EncryptedFolderModal — create an E2E-encrypted folder (wiring:e2).
4
+ *
5
+ * Collects folder name + password ×2 and an explicit "I understand there
6
+ * is NO recovery" acknowledgement. The parent (FileExplorer) performs the
7
+ * actual newfolder + marker-upload dance; this modal never touches the
8
+ * network and never stores the password anywhere.
9
+ *
10
+ * Crypto scheme + threat model: docs/E2E-ENCRYPTION.md.
11
+ */
12
+ import { ref, watch } from 'vue';
13
+ import type { LocaleCode } from '../types/ExplorerConfig';
14
+ import { useLocale } from '../composables/useLocale';
15
+ import { E2E_MIN_PASSWORD_LEN } from '../lib/e2ecrypto';
16
+ import Modal from '../modals/Modal.vue';
17
+
18
+ const props = defineProps<{
19
+ open: boolean;
20
+ locale: LocaleCode;
21
+ /** True while the parent is creating the folder + uploading the marker. */
22
+ busy?: boolean;
23
+ }>();
24
+
25
+ const emit = defineEmits<{
26
+ (e: 'close'): void;
27
+ (e: 'submit', payload: { name: string; password: string }): void;
28
+ }>();
29
+
30
+ const { t } = useLocale(() => props.locale);
31
+ const name = ref('');
32
+ const password = ref('');
33
+ const password2 = ref('');
34
+ const ack = ref(false);
35
+ const err = ref<string | null>(null);
36
+
37
+ watch(
38
+ () => props.open,
39
+ (v) => {
40
+ if (v) {
41
+ name.value = '';
42
+ password.value = '';
43
+ password2.value = '';
44
+ ack.value = false;
45
+ err.value = null;
46
+ }
47
+ },
48
+ );
49
+
50
+ function submit() {
51
+ if (props.busy) return;
52
+ const clean = name.value.trim();
53
+ if (!clean) {
54
+ err.value = t('modal.newfolder.placeholder');
55
+ return;
56
+ }
57
+ if (/[\\/]/.test(clean) || clean === '.' || clean === '..' || clean.startsWith('.filex')) {
58
+ err.value = t('e2e.create.bad_name');
59
+ return;
60
+ }
61
+ if (password.value.length < E2E_MIN_PASSWORD_LEN) {
62
+ err.value = t('e2e.create.pw_short');
63
+ return;
64
+ }
65
+ if (password.value !== password2.value) {
66
+ err.value = t('e2e.create.pw_mismatch');
67
+ return;
68
+ }
69
+ if (!ack.value) {
70
+ err.value = t('e2e.create.ack_required');
71
+ return;
72
+ }
73
+ err.value = null;
74
+ emit('submit', { name: clean, password: password.value });
75
+ }
76
+ </script>
77
+
78
+ <template>
79
+ <Modal :open="open" :title="t('e2e.create.title')" size="sm" @close="emit('close')">
80
+ <form class="fe-e2e-form" @submit.prevent="submit">
81
+ <input
82
+ v-model="name"
83
+ type="text"
84
+ class="fe-input"
85
+ :placeholder="t('modal.newfolder.placeholder')"
86
+ autocomplete="off"
87
+ :disabled="busy"
88
+ />
89
+ <input
90
+ v-model="password"
91
+ type="password"
92
+ class="fe-input"
93
+ :placeholder="t('e2e.create.pw_placeholder')"
94
+ autocomplete="new-password"
95
+ :disabled="busy"
96
+ />
97
+ <input
98
+ v-model="password2"
99
+ type="password"
100
+ class="fe-input"
101
+ :placeholder="t('e2e.create.pw2_placeholder')"
102
+ autocomplete="new-password"
103
+ :disabled="busy"
104
+ @keydown.enter.prevent="submit"
105
+ />
106
+ <div class="fe-e2e-warn" role="alert">
107
+ <strong>{{ t('e2e.create.warn_title') }}</strong>
108
+ <p>{{ t('e2e.create.warn_body') }}</p>
109
+ </div>
110
+ <label class="fe-e2e-ack">
111
+ <input v-model="ack" type="checkbox" :disabled="busy" />
112
+ <span>{{ t('e2e.create.ack') }}</span>
113
+ </label>
114
+ <p v-if="err" class="fe-form__error">{{ err }}</p>
115
+ </form>
116
+ <template #actions>
117
+ <button type="button" class="fe-btn" :disabled="busy" @click="emit('close')">
118
+ {{ t('modal.newfolder.cancel') }}
119
+ </button>
120
+ <button type="button" class="fe-btn fe-btn--primary" :disabled="busy" @click="submit">
121
+ {{ busy ? t('e2e.create.busy') : t('e2e.create.create') }}
122
+ </button>
123
+ </template>
124
+ </Modal>
125
+ </template>
@@ -0,0 +1,215 @@
1
+ <script setup lang="ts">
2
+ // (unscoped styles by design — see base.css wiring:d2 block; SFC scoped
3
+ // styles are banned for webcomponent data-v hash mismatch reasons.)
4
+ /**
5
+ * GalleryView — wiring:d2. Third view mode: large-thumbnail gallery for
6
+ * visual browsing (photos/videos). Derived from GridView (same props/emits,
7
+ * same listbox a11y, same selection/drag/touch contract) but renders big
8
+ * square tiles: thumbnail (or SVG file-type icon fallback) with the name
9
+ * below and size+date revealed on hover/focus. GridView itself is untouched.
10
+ */
11
+ import { ref } from 'vue';
12
+ import type { FileNode } from '../types/FileNode';
13
+ import type { LocaleCode } from '../types/ExplorerConfig';
14
+ import { useLocale } from '../composables/useLocale';
15
+ import { fileIconSvg } from '../lib/fileIcons';
16
+ import { applyDragGhost } from '../lib/dragGhost';
17
+
18
+ const props = defineProps<{
19
+ files: FileNode[];
20
+ selected: Set<string>;
21
+ clipped?: Set<string>;
22
+ showParentPath?: boolean;
23
+ locale: LocaleCode;
24
+ loading?: boolean;
25
+ /** Authenticated thumb resolver (useThumbs.src) — same contract as
26
+ * GridView: raw `thumb_url` is root-relative and unauthenticated, so
27
+ * embedded hosts NEED this. null = icon fallback. */
28
+ thumbSrc?: (n: FileNode) => string | null;
29
+ }>();
30
+
31
+ const emit = defineEmits<{
32
+ (e: 'click-card', node: FileNode, mod: { ctrl: boolean; shift: boolean }): void;
33
+ (e: 'dbl-card', node: FileNode): void;
34
+ (e: 'context-card', node: FileNode, ev: MouseEvent): void;
35
+ (e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
36
+ (e: 'item-drop-into', target: FileNode, ev: DragEvent): void;
37
+ }>();
38
+
39
+ const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
40
+
41
+ function thumbOf(n: FileNode): string | null {
42
+ return props.thumbSrc ? props.thumbSrc(n) : (n.thumb_url ?? null);
43
+ }
44
+
45
+ function isSelected(n: FileNode): boolean {
46
+ return props.selected.has(n.path);
47
+ }
48
+
49
+ function onClick(n: FileNode, ev: MouseEvent) {
50
+ emit('click-card', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey });
51
+ }
52
+
53
+ function onDbl(n: FileNode) {
54
+ emit('dbl-card', n);
55
+ }
56
+
57
+ function onCtx(n: FileNode, ev: MouseEvent) {
58
+ ev.preventDefault();
59
+ ev.stopPropagation();
60
+ emit('context-card', n, ev);
61
+ }
62
+
63
+ function onItemDragStart(n: FileNode, ev: DragEvent) {
64
+ applyDragGhost(
65
+ ev,
66
+ nodeDisplayName(n),
67
+ props.selected.has(n.path) ? props.selected.size : 1,
68
+ );
69
+ emit('item-drag-start', n, ev);
70
+ }
71
+
72
+ // Drop-target highlight — mirrors GridView (visual layer only).
73
+ const dropTargetPath = ref<string | null>(null);
74
+
75
+ function onItemDragOver(n: FileNode, ev: DragEvent) {
76
+ if (n.type !== 'dir') return;
77
+ if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
78
+ ev.preventDefault();
79
+ ev.stopPropagation();
80
+ if (ev.dataTransfer) ev.dataTransfer.dropEffect = 'move';
81
+ dropTargetPath.value = n.path;
82
+ }
83
+
84
+ function onItemDragLeave(n: FileNode) {
85
+ if (dropTargetPath.value === n.path) dropTargetPath.value = null;
86
+ }
87
+
88
+ function onItemDrop(n: FileNode, ev: DragEvent) {
89
+ dropTargetPath.value = null;
90
+ if (n.type !== 'dir') return;
91
+ if (!ev.dataTransfer?.types.includes('application/x-brf-files')) return;
92
+ ev.preventDefault();
93
+ ev.stopPropagation();
94
+ emit('item-drop-into', n, ev);
95
+ }
96
+
97
+ // Long-press → context menu, same as GridView (touch parity).
98
+ let pressTimer: ReturnType<typeof setTimeout> | undefined;
99
+ let pressTarget: FileNode | null = null;
100
+ function onTouchStart(n: FileNode, ev: TouchEvent) {
101
+ pressTarget = n;
102
+ if (pressTimer) clearTimeout(pressTimer);
103
+ pressTimer = setTimeout(() => {
104
+ if (pressTarget) {
105
+ const t0 = ev.touches[0];
106
+ emit('context-card', pressTarget, {
107
+ clientX: t0.clientX,
108
+ clientY: t0.clientY,
109
+ preventDefault: () => {},
110
+ } as unknown as MouseEvent);
111
+ }
112
+ }, 500);
113
+ }
114
+ function cancelPress() {
115
+ if (pressTimer) clearTimeout(pressTimer);
116
+ pressTarget = null;
117
+ }
118
+
119
+ function parentDir(path: string): string {
120
+ const stripped = path.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
121
+ const idx = stripped.lastIndexOf('/');
122
+ if (idx === -1) return '';
123
+ return stripped.slice(0, idx);
124
+ }
125
+
126
+ // Special rows keep their emoji (trash/storage are not file-TYPE icons).
127
+ function specialEmojiFor(n: FileNode): string | null {
128
+ if (n.basename === '.trash') return '🗑';
129
+ if (n.mime_type === 'inode/storage') return '💾';
130
+ if (n.type === 'dir' && n.e2e === true) return '🔒'; /* wiring:e2 — şifreli klasör rozeti */
131
+ return null;
132
+ }
133
+
134
+ function displayDate(ms: number | undefined): string {
135
+ if (!ms) return '';
136
+ const d = new Date(ms * (ms < 1e12 ? 1000 : 1));
137
+ return d.toLocaleString();
138
+ }
139
+
140
+ // Hover meta line: size for files, entry count (when known) for dirs.
141
+ function metaFor(n: FileNode): string {
142
+ const date = displayDate(n.last_modified);
143
+ const size = n.type === 'dir' ? '' : formatSize(n.size);
144
+ return [size, date].filter((s) => !!s).join(' · ');
145
+ }
146
+ </script>
147
+
148
+ <template>
149
+ <div
150
+ class="fe-gal"
151
+ :class="{ 'is-loading': loading }"
152
+ role="listbox"
153
+ aria-multiselectable="true"
154
+ :aria-label="t('gallery.aria')"
155
+ :aria-busy="loading ? 'true' : undefined"
156
+ >
157
+ <div
158
+ v-for="n in files"
159
+ :key="n.path"
160
+ class="fe-gal__card"
161
+ :class="{
162
+ 'is-selected': isSelected(n),
163
+ 'is-dir': n.type === 'dir',
164
+ 'is-trash': n.trashed,
165
+ 'is-clipped': clipped?.has(n.path),
166
+ 'is-droptarget': dropTargetPath === n.path,
167
+ }"
168
+ tabindex="0"
169
+ role="option"
170
+ :aria-selected="isSelected(n) ? 'true' : 'false'"
171
+ :aria-label="nodeDisplayName(n)"
172
+ draggable="true"
173
+ @click="onClick(n, $event)"
174
+ @dblclick="onDbl(n)"
175
+ @contextmenu="onCtx(n, $event)"
176
+ @dragstart="onItemDragStart(n, $event)"
177
+ @dragover="onItemDragOver(n, $event)"
178
+ @dragleave="onItemDragLeave(n)"
179
+ @drop="onItemDrop(n, $event)"
180
+ @touchstart.passive="onTouchStart(n, $event)"
181
+ @touchend="cancelPress"
182
+ @touchmove="cancelPress"
183
+ >
184
+ <div class="fe-gal__thumb">
185
+ <!-- draggable="false" for the same reason as GridView: HTML5 image
186
+ drag adds a 'Files' MIME to dataTransfer, which would trip the
187
+ parent's upload handler on internal drags. -->
188
+ <img
189
+ v-if="thumbOf(n)"
190
+ :src="thumbOf(n)!"
191
+ :alt="n.basename"
192
+ loading="lazy"
193
+ draggable="false"
194
+ />
195
+ <span v-else-if="specialEmojiFor(n)" class="fe-gal__icon">{{ specialEmojiFor(n) }}</span>
196
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
197
+ <span v-else class="fe-gal__icon fe-gal__icon--svg" v-html="fileIconSvg(n)"></span>
198
+ <div class="fe-gal__meta" aria-hidden="true">
199
+ <span v-if="metaFor(n)" class="fe-gal__meta-line">{{ metaFor(n) }}</span>
200
+ <span
201
+ v-if="showParentPath && parentDir(n.path)"
202
+ class="fe-gal__meta-line fe-gal__meta-line--path"
203
+ :title="parentDir(n.path)"
204
+ >{{ parentDir(n.path) }}</span>
205
+ </div>
206
+ </div>
207
+ <div class="fe-gal__label" :title="n.basename">
208
+ {{ nodeDisplayName(n) }}
209
+ </div>
210
+ </div>
211
+ <div v-if="!loading && files.length === 0" class="fe-gal__empty">
212
+ {{ t('empty.folder') }}
213
+ </div>
214
+ </div>
215
+ </template>
@@ -126,6 +126,7 @@ function parentDir(path: string): string {
126
126
  function specialEmojiFor(n: FileNode): string | null {
127
127
  if (n.basename === '.trash') return '🗑';
128
128
  if (n.mime_type === 'inode/storage') return '💾';
129
+ if (n.type === 'dir' && n.e2e === true) return '🔒'; /* wiring:e2 — şifreli klasör rozeti */
129
130
  return null;
130
131
  }
131
132
 
@@ -171,6 +172,7 @@ function snippetTitle(snippet: string): string {
171
172
  role="option"
172
173
  :aria-selected="isSelected(n) ? 'true' : 'false'"
173
174
  :aria-label="nodeDisplayName(n) /* wiring:c4 */"
175
+ :data-fe-path="n.path /* wiring:d1 — orta-tık yeni sekme delegasyonu */"
174
176
  draggable="true"
175
177
  @click="onClick(n, $event)"
176
178
  @dblclick="onDbl(n)"
@@ -244,6 +244,114 @@ watch(
244
244
  () => void refresh(),
245
245
  { immediate: true },
246
246
  );
247
+
248
+ /* === calisma:d3 — Yorumlar (node comments) ===
249
+ * Flat chronological thread per node (files AND folders — every backend
250
+ * row carries an id). Loaded through its own watcher + seq guard so the
251
+ * existing refresh() stays untouched. Add/delete are NOT optimistic: the
252
+ * list re-fetches after each successful API round-trip. The section hides
253
+ * silently when the backend gates (401/403) or lacks (404) the endpoint.
254
+ */
255
+ import type { NodeComment } from '../composables/useFileApi';
256
+
257
+ const commentsState = ref<SectionState>('hidden');
258
+ const comments = ref<NodeComment[]>([]);
259
+ const commentDraft = ref('');
260
+ const commentBusy = ref(false);
261
+ const commentNodeId = computed<number | null>(() =>
262
+ typeof single.value?.id === 'number' ? (single.value.id as number) : null,
263
+ );
264
+ let commentSeq = 0;
265
+
266
+ async function loadComments(): Promise<void> {
267
+ const seq = ++commentSeq;
268
+ const id = commentNodeId.value;
269
+ if (id == null) {
270
+ commentsState.value = 'hidden';
271
+ comments.value = [];
272
+ return;
273
+ }
274
+ commentsState.value = comments.value.length > 0 ? 'ok' : 'loading';
275
+ try {
276
+ const list = await props.api.listComments(id);
277
+ if (seq !== commentSeq) return;
278
+ comments.value = list;
279
+ commentsState.value = 'ok';
280
+ } catch (err) {
281
+ if (seq !== commentSeq) return;
282
+ comments.value = [];
283
+ const status = (err as { status?: number }).status;
284
+ commentsState.value =
285
+ status === 401 || status === 403 || status === 404 ? 'hidden' : 'error';
286
+ }
287
+ }
288
+
289
+ async function sendComment(): Promise<void> {
290
+ const id = commentNodeId.value;
291
+ const body = commentDraft.value.trim();
292
+ if (id == null || body === '' || commentBusy.value) return;
293
+ commentBusy.value = true;
294
+ try {
295
+ await props.api.addComment(id, body);
296
+ commentDraft.value = '';
297
+ await loadComments();
298
+ } catch (err) {
299
+ emit('toast', (err as Error).message);
300
+ } finally {
301
+ commentBusy.value = false;
302
+ }
303
+ }
304
+
305
+ async function removeComment(c: NodeComment): Promise<void> {
306
+ if (commentBusy.value) return;
307
+ commentBusy.value = true;
308
+ try {
309
+ await props.api.deleteComment(c.id);
310
+ await loadComments();
311
+ } catch (err) {
312
+ emit('toast', (err as Error).message);
313
+ } finally {
314
+ commentBusy.value = false;
315
+ }
316
+ }
317
+
318
+ /** Relative "3 minutes ago" stamp via Intl (locale-aware, no i18n keys). */
319
+ function relativeTime(iso: string): string {
320
+ const ms = Date.parse(iso);
321
+ if (Number.isNaN(ms)) return iso;
322
+ const diffS = Math.round((ms - Date.now()) / 1000);
323
+ const abs = Math.abs(diffS);
324
+ const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
325
+ ['year', 31536000],
326
+ ['month', 2592000],
327
+ ['day', 86400],
328
+ ['hour', 3600],
329
+ ['minute', 60],
330
+ ];
331
+ try {
332
+ const rtf = new Intl.RelativeTimeFormat(
333
+ props.locale === 'en' ? 'en' : 'tr',
334
+ { numeric: 'auto' },
335
+ );
336
+ for (const [unit, secs] of units) {
337
+ if (abs >= secs) return rtf.format(Math.trunc(diffS / secs), unit);
338
+ }
339
+ return rtf.format(diffS, 'second');
340
+ } catch {
341
+ return formatDateStr(iso);
342
+ }
343
+ }
344
+
345
+ watch(
346
+ () => commentNodeId.value,
347
+ () => {
348
+ comments.value = [];
349
+ commentDraft.value = '';
350
+ void loadComments();
351
+ },
352
+ { immediate: true },
353
+ );
354
+ /* === /calisma:d3 === */
247
355
  </script>
248
356
 
249
357
  <template>
@@ -450,6 +558,68 @@ watch(
450
558
  </li>
451
559
  </ul>
452
560
  </section>
561
+
562
+ <!-- ══ calisma:d3 — Yorumlar ══ -->
563
+ <section
564
+ v-if="commentsState === 'ok' || commentsState === 'error'"
565
+ class="fe-inspector__section"
566
+ >
567
+ <h3 class="fe-inspector__heading">
568
+ {{ t('inspector.section.comments') }}
569
+ <span
570
+ v-if="comments.length > 0"
571
+ class="fe-inspector__countbadge"
572
+ >{{ comments.length }}</span>
573
+ </h3>
574
+
575
+ <p v-if="commentsState === 'error'" class="fe-inspector__empty">
576
+ {{ t('inspector.error') }}
577
+ </p>
578
+ <template v-else>
579
+ <p v-if="comments.length === 0" class="fe-inspector__empty">
580
+ {{ t('inspector.comments.empty') }}
581
+ </p>
582
+ <ul v-else class="fe-inspector__comments">
583
+ <li v-for="c in comments" :key="c.id" class="fe-inspector__comment">
584
+ <div class="fe-inspector__comment-top">
585
+ <span class="fe-inspector__comment-author" :title="c.author_name">
586
+ {{ c.author_name || '—' }}
587
+ </span>
588
+ <span class="fe-inspector__comment-time" :title="formatDateStr(c.created_at)">
589
+ {{ relativeTime(c.created_at) }}
590
+ </span>
591
+ <button
592
+ v-if="c.can_delete"
593
+ type="button"
594
+ class="fe-inspector__comment-del"
595
+ :disabled="commentBusy"
596
+ :title="t('inspector.comments.delete')"
597
+ :aria-label="t('inspector.comments.delete')"
598
+ @click="removeComment(c)"
599
+ >×</button>
600
+ </div>
601
+ <p class="fe-inspector__comment-body">{{ c.body }}</p>
602
+ </li>
603
+ </ul>
604
+ <form class="fe-inspector__comment-form" @submit.prevent="sendComment">
605
+ <input
606
+ v-model="commentDraft"
607
+ type="text"
608
+ class="fe-inspector__comment-input"
609
+ maxlength="5000"
610
+ :placeholder="t('inspector.comments.placeholder')"
611
+ :aria-label="t('inspector.comments.placeholder')"
612
+ :disabled="commentBusy"
613
+ />
614
+ <button
615
+ type="submit"
616
+ class="fe-btn fe-btn--primary fe-btn--sm"
617
+ :disabled="commentBusy || commentDraft.trim() === ''"
618
+ >{{ t('inspector.comments.send') }}</button>
619
+ </form>
620
+ </template>
621
+ </section>
622
+ <!-- ══ /calisma:d3 ══ -->
453
623
  </div>
454
624
  </aside>
455
625
  </template>
@@ -139,6 +139,7 @@ function cancelPress() {
139
139
  function specialEmojiFor(n: FileNode): string | null {
140
140
  if (n.basename === '.trash') return '🗑';
141
141
  if (n.mime_type === 'inode/storage') return '💾';
142
+ if (n.type === 'dir' && n.e2e === true) return '🔒'; /* wiring:e2 — şifreli klasör rozeti */
142
143
  return null;
143
144
  }
144
145
 
@@ -361,6 +362,7 @@ const segments = computed<Segment[]>(() => {
361
362
  tabindex="0"
362
363
  :aria-selected="isSelected(n) ? 'true' : 'false'"
363
364
  :aria-label="nodeDisplayName(n) /* wiring:c4 */"
365
+ :data-fe-path="n.path /* wiring:d1 — orta-tık yeni sekme delegasyonu */"
364
366
  draggable="true"
365
367
  @click="onRowClick(n, $event)"
366
368
  @dblclick="onRowDbl(n)"