@brftech/filex-core 0.40.0 → 0.41.1

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 (125) hide show
  1. package/README.md +32 -25
  2. package/dist/ArchiveViewer-DxxjhYZD.js +124 -0
  3. package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
  4. package/dist/CsvViewer-CyuwENI_.js +141 -0
  5. package/dist/CsvViewer-CyuwENI_.js.map +1 -0
  6. package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
  7. package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
  8. package/dist/EpubViewer-K9PjCMmS.js +163 -0
  9. package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
  10. package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
  11. package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
  12. package/dist/MermaidViewer-Bp73aId8.js +137 -0
  13. package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
  14. package/dist/PsdViewer-C2eLivw1.js +122 -0
  15. package/dist/PsdViewer-C2eLivw1.js.map +1 -0
  16. package/dist/TiffViewer-DsHg5gcz.js +142 -0
  17. package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
  18. package/dist/Viewer3D-C_dc2HN9.js +75 -0
  19. package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
  20. package/dist/filex-core.js +218 -20361
  21. package/dist/filex-core.js.map +1 -1
  22. package/dist/filex-core.umd.cjs +61 -62
  23. package/dist/filex-core.umd.cjs.map +1 -1
  24. package/dist/index-vxMZpYb-.js +26700 -0
  25. package/dist/index-vxMZpYb-.js.map +1 -0
  26. package/dist/index.d.ts +4212 -862
  27. package/dist/style.css +1 -1
  28. package/package.json +1 -1
  29. package/src/FileExplorer.vue +2154 -506
  30. package/src/components/AdvancedSearch.vue +870 -0
  31. package/src/components/Breadcrumb.vue +191 -19
  32. package/src/components/CommandPalette.vue +66 -22
  33. package/src/components/ConnectionsPanel.vue +6 -2
  34. package/src/components/ContextMenu.vue +39 -2
  35. package/src/components/EncryptedFolderModal.vue +31 -25
  36. package/src/components/FilePane.vue +951 -0
  37. package/src/components/FilterBar.vue +568 -72
  38. package/src/components/GalleryView.vue +172 -56
  39. package/src/components/GridView.vue +331 -82
  40. package/src/components/HomeView.vue +331 -0
  41. package/src/components/InspectorPanel.vue +296 -53
  42. package/src/components/ListView.vue +1173 -194
  43. package/src/components/NFSExportsPanel.vue +7 -4
  44. package/src/components/OnboardingTour.vue +156 -33
  45. package/src/components/QuickLook.vue +26 -1
  46. package/src/components/RecentlyOpened.vue +17 -2
  47. package/src/components/S3KeysPanel.vue +7 -4
  48. package/src/components/SSHKeysPanel.vue +7 -4
  49. package/src/components/ShortcutSettings.vue +6 -2
  50. package/src/components/ShortcutsHelp.vue +4 -1
  51. package/src/components/SideNav.vue +206 -140
  52. package/src/components/StorageFields.vue +19 -1
  53. package/src/components/TabBar.vue +87 -1
  54. package/src/components/TagPicker.vue +77 -6
  55. package/src/components/ThemeGallery.vue +13 -198
  56. package/src/components/ThemePalette.vue +244 -0
  57. package/src/components/TimeZoneDialog.vue +135 -0
  58. package/src/components/TimeZonePicker.vue +696 -0
  59. package/src/components/TokensPanel.vue +35 -14
  60. package/src/components/Toolbar.vue +953 -465
  61. package/src/components/ViewSwitcher.vue +9 -4
  62. package/src/composables/useConnections.ts +35 -4
  63. package/src/composables/useExplorerTimeZone.ts +89 -0
  64. package/src/composables/useFileApi.ts +53 -4
  65. package/src/composables/useLocale.ts +382 -26
  66. package/src/composables/useRowTouch.ts +95 -0
  67. package/src/index.ts +177 -3
  68. package/src/lib/actionIcons.ts +330 -0
  69. package/src/lib/advSearch.ts +162 -0
  70. package/src/lib/dateGroups.ts +197 -0
  71. package/src/lib/destinationTree.ts +235 -0
  72. package/src/lib/downloadSelection.ts +154 -0
  73. package/src/lib/fileFilters.ts +319 -12
  74. package/src/lib/fileIcons.ts +299 -5
  75. package/src/lib/filePreview.ts +605 -0
  76. package/src/lib/listing.ts +72 -0
  77. package/src/lib/shareTtl.ts +31 -2
  78. package/src/lib/sortOrder.ts +459 -0
  79. package/src/lib/themes.ts +151 -116
  80. package/src/lib/timezone.ts +501 -0
  81. package/src/lib/uiProfile.ts +82 -0
  82. package/src/lib/viewPrefs.ts +1036 -0
  83. package/src/locales/en.ts +416 -26
  84. package/src/locales/tr.ts +409 -26
  85. package/src/modals/ConvertModal.vue +23 -3
  86. package/src/modals/DestinationPickerModal.vue +341 -0
  87. package/src/modals/Modal.vue +18 -4
  88. package/src/modals/NewDocumentModal.vue +525 -0
  89. package/src/modals/NewFolderModal.vue +15 -9
  90. package/src/modals/PermissionsModal.vue +557 -476
  91. package/src/modals/PreviewModal.vue +516 -156
  92. package/src/styles/base.css +5409 -8
  93. package/src/styles/variables.css +139 -65
  94. package/src/types/Connections.ts +9 -0
  95. package/src/types/ExplorerConfig.ts +144 -39
  96. package/src/types/FileNode.ts +28 -0
  97. package/src/viewers/ArchiveViewer.vue +46 -8
  98. package/src/viewers/CsvViewer.vue +19 -2
  99. package/src/viewers/DrawioViewer.vue +12 -1
  100. package/src/viewers/EpubViewer.vue +50 -6
  101. package/src/viewers/IpynbViewer.vue +19 -2
  102. package/src/viewers/MermaidViewer.vue +20 -3
  103. package/src/viewers/PdfViewer.vue +24 -4
  104. package/src/viewers/PsdViewer.vue +28 -4
  105. package/src/viewers/TiffViewer.vue +19 -2
  106. package/src/viewers/Viewer3D.vue +36 -2
  107. package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
  108. package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
  109. package/dist/CsvViewer-CqWeV8VO.js +0 -131
  110. package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
  111. package/dist/DrawioViewer-BNALOB04.js.map +0 -1
  112. package/dist/EpubViewer-BPmlImG7.js +0 -145
  113. package/dist/EpubViewer-BPmlImG7.js.map +0 -1
  114. package/dist/IpynbViewer-CDlQmuCV.js +0 -175
  115. package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
  116. package/dist/MermaidViewer-sfZWdR-d.js +0 -127
  117. package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
  118. package/dist/PsdViewer-DwuySZsb.js +0 -112
  119. package/dist/PsdViewer-DwuySZsb.js.map +0 -1
  120. package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
  121. package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
  122. package/dist/Viewer3D-CJJJgkuv.js +0 -60
  123. package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
  124. package/src/components/SecondaryPane.vue +0 -447
  125. package/src/modals/ShareModal.vue +0 -139
