@brftech/filex-core 0.1.82 → 0.1.84

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 (43) hide show
  1. package/README.md +2 -0
  2. package/dist/{ArchiveViewer-BRASXNIS.js → ArchiveViewer-wg4uE-3C.js} +2 -2
  3. package/dist/{ArchiveViewer-BRASXNIS.js.map → ArchiveViewer-wg4uE-3C.js.map} +1 -1
  4. package/dist/{CsvViewer-CgiLiHWw.js → CsvViewer-57uz4UKk.js} +2 -2
  5. package/dist/{CsvViewer-CgiLiHWw.js.map → CsvViewer-57uz4UKk.js.map} +1 -1
  6. package/dist/{DrawioViewer-D2I_uEng.js → DrawioViewer-BfL9Hbyp.js} +2 -2
  7. package/dist/{DrawioViewer-D2I_uEng.js.map → DrawioViewer-BfL9Hbyp.js.map} +1 -1
  8. package/dist/{EpubViewer-6nVPRR6R.js → EpubViewer-CYG2CV_3.js} +2 -2
  9. package/dist/{EpubViewer-6nVPRR6R.js.map → EpubViewer-CYG2CV_3.js.map} +1 -1
  10. package/dist/{IpynbViewer-B7p9hxFx.js → IpynbViewer-DJw34-wV.js} +2 -2
  11. package/dist/{IpynbViewer-B7p9hxFx.js.map → IpynbViewer-DJw34-wV.js.map} +1 -1
  12. package/dist/{MermaidViewer-O9vGvp4y.js → MermaidViewer-C86jjm27.js} +2 -2
  13. package/dist/{MermaidViewer-O9vGvp4y.js.map → MermaidViewer-C86jjm27.js.map} +1 -1
  14. package/dist/{PsdViewer-C6EaEAxF.js → PsdViewer-DGHIqv-x.js} +2 -2
  15. package/dist/{PsdViewer-C6EaEAxF.js.map → PsdViewer-DGHIqv-x.js.map} +1 -1
  16. package/dist/{TiffViewer-Dod4uIXI.js → TiffViewer-gct9Ub3o.js} +2 -2
  17. package/dist/{TiffViewer-Dod4uIXI.js.map → TiffViewer-gct9Ub3o.js.map} +1 -1
  18. package/dist/{Viewer3D-B3kLZrwO.js → Viewer3D-BVt4l2a9.js} +2 -2
  19. package/dist/{Viewer3D-B3kLZrwO.js.map → Viewer3D-BVt4l2a9.js.map} +1 -1
  20. package/dist/filex-core.js +1 -1
  21. package/dist/filex-core.umd.cjs +38 -38
  22. package/dist/filex-core.umd.cjs.map +1 -1
  23. package/dist/index-BeUoODQq.js +6871 -0
  24. package/dist/index-BeUoODQq.js.map +1 -0
  25. package/dist/index.d.ts +6 -0
  26. package/dist/style.css +1 -1
  27. package/package.json +1 -1
  28. package/src/FileExplorer.vue +393 -31
  29. package/src/components/Breadcrumb.vue +100 -5
  30. package/src/components/CommandPalette.vue +299 -0
  31. package/src/components/GridView.vue +8 -13
  32. package/src/components/ListView.vue +173 -20
  33. package/src/components/ShortcutsHelp.vue +59 -0
  34. package/src/components/Toolbar.vue +65 -1
  35. package/src/composables/useKeyboardShortcuts.ts +38 -0
  36. package/src/composables/useRealtime.ts +9 -1
  37. package/src/lib/fileIcons.ts +127 -0
  38. package/src/locales/en.ts +54 -0
  39. package/src/locales/tr.ts +54 -0
  40. package/src/styles/base.css +565 -0
  41. package/src/styles/variables.css +70 -0
  42. package/dist/index-DL6_eaM3.js +0 -5888
  43. package/dist/index-DL6_eaM3.js.map +0 -1
@@ -6,10 +6,11 @@
6
6
  * just emit the events and let FileExplorer.vue handle them uniformly
7
7
  * across List and Grid.
8
8
  */
9
- import { computed } from 'vue';
9
+ import { computed, ref } from 'vue';
10
10
  import type { FileNode } from '../types/FileNode';
11
11
  import type { LocaleCode } from '../types/ExplorerConfig';
12
12
  import { useLocale } from '../composables/useLocale';
13
+ import { fileIconSvg } from '../lib/fileIcons';
13
14
  import StarButton from './StarButton.vue';
14
15
 
