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