@@ -1,13 +1,39 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * GridView — card grid. Thumbnails preferred, fall back to icon.
3
+ * GridView — card grid.
4
+ *
5
+ * gorunum:v1 — the card has an anatomy now, and it is not the same anatomy
6
+ * for a folder and for a file:
7
+ *
8
+ * folder 186×56 glyph · name over "Folder" · ⋮
9
+ * file 186×166 184×108 preview · 56px footer (24px type tile · name
10
+ * over "1.7 MB • Sep 9, 2026" · ⋮)
11
+ *
12
+ * A folder has no preview to show and no size or date worth printing, so a
13
+ * square thumbnail box above its name was 108px of nothing on the most common
14
+ * row in any listing. The two shapes share one footer row — the folder card
15
+ * IS that row — so there is a single piece of markup to change when the row
16
+ * changes.
4
17
  */
5
- import { computed, ref } from 'vue'; /* wiring:c4 */
18
+ import { computed, onBeforeUnmount, ref, watch } from 'vue'; /* wiring:c4 */
6
19
  import type { FileNode } from '../types/FileNode';
7
20
  import { hasInternalDrag } from '../lib/dragOut';
8
21
  import type { LocaleCode } from '../types/ExplorerConfig';
9
22
  import { useLocale } from '../composables/useLocale';
10
- import { fileIconSvg } from '../lib/fileIcons';
23
+ import { useRowTouch } from '../composables/useRowTouch';
24
+ import { encryptedFolderTile, fileIconTile, isEncryptedFolder } from '../lib/fileIcons';
25
+ import {
26
+ createFilePreviews,
27
+ drawsAsPage,
28
+ drawsAsVideo,
29
+ type FilePreview,
30
+ } from '../lib/filePreview'; /* gorunum:v1-preview */
31
+ import { byFoldersFirst, parentDirOf } from '../lib/listing'; /* gorunum:v1 */
32
+ import {
33
+ groupByDate,
34
+ groupingActive,
35
+ } from '../lib/dateGroups'; /* gruplama */
36
+ import { useSortStore, type ListingOrder } from '../lib/sortOrder'; /* gruplama */
11
37
  import StarButton from './StarButton.vue';