15
16
  const props = defineProps<{
@@ -111,21 +112,16 @@ function cancelPress() {
111
112
  pressTarget = null;
112
113
  }
113
114
 
114
- function iconFor(n: FileNode): string {
115
+ // Special rows keep their emoji (trash/storage are not file-TYPE icons);
116
+ // everything else renders the SVG icon set from lib/fileIcons.
117
+ function specialEmojiFor(n: FileNode): string | null {
115
118
  if (n.basename === '.trash') return '🗑';
116
119
  if (n.mime_type === 'inode/storage') return '💾';
117
- if (n.type === 'dir') return '📁';
118
- const e = (n.extension || '').toLowerCase();
119
- if (['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'avif', 'heic', 'svg'].includes(e)) return '🖼';
120
- if (['mp4', 'webm', 'mov', 'mkv', 'avi', 'ogv', 'm4v'].includes(e)) return '🎞';
121
- if (['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus'].includes(e)) return '🎵';
122
- if (e === 'pdf') return '📕';
123
- if (['doc', 'docx', 'odt', 'rtf'].includes(e)) return '📄';
124
- if (['xls', 'xlsx', 'ods', 'csv'].includes(e)) return '📊';
125
- if (['ppt', 'pptx', 'odp'].includes(e)) return '📽';
126
- if (['zip', 'tar', 'gz', 'bz2', '7z', 'rar'].includes(e)) return '🗜';
127
- if (['txt', 'md', 'log', 'json', 'yml', 'yaml', 'xml'].includes(e)) return '📝';
128
- return '📎';
120
+ return null;
121
+ }
122
+
123
+ function isPinnedSpecial(n: FileNode): boolean {
124
+ return n.basename === '.trash' || n.mime_type === 'inode/storage';
129
125
  }
130
126
 
131
127
  function displayDate(ms: number | undefined): string {
@@ -141,20 +137,174 @@ function parentDir(path: string): string {
141
137
  return stripped.slice(0, idx);
142
138
  }
143
139
 
144
- const rows = computed(() => props.files);
140
+ // ------------------------------------------------------------------
141
+ // Column sorting — local to the list view. Default (null) keeps the
142
+ // backend order, i.e. exactly the pre-existing behavior.
143
+ // ------------------------------------------------------------------
144
+
145
+ type SortKey = 'name' | 'size' | 'modified';
146
+ const SORT_LS_KEY = 'filex.list-sort';
147
+
148
+ const sortKey = ref<SortKey | null>(null);
149
+ const sortDir = ref<'asc' | 'desc'>('asc');
150
+ try {
151
+ const raw = localStorage.getItem(SORT_LS_KEY);
152
+ if (raw) {
153
+ const p = JSON.parse(raw) as { key?: unknown; dir?: unknown };
154
+ if (p.key === 'name' || p.key === 'size' || p.key === 'modified') sortKey.value = p.key;
155
+ if (p.dir === 'asc' || p.dir === 'desc') sortDir.value = p.dir;
156
+ }
157
+ } catch {
158
+ /* private mode / bad JSON */
159
+ }
160
+
161
+ // Dates default to newest-first, the others to ascending.
162
+ function defaultDir(key: SortKey): 'asc' | 'desc' {
163
+ return key === 'modified' ? 'desc' : 'asc';
164
+ }
165
+
166
+ // Click cycle per column: default direction → flipped → back to backend order.
167
+ function toggleSort(key: SortKey) {
168
+ if (sortKey.value !== key) {
169
+ sortKey.value = key;
170
+ sortDir.value = defaultDir(key);
171
+ } else if (sortDir.value === defaultDir(key)) {
172
+ sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
173
+ } else {
174
+ sortKey.value = null;
175
+ }
176
+ try {
177
+ localStorage.setItem(
178
+ SORT_LS_KEY,
179
+ JSON.stringify({ key: sortKey.value, dir: sortDir.value }),
180
+ );
181
+ } catch {
182
+ /* quota */
183
+ }
184
+ }
185
+
186
+ function ariaSort(key: SortKey): 'ascending' | 'descending' | 'none' {
187
+ if (sortKey.value !== key) return 'none';
188
+ return sortDir.value === 'asc' ? 'ascending' : 'descending';
189
+ }
190
+
191
+ function modifiedMs(n: FileNode): number | null {
192
+ const v = n.last_modified;
193
+ if (!v) return null;
194
+ return v * (v < 1e12 ? 1000 : 1);
195
+ }
196
+
197
+ const rows = computed(() => {
198
+ const key = sortKey.value;
199
+ if (!key) return props.files;
200
+ // Virtual rows (.trash / storage drives) stay pinned on top in their
201
+ // original order; only real entries take part in the sort.
202
+ const pinned: FileNode[] = [];
203
+ const rest: FileNode[] = [];
204
+ for (const n of props.files) (isPinnedSpecial(n) ? pinned : rest).push(n);
205
+ const dir = sortDir.value === 'asc' ? 1 : -1;
206
+ rest.sort((a, b) => {
207
+ if (key === 'name') {
208
+ return (
209
+ dir *
210
+ a.basename.localeCompare(b.basename, undefined, { numeric: true, sensitivity: 'base' })
211
+ );
212
+ }
213
+ if (key === 'size') return dir * ((a.size ?? -1) - (b.size ?? -1));
214
+ const am = modifiedMs(a);
215
+ const bm = modifiedMs(b);
216
+ // Undated rows go last in both directions so date groups stay clean.
217
+ if (am == null && bm == null) return 0;
218
+ if (am == null) return 1;
219
+ if (bm == null) return -1;
220
+ return dir * (am - bm);
221
+ });
222
+ return [...pinned, ...rest];
223
+ });
224
+
225
+ // ------------------------------------------------------------------
226
+ // Date grouping — only while sorted by modified date. Rows are split
227
+ // into contiguous segments so the row markup below stays untouched.
228
+ // ------------------------------------------------------------------
229
+
230
+ interface Segment {
231
+ id: string;
232
+ /** null = no header (ungrouped mode, or the pinned virtual rows). */
233
+ label: string | null;
234
+ nodes: FileNode[];
235
+ }
236
+
237
+ const monthFmt = computed(
238
+ () =>
239
+ new Intl.DateTimeFormat(props.locale === 'tr' ? 'tr-TR' : 'en-US', {
240
+ month: 'long',
241
+ year: 'numeric',
242
+ }),
243
+ );
244
+
245
+ function bucketFor(ms: number | null): { id: string; label: string } {
246
+ if (ms == null) return { id: 'none', label: t('group.no_date') };
247
+ const d = new Date(ms);
248
+ const now = new Date();
249
+ const dayStart = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
250
+ const today = dayStart(now);
251
+ const day = dayStart(d);
252
+ const DAY = 86_400_000;
253
+ // Future stamps (clock skew) read best as "today".
254
+ if (day >= today) return { id: 'today', label: t('group.today') };
255
+ if (day === today - DAY) return { id: 'yesterday', label: t('group.yesterday') };
256
+ if (day > today - 7 * DAY) return { id: 'week', label: t('group.this_week') };
257
+ if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) {
258
+ return { id: 'month', label: t('group.this_month') };
259
+ }
260
+ return { id: `m-${d.getFullYear()}-${d.getMonth()}`, label: monthFmt.value.format(d) };
261
+ }
262
+
263
+ const segments = computed<Segment[]>(() => {
264
+ const list = rows.value;
265
+ if (sortKey.value !== 'modified') return [{ id: 'all', label: null, nodes: list }];
266
+ const out: Segment[] = [];
267
+ let cur: Segment | null = null;
268
+ for (const n of list) {
269
+ const b = isPinnedSpecial(n) ? { id: 'pinned', label: null } : bucketFor(modifiedMs(n));
270
+ if (!cur || cur.id !== b.id) {
271
+ cur = { id: b.id, label: b.label, nodes: [] };
272
+ out.push(cur);
273
+ }
274
+ cur.nodes.push(n);
275
+ }
276
+ return out;
277
+ });
145
278
  </script>
146
279
 
147
280
  <template>
148
281
  <div class="fe-list" :class="{ 'is-loading': loading, 'has-star-col': !!apiBase || apiBase === '' }">
149
282
  <div class="fe-list__head" role="row">
150
283
  <div class="fe-list__col fe-list__col--star" role="columnheader" aria-label="Star"></div>
151
- <div class="fe-list__col fe-list__col--name" role="columnheader">{{ t('col.name') }}</div>
152
- <div class="fe-list__col fe-list__col--size" role="columnheader">{{ t('col.size') }}</div>
153
- <div class="fe-list__col fe-list__col--mod" role="columnheader">{{ t('col.modified') }}</div>
284
+ <div class="fe-list__col fe-list__col--name" role="columnheader" :aria-sort="ariaSort('name')">
285
+ <button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('name')">
286
+ {{ t('col.name') }}
287
+ <span v-if="sortKey === 'name'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
288
+ </button>
289
+ </div>
290
+ <div class="fe-list__col fe-list__col--size" role="columnheader" :aria-sort="ariaSort('size')">
291
+ <button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('size')">
292
+ {{ t('col.size') }}
293
+ <span v-if="sortKey === 'size'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
294
+ </button>
295
+ </div>
296
+ <div class="fe-list__col fe-list__col--mod" role="columnheader" :aria-sort="ariaSort('modified')">
297
+ <button type="button" class="fe-list__sort" :title="t('col.sort')" @click="toggleSort('modified')">
298
+ {{ t('col.modified') }}
299
+ <span v-if="sortKey === 'modified'" class="fe-list__sort-arrow" aria-hidden="true">{{ sortDir === 'asc' ? '↑' : '↓' }}</span>
300
+ </button>
301
+ </div>
154
302
  </div>
155
303
  <div class="fe-list__body" role="rowgroup">
304
+ <template v-for="seg in segments" :key="seg.id">
305
+ <div v-if="seg.label" class="fe-list__group" role="presentation">{{ seg.label }}</div>
156
306
  <div
157
- v-for="n in rows"
307
+ v-for="n in seg.nodes"
158
308
  :key="n.path"
159
309
  class="fe-list__row"
160
310
  :class="{
@@ -188,7 +338,9 @@ const rows = computed(() => props.files);
188
338
  />
189
339
  </div>
190
340
  <div class="fe-list__col fe-list__col--name">
191
- <span class="fe-list__icon" aria-hidden="true">{{ iconFor(n) }}</span>
341
+ <span v-if="specialEmojiFor(n)" class="fe-list__icon" aria-hidden="true">{{ specialEmojiFor(n) }}</span>
342
+ <!-- eslint-disable-next-line vue/no-v-html — static markup from lib/fileIcons -->
343
+ <span v-else class="fe-list__icon fe-list__icon--svg" aria-hidden="true" v-html="fileIconSvg(n)"></span>
192
344
  <div class="fe-list__name-wrap">
193
345
  <span class="fe-list__name" :title="n.basename">{{ nodeDisplayName(n) }}</span>
194
346
  <span
@@ -205,6 +357,7 @@ const rows = computed(() => props.files);
205
357
  {{ displayDate(n.last_modified) }}
206
358
  </div>
207
359
  </div>
360
+ </template>
208
361
  <div v-if="!loading && rows.length === 0" class="fe-list__empty">
209
362
  {{ t('empty.folder') }}
210
363
  </div>
@@ -0,0 +1,59 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * ShortcutsHelp — "?" cheat-sheet modal.
4
+ *
5
+ * Renders the SHORTCUTS table exported by useKeyboardShortcuts (single
6
+ * source of truth) grouped by section. Modal.vue supplies Esc handling,
7
+ * backdrop, focus and the close button.
8
+ */
9
+ import { computed } from 'vue';
10
+ import type { LocaleCode } from '../types/ExplorerConfig';
11
+ import { useLocale } from '../composables/useLocale';
12
+ import { SHORTCUTS, type ShortcutDef } from '../composables/useKeyboardShortcuts';
13
+ import Modal from '../modals/Modal.vue';
14
+
15
+ const props = defineProps<{
16
+ open: boolean;
17
+ locale: LocaleCode;
18
+ }>();
19
+
20
+ const emit = defineEmits<{
21
+ (e: 'close'): void;
22
+ }>();
23
+
24
+ const { t } = useLocale(() => props.locale);
25
+
26
+ const groups = computed(() => {
27
+ const order: string[] = [];
28
+ const map = new Map<string, ShortcutDef[]>();
29
+ for (const s of SHORTCUTS) {
30
+ if (!map.has(s.groupKey)) {
31
+ map.set(s.groupKey, []);
32
+ order.push(s.groupKey);
33
+ }
34
+ map.get(s.groupKey)!.push(s);
35
+ }
36
+ return order.map((key) => ({ key, items: map.get(key)! }));
37
+ });
38
+ </script>
39
+
40
+ <template>
41
+ <Modal :open="open" :title="t('shortcuts.title')" size="md" @close="emit('close')">
42
+ <div class="fe-shortcuts">
43
+ <section v-for="g in groups" :key="g.key" class="fe-shortcuts__group">
44
+ <h3 class="fe-shortcuts__heading">{{ t(g.key) }}</h3>
45
+ <div class="fe-shortcuts__table">
46
+ <div v-for="s in g.items" :key="s.labelKey" class="fe-shortcuts__row">
47
+ <span class="fe-shortcuts__keys">
48
+ <template v-for="(combo, i) in s.keys" :key="combo">
49
+ <kbd class="fe-kbd">{{ combo }}</kbd>
50
+ <span v-if="i < s.keys.length - 1" class="fe-shortcuts__or" aria-hidden="true">/</span>
51
+ </template>
52
+ </span>
53
+ <span class="fe-shortcuts__desc">{{ t(s.labelKey) }}</span>
54
+ </div>
55
+ </div>
56
+ </section>
57
+ </div>
58
+ </Modal>
59
+ </template>
@@ -11,7 +11,7 @@
11
11
  * Presentational only — all logic (rename, share, …) lives in
12
12
  * FileExplorer.vue, which listens for `action` emits.
13
13
  */
14
- import { computed, ref, watch } from 'vue';
14
+ import { computed, onMounted, ref, watch } from 'vue';
15
15
  import type { ViewMode } from '../types/FileNode';
16
16
  import type { LocaleCode } from '../types/ExplorerConfig';
17
17
  import type { ContextAction } from './ContextMenu.vue';
@@ -69,8 +69,34 @@ const emit = defineEmits<{
69
69
  (e: 'go-up'): void;
70
70
  (e: 'action', key: string): void;
71
71
  (e: 'open-recents'): void;
72
+ (e: 'update:density', v: Density): void;
72
73
  }>();
73
74
 
75
+ // Density toggle — the toolbar owns the persisted preference; the parent
76
+ // only mirrors the value into a root class so both views pick it up.
77
+ export type Density = 'comfortable' | 'compact';
78
+ const DENSITY_LS_KEY = 'filex.density';
79
+ const density = ref<Density>(
80
+ (() => {
81
+ try {
82
+ return localStorage.getItem(DENSITY_LS_KEY) === 'compact' ? 'compact' : 'comfortable';
83
+ } catch {
84
+ return 'comfortable';
85
+ }
86
+ })(),
87
+ );
88
+ onMounted(() => emit('update:density', density.value));
89
+
90
+ function toggleDensity() {
91
+ density.value = density.value === 'compact' ? 'comfortable' : 'compact';
92
+ try {
93
+ localStorage.setItem(DENSITY_LS_KEY, density.value);
94
+ } catch {
95
+ /* quota */
96
+ }
97
+ emit('update:density', density.value);
98
+ }
99
+
74
100
  const { t } = useLocale(() => props.locale);
75
101
 
76
102
  const searchEl = ref<HTMLInputElement | null>(null);
@@ -192,6 +218,44 @@ function fire(key: string) {
192
218
  </button>
193
219
  </div>
194
220
 
221
+ <button
222
+ type="button"
223
+ class="fe-btn fe-btn--icon-only fe-toolbar__density"
224
+ :class="{ 'is-active': density === 'compact' }"
225
+ :aria-pressed="density === 'compact'"
226
+ :aria-label="density === 'compact' ? t('toolbar.density.comfortable') : t('toolbar.density.compact')"
227
+ :title="density === 'compact' ? t('toolbar.density.comfortable') : t('toolbar.density.compact')"
228
+ @click="toggleDensity"
229
+ >
230
+ <!-- Row-spacing glyph: tight rows when compact is ON, airy rows otherwise. -->
231
+ <svg
232
+ v-if="density === 'compact'"
233
+ class="fe-ficon"
234
+ viewBox="0 0 24 24"
235
+ fill="none"
236
+ stroke="currentColor"
237
+ stroke-width="1.8"
238
+ stroke-linecap="round"
239
+ aria-hidden="true"
240
+ focusable="false"
241
+ >
242
+ <path d="M4 6.5h16M4 10.5h16M4 14.5h16M4 18.5h16" />
243
+ </svg>
244
+ <svg
245
+ v-else
246
+ class="fe-ficon"
247
+ viewBox="0 0 24 24"
248
+ fill="none"
249
+ stroke="currentColor"
250
+ stroke-width="1.8"
251
+ stroke-linecap="round"
252
+ aria-hidden="true"
253
+ focusable="false"
254
+ >
255
+ <path d="M4 5.5h16M4 12h16M4 18.5h16" />
256
+ </svg>
257
+ </button>
258
+
195
259
  <div class="fe-toolbar__view" role="tablist" aria-label="View">
196
260
  <button
197
261
  type="button"
@@ -21,9 +21,41 @@ export interface ShortcutHandlers {
21
21
  onDuplicate?: () => void; // Ctrl+D
22
22
  onPathJump?: () => void; // Cmd+K / Ctrl+K
23
23
  onGoUp?: () => void; // Alt+Up / Backspace (when nothing selected)
24
+ onShowHelp?: () => void; // ? (Shift+/ on most layouts)
24
25
  hasSelection?: () => boolean; // disambiguates Backspace
25
26
  }
26
27
 
28
+ /**
29
+ * SHORTCUTS — the single source of truth for the shortcut cheat-sheet
30
+ * (ShortcutsHelp.vue). Only shortcuts actually wired by FileExplorer are
31
+ * listed; labels/groups resolve through the locale catalogue so the help
32
+ * dialog stays tr/en aware. `keys` entries are display combos — each one
33
+ * renders as its own <kbd>, alternatives joined visually with "/".
34
+ */
35
+ export interface ShortcutDef {
36
+ keys: string[];
37
+ labelKey: string;
38
+ groupKey: string;
39
+ }
40
+
41
+ export const SHORTCUTS: ShortcutDef[] = [
42
+ // Navigation
43
+ { keys: ['Ctrl+K'], labelKey: 'shortcuts.palette', groupKey: 'shortcuts.group.nav' },
44
+ { keys: ['/'], labelKey: 'shortcuts.search', groupKey: 'shortcuts.group.nav' },
45
+ { keys: ['Alt+↑', 'Backspace'], labelKey: 'shortcuts.go_up', groupKey: 'shortcuts.group.nav' },
46
+ { keys: ['Enter'], labelKey: 'shortcuts.open', groupKey: 'shortcuts.group.nav' },
47
+ { keys: ['Esc'], labelKey: 'shortcuts.close', groupKey: 'shortcuts.group.nav' },
48
+ { keys: ['?'], labelKey: 'shortcuts.help', groupKey: 'shortcuts.group.nav' },
49
+ // Selection
50
+ { keys: ['Ctrl+A'], labelKey: 'shortcuts.select_all', groupKey: 'shortcuts.group.selection' },
51
+ // File operations
52
+ { keys: ['F2'], labelKey: 'shortcuts.rename', groupKey: 'shortcuts.group.file' },
53
+ { keys: ['Del'], labelKey: 'shortcuts.delete', groupKey: 'shortcuts.group.file' },
54
+ { keys: ['Ctrl+X'], labelKey: 'shortcuts.cut', groupKey: 'shortcuts.group.file' },
55
+ { keys: ['Ctrl+C'], labelKey: 'shortcuts.copy', groupKey: 'shortcuts.group.file' },
56
+ { keys: ['Ctrl+V'], labelKey: 'shortcuts.paste', groupKey: 'shortcuts.group.file' },
57
+ ];
58
+
27
59
  export function useKeyboardShortcuts(rootEl: Ref<HTMLElement | null>, handlers: ShortcutHandlers) {
28
60
  function onKey(e: KeyboardEvent) {
29
61
  const root = rootEl.value;
@@ -87,6 +119,12 @@ export function useKeyboardShortcuts(rootEl: Ref<HTMLElement | null>, handlers:
87
119
  handlers.onFocusSearch();
88
120
  }
89
121
  break;
122
+ case '?':
123
+ if (handlers.onShowHelp && !ctrl && !e.altKey) {
124
+ e.preventDefault();
125
+ handlers.onShowHelp();
126
+ }
127
+ break;
90
128
  case 'a':
91
129
  case 'A':
92
130
  if (ctrl && handlers.onSelectAll) {
@@ -19,6 +19,12 @@ export interface RealtimeApi {
19
19
  export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
20
20
  const presenceUsers: Ref<PresenceUser[]> = ref([]);
21
21
  const connected = ref(false);
22
+ // True while the live socket is unavailable and the polling fallback is
23
+ // active — the explorer surfaces a small "no live connection" badge from it.
24
+ // Stays false during ordinary reconnect attempts, so brief blips don't flash
25
+ // the badge; only a genuinely given-up socket (RealtimeClient onFallback)
26
+ // flips it.
27
+ const degraded = ref(false);
22
28
 
23
29
  let client: RealtimeClient | null = null;
24
30
  let pendingSubscribe: string | null = null;
@@ -42,6 +48,7 @@ export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
42
48
  }
43
49
 
44
50
  function onFallback(active: boolean): void {
51
+ degraded.value = active;
45
52
  if (active) {
46
53
  // No live socket → poll the listing; presence isn't available here.
47
54
  presenceUsers.value = [];
@@ -88,7 +95,8 @@ export function useRealtime(api: RealtimeApi, opts: { reload: () => void }) {
88
95
  }
89
96
  client?.close();
90
97
  client = null;
98
+ degraded.value = false;
91
99
  }
92
100
 
93
- return { presenceUsers, connected, start, subscribe, setFocus, stop };
101
+ return { presenceUsers, connected, degraded, start, subscribe, setFocus, stop };
94
102
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * fileIcons — extension/type → inline SVG icon mapping.
3
+ *
4
+ * Replaces the old emoji maps in GridView/ListView with a minimalist
5
+ * line-icon set. Icons are hand-drawn 24×24 strokes on `currentColor`;
6
+ * each family gets an accent via `--fe-icon-<family>` (variables.css),
7
+ * applied through the `fe-ficon--<family>` class in base.css.
8
+ *
9
+ * Scope: file-TYPE icons only. Action emojis (trash/star/…) and the
10
+ * special `.trash` / storage rows stay emoji — see the views.
11
+ */
12
+
13
+ export type IconFamily =
14
+ | 'folder'
15
+ | 'image'
16
+ | 'video'
17
+ | 'audio'
18
+ | 'pdf'
19
+ | 'doc'
20
+ | 'sheet'
21
+ | 'slides'
22
+ | 'archive'
23
+ | 'code'
24
+ | 'text'
25
+ | 'unknown';
26
+
27
+ const EXT_FAMILIES: Record<string, IconFamily> = {};
28
+
29
+ function reg(family: IconFamily, exts: string[]) {
30
+ for (const e of exts) EXT_FAMILIES[e] = family;
31
+ }
32
+
33
+ reg('image', ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'avif', 'heic', 'svg', 'ico', 'tiff', 'tif']);
34
+ reg('video', ['mp4', 'webm', 'mov', 'mkv', 'avi', 'ogv', 'm4v']);
35
+ reg('audio', ['mp3', 'wav', 'flac', 'ogg', 'm4a', 'aac', 'opus']);
36
+ reg('pdf', ['pdf']);
37
+ reg('doc', ['doc', 'docx', 'odt', 'rtf']);
38
+ reg('sheet', ['xls', 'xlsx', 'ods', 'csv']);
39
+ reg('slides', ['ppt', 'pptx', 'odp']);
40
+ reg('archive', ['zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'xz', 'zst']);
41
+ reg('code', [
42
+ 'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs', 'vue', 'py', 'go', 'rs', 'php', 'rb',
43
+ 'java', 'kt', 'swift', 'c', 'cpp', 'h', 'hpp', 'cs', 'css', 'scss', 'less',
44
+ 'html', 'htm', 'json', 'yml', 'yaml', 'xml', 'sh', 'bash', 'ps1', 'sql', 'toml',
45
+ ]);
46
+ reg('text', ['txt', 'md', 'markdown', 'log', 'ini', 'conf', 'cfg', 'env']);
47
+
48
+ /** Pick the icon family for a listing node. */
49
+ export function iconFamilyFor(node: {
50
+ type?: string;
51
+ extension?: string | null;
52
+ }): IconFamily {
53
+ if (node.type === 'dir') return 'folder';
54
+ const ext = (node.extension || '').toLowerCase();
55
+ return EXT_FAMILIES[ext] ?? 'unknown';
56
+ }
57
+
58
+ // Shared fragments. The document families reuse one sheet-with-fold base so
59
+ // the set reads as one system; media families get standalone shapes.
60
+ const FILE_BASE =
61
+ '<path d="M6.5 3h7L18.5 8v11.5a1.5 1.5 0 0 1-1.5 1.5H6.5A1.5 1.5 0 0 1 5 19.5v-15A1.5 1.5 0 0 1 6.5 3z"/>' +
62
+ '<path d="M13.5 3v5h5"/>';
63
+
64
+ const GLYPHS: Record<IconFamily, string> = {
65
+ // Folder is the one "filled" member — a soft currentColor wash keeps it
66
+ // visually anchored without breaking the line style.
67
+ folder:
68
+ '<path d="M3 7a2 2 0 0 1 2-2h4.2l2 2.4H19a2 2 0 0 1 2 2V17a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" fill="currentColor" fill-opacity="0.14"/>',
69
+ image:
70
+ '<rect x="3" y="5" width="18" height="14" rx="2"/>' +
71
+ '<circle cx="8.6" cy="10" r="1.6"/>' +
72
+ '<path d="M5.2 16.6l4.3-4.3 3 3 2.4-2.4 3.9 3.7"/>',
73
+ video:
74
+ '<rect x="3" y="5" width="18" height="14" rx="2"/>' +
75
+ '<path d="M10.2 9.3v5.4l4.8-2.7z" fill="currentColor" stroke="none"/>',
76
+ audio:
77
+ '<path d="M9 17.5V7.2l9-2.2v10.4"/>' +
78
+ '<circle cx="6.8" cy="17.6" r="2.2"/>' +
79
+ '<circle cx="15.8" cy="15.5" r="2.2"/>',
80
+ pdf:
81
+ FILE_BASE +
82
+ '<rect x="7.5" y="12" width="9" height="5" rx="1" fill="currentColor" fill-opacity="0.14"/>',
83
+ doc:
84
+ FILE_BASE +
85
+ '<path d="M8.5 13h7M8.5 16h7"/>',
86
+ sheet:
87
+ FILE_BASE +
88
+ '<path d="M8 12.5h8M8 15.5h8M8 18h8M12 12.5V18"/>',
89
+ slides:
90
+ FILE_BASE +
91
+ '<rect x="8" y="12.5" width="8" height="5" rx="0.8"/>',
92
+ archive:
93
+ FILE_BASE +
94
+ '<path d="M10 3v1.6M10 6.6v1.6M10 10.2v1.6"/>' +
95
+ '<rect x="8.6" y="14" width="2.8" height="3.4" rx="0.8"/>',
96
+ code:
97
+ FILE_BASE +
98
+ '<path d="M10.4 12.5L8.3 15l2.1 2.5M13.6 12.5l2.1 2.5-2.1 2.5"/>',
99
+ text:
100
+ FILE_BASE +
101
+ '<path d="M8.5 12h7M8.5 15h7M8.5 18h4.5"/>',
102
+ unknown:
103
+ FILE_BASE +
104
+ '<path d="M10.3 13.1a1.8 1.8 0 1 1 2.5 1.9c-.6.3-.8.7-.8 1.4"/>' +
105
+ '<circle cx="12" cy="18.4" r="0.9" fill="currentColor" stroke="none"/>',
106
+ };
107
+
108
+ const SVG_CACHE = new Map<IconFamily, string>();
109
+
110
+ /** Inline SVG markup for a family (safe static strings — v-html friendly). */
111
+ export function iconSvg(family: IconFamily): string {
112
+ let svg = SVG_CACHE.get(family);
113
+ if (!svg) {
114
+ svg =
115
+ `<svg class="fe-ficon fe-ficon--${family}" viewBox="0 0 24 24" fill="none" ` +
116
+ 'stroke="currentColor" stroke-width="1.6" stroke-linecap="round" ' +
117
+ 'stroke-linejoin="round" aria-hidden="true" focusable="false">' +
118
+ `${GLYPHS[family]}</svg>`;
119
+ SVG_CACHE.set(family, svg);
120
+ }
121
+ return svg;
122
+ }
123
+
124
+ /** Convenience: node → SVG markup in one call. */
125
+ export function fileIconSvg(node: { type?: string; extension?: string | null }): string {
126
+ return iconSvg(iconFamilyFor(node));
127
+ }
package/src/locales/en.ts CHANGED
@@ -133,4 +133,58 @@ export const en: Record<string, string> = {
133
133
  'viewer.archive.error': 'Could not read archive contents.',
134
134
  'viewer.name': 'Name',
135
135
  'viewer.size': 'Size',
136
+
137
+ // ——— cila:a — sorting / date groups / density ———
138
+ 'col.sort': 'Sort',
139
+ 'group.today': 'Today',
140
+ 'group.yesterday': 'Yesterday',
141
+ 'group.this_week': 'This Week',
142
+ 'group.this_month': 'This Month',
143
+ 'group.no_date': 'No date',
144
+ 'toolbar.density.compact': 'Compact view',
145
+ 'toolbar.density.comfortable': 'Comfortable view',
146
+
147
+ // ——— cila:c — command palette + shortcuts help + breadcrumb ———
148
+ 'palette.placeholder': 'Search files, folders or commands…',
149
+ 'palette.files': 'Files',
150
+ 'palette.commands': 'Commands',
151
+ 'palette.goto': 'Go to path',
152
+ 'palette.empty': 'No results',
153
+ 'palette.hint': '↑↓ navigate · Enter select · Esc close',
154
+ 'cmd.view_toggle': 'Toggle view (list ⇄ grid)',
155
+ 'cmd.trash': 'Open trash',
156
+ 'shortcuts.title': 'Keyboard Shortcuts',
157
+ 'shortcuts.group.nav': 'Navigation',
158
+ 'shortcuts.group.selection': 'Selection',
159
+ 'shortcuts.group.file': 'File operations',
160
+ 'shortcuts.palette': 'Command palette',
161
+ 'shortcuts.search': 'Focus search',
162
+ 'shortcuts.go_up': 'Go up one level',
163
+ 'shortcuts.open': 'Open selected item',
164
+ 'shortcuts.close': 'Close / cancel',
165
+ 'shortcuts.help': 'This help dialog',
166
+ 'shortcuts.select_all': 'Select all',
167
+ 'shortcuts.rename': 'Rename',
168
+ 'shortcuts.delete': 'Delete',
169
+ 'shortcuts.cut': 'Cut',
170
+ 'shortcuts.copy': 'Copy',
171
+ 'shortcuts.paste': 'Paste',
172
+ 'breadcrumb.more': 'Hidden folders',
173
+
174
+ // ——— State screens + undo snackbar + connection badge (cila:b) ———
175
+ 'empty.hint': 'Drag and drop files here',
176
+ 'empty.upload': 'Upload files',
177
+ 'empty.search.title': 'No files match your search',
178
+ 'empty.search.hint': 'Try a different search term',
179
+ 'empty.trash.title': 'Trash is empty',
180
+ 'error.title': 'Could not load this folder',
181
+ 'error.retry': 'Try again',
182
+ 'toast.undo': 'Undo',
183
+ 'toast.undone': 'Undone',
184
+ 'toast.undo_failed': 'Could not undo',
185
+ 'toast.renamed': 'Renamed',
186
+ 'toast.moved': 'Moved',
187
+ 'toast.trashed': 'Moved to trash',
188
+ 'conn.offline': 'No live connection — changes may be delayed',
189
+ 'conn.tooltip': 'Live connection to the server is unavailable; the list refreshes periodically. This notice disappears once the connection is back.',
136
190
  };