12
38
  import { snippetSegments } from '../lib/snippet'; /* bul:s3 */
13
39
  import { applyDragGhost } from '../lib/dragGhost'; /* wiring:c4 */
@@ -49,46 +75,125 @@ const props = defineProps<{
49
75
  authHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
50
76
  authCredentials?: RequestCredentials;
51
77
  /**
52
- * surucu:d1 — draw "Folders" and "Files" as labelled sections
53
- * (`uiProfile: 'drive'`). Absent/false renders the flat grid unchanged, down
54
- * to the DOM: the two headings are the only extra nodes, and without this
55
- * prop none are emitted.
78
+ * surucu:d1 — draw "Folders" and "Files" as labelled sections.
56
79
  *
57
- * The rows are re-ordered HERE (directories first, stable within each group)
58
- * rather than trusted to arrive that way the listing endpoint promises no
59
- * grouping, and a "Folders" heading with a spreadsheet under it is worse
60
- * than no heading at all.
80
+ * It used to arrive with `uiProfile: 'drive'`; the explorer now passes it
81
+ * unconditionally, because the sections are part of the shell rather than of
82
+ * a profile. The prop stays for an embedder mounting this view directly:
83
+ * absent/false renders the grid without them, down to the DOM — the two
84
+ * headings are the only extra nodes, and without this prop none are emitted.
85
+ *
86
+ * ⚠ The prop gates the HEADINGS, not the order. Folders come before files
87
+ * in every profile (see `ordered`) — a heading only names a group that is
88
+ * there either way.
61
89
  */
62
90
  sections?: boolean;
91
+ /**
92
+ * gruplama — where these rows got their order, exactly as `ListView` takes
93
+ * it (`lib/sortOrder.ListingOrder`).
94
+ *
95
+ * ⚠ It changes nothing about the ORDER here — the pane hands this view rows
96
+ * that are already sorted, and `compareInOrder('relevance')` is
97
+ * byte-for-byte the folders-first pass below. It is read for one thing: a
98
+ * date heading over a RANKED answer would be a claim about an order the
99
+ * rows are not in. Omitted = an ordinary folder listing.
100
+ */
101
+ order?: ListingOrder;
102
+ /**
103
+ * gorunum:v1-preview — we are inside an E2E-encrypted folder.
104
+ *
105
+ * Every body on the wire in there is ciphertext, so reading a file's first
106
+ * kilobytes to draw its first lines would spend a request to learn that the
107
+ * bytes do not decode. The text preview stays off; the cards keep their
108
+ * type tiles. Absent/false = the ordinary case.
109
+ */
110
+ e2eActive?: boolean;
63
111
  }>();
64
112
 
65
113
  const emit = defineEmits<{
66
- (e: 'click-card', node: FileNode, mod: { ctrl: boolean; shift: boolean }): void;
114
+ (e: 'click-card', node: FileNode, mod: { ctrl: boolean; shift: boolean; touch?: boolean }): void;
67
115
  (e: 'dbl-card', node: FileNode): void;
68
116
  (e: 'context-card', node: FileNode, ev: MouseEvent): void;
69
117
  (e: 'item-drag-start', node: FileNode, ev: DragEvent): void;
70
118
  (e: 'item-drop-into', target: FileNode, ev: DragEvent): void;
71
119
  (e: 'star-change', node: FileNode, value: boolean): void;
120
+ /** gorunum:v1 — the order this view renders, so the parent's shift-range
121
+ * arithmetic runs over what the user sees rather than over the backend's
122
+ * answer. See the same emit in ListView. */
123
+ (e: 'display-order', nodes: FileNode[]): void;
72
124
  }>();
73
125
 
74
- /* surucu:d1 — grouped order + the two headings. `sort` is stable, so within
75
- * folders and within files the listing's own order survives untouched. */
76
- const ordered = computed<FileNode[]>(() =>
77
- props.sections
78
- ? [...props.files].sort((a, b) => (a.type === 'dir' ? 0 : 1) - (b.type === 'dir' ? 0 : 1))
79
- : props.files,
126
+ /**
127
+ * gorunum:v1 folders first, always, and by the SHARED comparator
128
+ * (`lib/listing.byFoldersFirst`) the list view uses as its own primary key.
129
+ * Two sorts, one rule: a second private copy of "dirs are 0, files are 1" is
130
+ * how the grid and the list end up disagreeing (filex lesson #67).
131
+ *
132
+ * `Array.prototype.sort` is stable, and this comparator returns 0 for any two
133
+ * nodes of the same kind, so whatever order the parent sends survives
134
+ * untouched INSIDE each group. The day a sort control reaches the grid,
135
+ * descending will mean folders descending and then files descending, without
136
+ * this line being touched.
137
+ *
138
+ * Display order only. Selection is keyed by path and the explorer's shift-range
139
+ * still walks its own list, so nothing here moves an index the parent holds.
140
+ */
141
+ const { t, formatSize, nodeDisplayName, formatDate, formatMonthYear, zonedYearMonth, toDate } =
142
+ useLocale(() => props.locale);
143
+
144
+ const ordered = computed<FileNode[]>(() => [...props.files].sort(byFoldersFirst));
145
+ watch(
146
+ ordered,
147
+ (list) => emit('display-order', list),
148
+ { immediate: true },
80
149
  );
150
+
81
151
  const firstDirPath = computed(() => ordered.value.find((f) => f.type === 'dir')?.path ?? null);
82
152
  const firstFilePath = computed(() => ordered.value.find((f) => f.type !== 'dir')?.path ?? null);
83
153
 
154
+ /* gruplama — the same sort this pane's list view reads, by injection, so the
155
+ * two cannot disagree about whether a date heading is honest right now. */
156
+ const sort = useSortStore();
157
+
158
+ /**
159
+ * gruplama — THE DATE HEADINGS, from `lib/dateGroups`, which is also where the
160
+ * list gets them. Not a grid variant of the ladder: a rung added there has to
161
+ * appear here on the same day or the two views name the same day differently.
162
+ */
163
+ const grouped = computed(() =>
164
+ groupByDate(ordered.value, {
165
+ active: groupingActive(sort.key.value, props.order),
166
+ dateOf: (n) => toDate(n.last_modified),
167
+ /* Folders keep their own run at the top — the same rule the list follows,
168
+ * and the reason "folders before files" survives a date sort. Here the run
169
+ * is the one that already had a name. */
170
+ aside: (n) =>
171
+ n.type === 'dir'
172
+ ? { id: 'dirs', label: props.sections ? t('drive.section.folders') : null }
173
+ : null,
174
+ labels: { t, formatMonthYear, zonedYearMonth },
175
+ }),
176
+ );
177
+
178
+ /**
179
+ * ⚠⚠ TWO HEADING SYSTEMS DO NOT STACK. While the rows are in date order the
180
+ * "Files" label is replaced by the date headings rather than sitting above
181
+ * them: the date headings ARE the files' headings, and "Files" followed
182
+ * immediately by "Today" names one group twice and tells the reader nothing
183
+ * the second time. "Folders" stays, because the folders are still one run and
184
+ * no date can name it.
185
+ *
186
+ * Off that order (any other sort key, or a ranked search answer) the grid
187
+ * keeps exactly the Folders / Files sections it has always drawn.
188
+ */
84
189
  function headingBefore(n: FileNode): string | null {
190
+ if (grouped.value.active) return grouped.value.headingBefore(n);
85
191
  if (!props.sections) return null;
86
192
  if (n.path === firstDirPath.value) return t('drive.section.folders');
87
193
  if (n.path === firstFilePath.value) return t('drive.section.files');
88
194
  return null;
89
195
  }
90
196
 
91
- const { t, formatSize, nodeDisplayName } = useLocale(() => props.locale);
92
197
 
93
198
  // Prefer the authenticated resolver when the host wired one; otherwise fall
94
199
  // back to the raw URL (legacy same-origin behavior).
@@ -96,6 +201,61 @@ function thumbOf(n: FileNode): string | null {
96
201
  return props.thumbSrc ? props.thumbSrc(n) : (n.thumb_url ?? null);
97
202
  }
98
203
 
204
+ /* ==================================================================
205
+ * gorunum:v1-preview — the card shows the file's own first lines.
206
+ *
207
+ * ⚠ The brief for this slice said these files "have no thumbnail". Measured
208
+ * on the local backend, they do: `internal/thumb/generic.go` renders a card
209
+ * tinted from a hash of the extension with the letters "TS"/"CSV"/"ZIP" drawn
210
+ * in it, and the listing hands out a `thumb_url` for it (app.ts → a 1,745-byte
211
+ * JPEG). So the choice is not "preview or nothing", it is "the file's first
212
+ * lines, or a coloured rectangle repeating the extension the footer tile and
213
+ * the Type column both already say".
214
+ *
215
+ * Hence the order below: for a node we can read as TEXT the `<img>` is not
216
+ * rendered at all — which also means `thumbOf()` is never called for it and
217
+ * `useThumbs` never fetches that generic JPEG, so this costs the same one
218
+ * request it replaces. Every other node is untouched: images, video, audio
219
+ * and PDFs keep their real, content-derived thumbnails.
220
+ *
221
+ * While the read is in flight (and if it fails, or the bytes turn out not to
222
+ * be text) the card shows the type tile on `--fe-bg-elev` — the spec's stated
223
+ * fallback, and no flash of a placeholder we are about to replace.
224
+ * ================================================================== */
225
+ const previews = createFilePreviews({
226
+ // Read once: the explorer's `config.apiBase` is fixed for the life of a
227
+ // mounted panel, and `enabled` has to be answerable at construction.
228
+ apiBase: props.apiBase,
229
+ // Wrapped rather than passed, so a parent that re-creates its arrow on each
230
+ // render is still called through the CURRENT prop. ⚠ The builder is async;
231
+ // `createFilePreviews` awaits it (see the note there — a Promise spread into
232
+ // a headers object sends no Authorization and 401s in silence).
233
+ authHeaders: props.authHeaders ? () => props.authHeaders!() : undefined,
234
+ credentials: props.authCredentials,
235
+ disabled: () => props.e2eActive === true,
236
+ });
237
+ onBeforeUnmount(() => previews.dispose());
238
+
239
+ /** Non-null only for a node whose bytes we can read as text. */
240
+ function previewKind(n: FileNode) {
241
+ return previews.kindFor(n);
242
+ }
243
+
244
+ /** At most one element, so the template gets a typed local without a
245
+ * non-null assertion repeated at every nested loop. */
246
+ function previewFor(n: FileNode): FilePreview[] {
247
+ const p = previews.get(n);
248
+ return p ? [p] : [];
249
+ }
250
+
251
+ /** Template-ref sink: the preview box itself is what the IntersectionObserver
252
+ * watches, so nothing is fetched for a card that never comes into view.
253
+ * ⚠ `unknown` because a Vue template `:ref` hands back an Element, a
254
+ * component instance or null depending on what it is put on. */
255
+ function bindPreview(el: unknown, n: FileNode) {
256
+ previews.bind(el instanceof Element ? el : null, n);
257
+ }
258
+
99
259
  /** A card carries a star when the host wired the API and the node is a file
100
260
  * with a server id — the same rule the list row uses. */
101
261
  function canStar(n: FileNode): boolean {
@@ -108,7 +268,7 @@ function isSelected(n: FileNode): boolean {
108
268
  }
109
269
 
110
270
  function onClick(n: FileNode, ev: MouseEvent) {
111
- emit('click-card', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey });
271
+ emit('click-card', n, { ctrl: ev.ctrlKey || ev.metaKey, shift: ev.shiftKey, touch: touch.isTap(ev) });
112
272
  }
113
273
 
114
274
  function onDbl(n: FileNode) {
@@ -121,6 +281,17 @@ function onCtx(n: FileNode, ev: MouseEvent) {
121
281
  emit('context-card', n, ev);
122
282
  }
123
283
 
284
+ /**
285
+ * gorunum:v1 — the ⋮ button. It calls the RIGHT-CLICK handler with the same
286
+ * node and the same event, so the menu it opens is the context menu, built by
287
+ * the explorer's one `selectionActionList`. There is deliberately no action
288
+ * list here: a second one drifts from the first the week after it is written
289
+ * (filex lesson #67).
290
+ */
291
+ function onMenuButton(n: FileNode, ev: MouseEvent) {
292
+ onCtx(n, ev);
293
+ }
294
+
124
295
  function onItemDragStart(n: FileNode, ev: DragEvent) {
125
296
  /* wiring:c4 — custom ghost (name + multi-select count badge), visual only. */
126
297
  applyDragGhost(
@@ -157,36 +328,32 @@ function onItemDrop(n: FileNode, ev: DragEvent) {
157
328
  emit('item-drop-into', n, ev);
158
329
  }
159
330
 
160
- let pressTimer: ReturnType<typeof setTimeout> | undefined;
161
- let pressTarget: FileNode | null = null;
162
- function onTouchStart(n: FileNode, ev: TouchEvent) {
163
- pressTarget = n;
164
- if (pressTimer) clearTimeout(pressTimer);
165
- pressTimer = setTimeout(() => {
166
- if (pressTarget) {
167
- const t0 = ev.touches[0];
168
- emit('context-card', pressTarget, {
169
- clientX: t0.clientX,
170
- clientY: t0.clientY,
171
- preventDefault: () => {},
172
- } as unknown as MouseEvent);
173
- }
174
- }, 500);
175
- }
176
- function cancelPress() {
177
- if (pressTimer) clearTimeout(pressTimer);
178
- pressTarget = null;
331
+ /* Long press the card's menu; a tap is reported as a tap (issue #26). */
332
+ const touch = useRowTouch<FileNode>((n, at) =>
333
+ emit('context-card', n, { ...at, preventDefault: () => {}, stopPropagation: () => {} } as unknown as MouseEvent),
334
+ );
335
+
336
+ /**
337
+ * The card's second line. A file prints "1.7 MB • Sep 9, 2026"; a folder
338
+ * prints what it is, because its size is 0 and its date is the date something
339
+ * inside it changed.
340
+ *
341
+ * Same epoch normalization the list and the gallery use (the backend has sent
342
+ * both seconds and milliseconds), and the same locale the rest of the card is
343
+ * drawn in — `toLocaleDateString` with the explorer's own tag, not the
344
+ * browser's, so a Turkish panel says "9 Eyl 2026" whatever the OS is set to.
345
+ */
346
+ function displayDate(ms: number | undefined): string {
347
+ return formatDate(ms);
179
348
  }
180
349
 
181
- function parentDir(path: string): string {
182
- const stripped = path.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '');
183
- const idx = stripped.lastIndexOf('/');
184
- if (idx === -1) return '';
185
- return stripped.slice(0, idx);
350
+ function captionFor(n: FileNode): string {
351
+ if (n.type === 'dir') return t('node.folder');
352
+ const when = displayDate(n.last_modified);
353
+ const size = formatSize(n.size);
354
+ return when ? `${size} • ${when}` : size;
186
355
  }
187
356
 
188
- // Special rows keep their emoji (trash/storage are not file-TYPE icons);
189
- // everything else renders the SVG icon set from lib/fileIcons.
190
357
  function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
191
358
  if (b === 'kept') return '\u2713';
192
359
  if (b === 'syncing') return '\u27f3';
@@ -194,13 +361,6 @@ function keepGlyph(b: 'kept' | 'syncing' | 'cloud' | 'partial'): string {
194
361
  return '\u2601';
195
362
  }
196
363
 
197
- function specialEmojiFor(n: FileNode): string | null {
198
- if (n.basename === '.trash') return '🗑';
199
- if (n.mime_type === 'inode/storage') return '💾';
200
- if (n.type === 'dir' && n.e2e === true) return '🔒'; /* wiring:e2 — encrypted-folder badge */
201
- return null;
202
- }
203
-
204
364
  /* bul:s3 — search-result enrichment, same presence-gating as ListView:
205
365
  * only search hits carry `snippet`, so normal listings render nothing. */
206
366
  function cardSnippet(n: FileNode): string {
@@ -236,6 +396,8 @@ function snippetTitle(snippet: string): string {
236
396
  <div
237
397
  class="fe-grid__card"
238
398
  :class="{
399
+ 'fe-grid__card--folder': n.type === 'dir' /* gorunum:v1 */,
400
+ 'fe-grid__card--file': n.type !== 'dir' /* gorunum:v1 */,
239
401
  'is-selected': isSelected(n),
240
402
  'is-dir': n.type === 'dir',
241
403
  'is-trash': n.trashed,
@@ -255,11 +417,53 @@ function snippetTitle(snippet: string): string {
255
417
  @dragover="onItemDragOver(n, $event)"
256
418
  @dragleave="onItemDragLeave(n) /* wiring:c4 */"
257
419
  @drop="onItemDrop(n, $event)"
258
- @touchstart.passive="onTouchStart(n, $event)"
259
- @touchend="cancelPress"
260
- @touchmove="cancelPress"
420
+ @touchstart.passive="touch.onTouchStart(n, $event)"
421
+ @touchend.passive="touch.onTouchEnd"
422
+ @touchmove.passive="touch.onTouchMove"
261
423
  >
262
- <div class="fe-grid__thumb">
424
+ <!-- gorunum:v1 — the preview, files only: 184×108. A thumbnail when
425
+ there is one, otherwise the type tile centred on --fe-bg-elev.
426
+ gorunum:v1-preview — and, for a file we can read as text, its own
427
+ first lines instead (see the block comment in the script). -->
428
+ <div v-if="n.type !== 'dir'" class="fe-grid__thumb" :ref="(el) => bindPreview(el, n)">
429
+ <!-- gorunum:v1-preview — text-readable kinds never reach the <img>
430
+ branch, so no generic placeholder thumbnail is fetched for them. -->
431
+ <template v-if="previewKind(n)">
432
+ <!-- ⚠ Interpolated, never v-html: this is somebody's file content. -->
433
+ <div
434
+ v-for="p in previewFor(n)"
435
+ :key="'fprev'"
436
+ class="fe-fprev"
437
+ :class="'fe-fprev--' + p.kind"
438
+ :style="p.kind === 'table' ? { '--fprev-cols': String(p.cols) } : undefined"
439
+ aria-hidden="true"
440
+ >
441
+ <!-- One grid, cells emitted flat: <template> makes no DOM, so a
442
+ short row's padding cells keep every column aligned. -->
443
+ <template v-if="p.kind === 'table'">
444
+ <template v-for="(row, ri) in p.rows" :key="ri">
445
+ <span
446
+ v-for="(cell, ci) in row"
447
+ :key="ci"
448
+ class="fe-fprev__cell"
449
+ :class="{ 'is-head': ri === 0 }"
450
+ >{{ cell }}</span>
451
+ </template>
452
+ </template>
453
+ <template v-else>
454
+ <div v-for="(ln, li) in p.lines" :key="li" class="fe-fprev__line"><span
455
+ v-if="ln.tint"
456
+ class="fe-fprev__tint"
457
+ >{{ ln.indent }}{{ ln.tint }}</span>{{ ln.text }}</div>
458
+ </template>
459
+ </div>
460
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
461
+ <span
462
+ v-if="previewFor(n).length === 0"
463
+ class="fe-grid__icon fe-grid__icon--svg"
464
+ v-html="fileIconTile(n)"
465
+ ></span>
466
+ </template>
263
467
  <!--
264
468
  draggable="false" — HTML5 image drag dataTransfer adds 'Files'
265
469
  MIME, which trips the parent's upload handler; without this
@@ -267,20 +471,41 @@ function snippetTitle(snippet: string): string {
267
471
  Parent card stays draggable=true so internal move works.
268
472
  -->
269
473
  <img
270
- v-if="thumbOf(n)"
474
+ v-else-if="thumbOf(n)"
271
475
  :src="thumbOf(n)!"
272
476
  :alt="n.basename"
477
+ :class="{ 'fe-thumb--page': drawsAsPage(n) /* gorunum:v1-preview — crop a page from its TOP */ }"
273
478
  loading="lazy"
274
479
  draggable="false"
275
480
  />
276
- <span v-else-if="specialEmojiFor(n)" class="fe-grid__icon">{{ specialEmojiFor(n) }}</span>
481
+ <!-- ikon:emoji an encrypted folder is still a FOLDER, so it keeps the
482
+ folder's own shape and colour with the padlock cut out of it; the
483
+ 🔒 it replaces said "locked" and nothing else. One definition,
484
+ in lib/fileIcons, for all three views. -->
485
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
486
+ <span
487
+ v-else-if="isEncryptedFolder(n)"
488
+ class="fe-grid__icon fe-grid__icon--svg"
489
+ role="img"
490
+ :aria-label="t('e2e.badge')"
491
+ v-html="encryptedFolderTile()"
492
+ ></span>
277
493
  <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
278
- <span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconSvg(n)"></span>
494
+ <span v-else class="fe-grid__icon fe-grid__icon--svg" v-html="fileIconTile(n)"></span>
279
495
  <!-- Star, ON the tile. A hover-only affordance would be invisible to
280
496
  the person looking for what they starred, so the chip is always
281
497
  painted once the file IS starred and only appears on hover/focus
282
498
  otherwise (see .fe-grid__star in styles/base.css). @click.stop so
283
499
  starring never doubles as a card selection. -->
500
+ <!-- gorunum:v1-preview — a frame lifted out of a video is, on a card,
501
+ indistinguishable from a photograph. The badge is the difference,
502
+ and it is drawn only over a real frame: a video that fell back to
503
+ its type tile already says what it is. -->
504
+ <span
505
+ v-if="!previewKind(n) && thumbOf(n) && drawsAsVideo(n)"
506
+ class="fe-thumb__play"
507
+ aria-hidden="true"
508
+ ></span>
284
509
  <div v-if="canStar(n)" class="fe-grid__star" @click.stop @dblclick.stop>
285
510
  <StarButton
286
511
  :starred="!!starredIds?.has(n.id!)"
@@ -295,30 +520,54 @@ function snippetTitle(snippet: string): string {
295
520
  />
296
521
  </div>
297
522
  </div>
298
- <div class="fe-grid__label" :title="n.basename">
299
- {{ nodeDisplayName(n) }}
523
+
524
+ <!-- gorunum:v1 — the row both cards share: tile · name over caption · ⋮.
525
+ On a file it is the 56px footer under the preview; on a folder it is
526
+ the whole 56px card. -->
527
+ <div class="fe-grid__foot">
528
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
300
529
  <span
301
- v-if="keepBadgeFor && keepBadgeFor(n)"
302
- :class="['fe-keepbadge', 'fe-keepbadge--' + keepBadgeFor(n)]"
303
- :title="t('keep.badge_' + keepBadgeFor(n))"
530
+ v-if="isEncryptedFolder(n)"
531
+ class="fe-grid__tile fe-grid__tile--svg"
304
532
  role="img"
305
- :aria-label="t('keep.badge_' + keepBadgeFor(n))"
306
- >{{ keepGlyph(keepBadgeFor(n)!) }}</span>
307
- </div>
308
- <div
309
- v-if="showParentPath"
310
- class="fe-grid__parent"
311
- :title="parentDir(n.path)"
312
- >{{ parentDir(n.path) || '—' }}</div>
313
- <div class="fe-grid__meta">
314
- {{ formatSize(n.size) }}
315
- </div>
316
- <!-- bul:s3 content snippet («» → <mark> via TEXT segments, no innerHTML) -->
317
- <div v-if="cardSnippet(n)" class="fe-grid__snippet" :title="snippetTitle(cardSnippet(n))">
318
- <template v-for="(seg, si) in snippetSegments(cardSnippet(n))" :key="si">
319
- <mark v-if="seg.match" class="fe-grid__mark">{{ seg.text }}</mark>
320
- <template v-else>{{ seg.text }}</template>
321
- </template>
533
+ :aria-label="t('e2e.badge')"
534
+ v-html="encryptedFolderTile()"
535
+ ></span>
536
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
537
+ <span v-else class="fe-grid__tile fe-grid__tile--svg" v-html="fileIconTile(n)"></span>
538
+ <div class="fe-grid__main">
539
+ <div class="fe-grid__label" :title="n.basename">
540
+ {{ nodeDisplayName(n) }}
541
+ <span
542
+ v-if="keepBadgeFor && keepBadgeFor(n)"
543
+ :class="['fe-keepbadge', 'fe-keepbadge--' + keepBadgeFor(n)]"
544
+ :title="t('keep.badge_' + keepBadgeFor(n))"
545
+ role="img"
546
+ :aria-label="t('keep.badge_' + keepBadgeFor(n))"
547
+ >{{ keepGlyph(keepBadgeFor(n)!) }}</span>
548
+ </div>
549
+ <div class="fe-grid__meta">{{ captionFor(n) }}</div>
550
+ <div
551
+ v-if="showParentPath"
552
+ class="fe-grid__parent"
553
+ :title="parentDirOf(n.path)"
554
+ >{{ parentDirOf(n.path) || '—' }}</div>
555
+ <!-- bul:s3 — content snippet («» → <mark> via TEXT segments, no innerHTML) -->
556
+ <div v-if="cardSnippet(n)" class="fe-grid__snippet" :title="snippetTitle(cardSnippet(n))">
557
+ <template v-for="(seg, si) in snippetSegments(cardSnippet(n))" :key="si">
558
+ <mark v-if="seg.match" class="fe-grid__mark">{{ seg.text }}</mark>
559
+ <template v-else>{{ seg.text }}</template>
560
+ </template>
561
+ </div>
562
+ </div>
563
+ <button
564
+ type="button"
565
+ class="fe-grid__menu"
566
+ :aria-label="t('toolbar.more')"
567
+ :title="t('toolbar.more')"
568
+ @click.stop="onMenuButton(n, $event)"
569
+ @dblclick.stop
570
+ >⋮</button>
322
571
  </div>
323
572
  </div>
324
573
  </template>