@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.
- package/README.md +32 -25
- package/dist/ArchiveViewer-DxxjhYZD.js +124 -0
- package/dist/ArchiveViewer-DxxjhYZD.js.map +1 -0
- package/dist/CsvViewer-CyuwENI_.js +141 -0
- package/dist/CsvViewer-CyuwENI_.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-BIYsTX97.js} +48 -42
- package/dist/DrawioViewer-BIYsTX97.js.map +1 -0
- package/dist/EpubViewer-K9PjCMmS.js +163 -0
- package/dist/EpubViewer-K9PjCMmS.js.map +1 -0
- package/dist/IpynbViewer-DYU6Zgz2.js +184 -0
- package/dist/IpynbViewer-DYU6Zgz2.js.map +1 -0
- package/dist/MermaidViewer-Bp73aId8.js +137 -0
- package/dist/MermaidViewer-Bp73aId8.js.map +1 -0
- package/dist/PsdViewer-C2eLivw1.js +122 -0
- package/dist/PsdViewer-C2eLivw1.js.map +1 -0
- package/dist/TiffViewer-DsHg5gcz.js +142 -0
- package/dist/TiffViewer-DsHg5gcz.js.map +1 -0
- package/dist/Viewer3D-C_dc2HN9.js +75 -0
- package/dist/Viewer3D-C_dc2HN9.js.map +1 -0
- package/dist/filex-core.js +218 -20361
- package/dist/filex-core.js.map +1 -1
- package/dist/filex-core.umd.cjs +61 -62
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/index-vxMZpYb-.js +26700 -0
- package/dist/index-vxMZpYb-.js.map +1 -0
- package/dist/index.d.ts +4212 -862
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2154 -506
- package/src/components/AdvancedSearch.vue +870 -0
- package/src/components/Breadcrumb.vue +191 -19
- package/src/components/CommandPalette.vue +66 -22
- package/src/components/ConnectionsPanel.vue +6 -2
- package/src/components/ContextMenu.vue +39 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +951 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +172 -56
- package/src/components/GridView.vue +331 -82
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1173 -194
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +26 -1
- package/src/components/RecentlyOpened.vue +17 -2
- package/src/components/S3KeysPanel.vue +7 -4
- package/src/components/SSHKeysPanel.vue +7 -4
- package/src/components/ShortcutSettings.vue +6 -2
- package/src/components/ShortcutsHelp.vue +4 -1
- package/src/components/SideNav.vue +206 -140
- package/src/components/StorageFields.vue +19 -1
- package/src/components/TabBar.vue +87 -1
- package/src/components/TagPicker.vue +77 -6
- package/src/components/ThemeGallery.vue +13 -198
- package/src/components/ThemePalette.vue +244 -0
- package/src/components/TimeZoneDialog.vue +135 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +35 -14
- package/src/components/Toolbar.vue +953 -465
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +89 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useLocale.ts +382 -26
- package/src/composables/useRowTouch.ts +95 -0
- package/src/index.ts +177 -3
- package/src/lib/actionIcons.ts +330 -0
- package/src/lib/advSearch.ts +162 -0
- package/src/lib/dateGroups.ts +197 -0
- package/src/lib/destinationTree.ts +235 -0
- package/src/lib/downloadSelection.ts +154 -0
- package/src/lib/fileFilters.ts +319 -12
- package/src/lib/fileIcons.ts +299 -5
- package/src/lib/filePreview.ts +605 -0
- package/src/lib/listing.ts +72 -0
- package/src/lib/shareTtl.ts +31 -2
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +151 -116
- package/src/lib/timezone.ts +501 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +416 -26
- package/src/locales/tr.ts +409 -26
- package/src/modals/ConvertModal.vue +23 -3
- package/src/modals/DestinationPickerModal.vue +341 -0
- package/src/modals/Modal.vue +18 -4
- package/src/modals/NewDocumentModal.vue +525 -0
- package/src/modals/NewFolderModal.vue +15 -9
- package/src/modals/PermissionsModal.vue +557 -476
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5409 -8
- package/src/styles/variables.css +139 -65
- package/src/types/Connections.ts +9 -0
- package/src/types/ExplorerConfig.ts +144 -39
- package/src/types/FileNode.ts +28 -0
- package/src/viewers/ArchiveViewer.vue +46 -8
- package/src/viewers/CsvViewer.vue +19 -2
- package/src/viewers/DrawioViewer.vue +12 -1
- package/src/viewers/EpubViewer.vue +50 -6
- package/src/viewers/IpynbViewer.vue +19 -2
- package/src/viewers/MermaidViewer.vue +20 -3
- package/src/viewers/PdfViewer.vue +24 -4
- package/src/viewers/PsdViewer.vue +28 -4
- package/src/viewers/TiffViewer.vue +19 -2
- package/src/viewers/Viewer3D.vue +36 -2
- package/dist/ArchiveViewer-Cdfv9LFC.js +0 -99
- package/dist/ArchiveViewer-Cdfv9LFC.js.map +0 -1
- package/dist/CsvViewer-CqWeV8VO.js +0 -131
- package/dist/CsvViewer-CqWeV8VO.js.map +0 -1
- package/dist/DrawioViewer-BNALOB04.js.map +0 -1
- package/dist/EpubViewer-BPmlImG7.js +0 -145
- package/dist/EpubViewer-BPmlImG7.js.map +0 -1
- package/dist/IpynbViewer-CDlQmuCV.js +0 -175
- package/dist/IpynbViewer-CDlQmuCV.js.map +0 -1
- package/dist/MermaidViewer-sfZWdR-d.js +0 -127
- package/dist/MermaidViewer-sfZWdR-d.js.map +0 -1
- package/dist/PsdViewer-DwuySZsb.js +0 -112
- package/dist/PsdViewer-DwuySZsb.js.map +0 -1
- package/dist/TiffViewer-BDQ3d3CQ.js +0 -132
- package/dist/TiffViewer-BDQ3d3CQ.js.map +0 -1
- package/dist/Viewer3D-CJJJgkuv.js +0 -60
- package/dist/Viewer3D-CJJJgkuv.js.map +0 -1
- package/src/components/SecondaryPane.vue +0 -447
- package/src/modals/ShareModal.vue +0 -139
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* NewDocumentModal — "New document": pick a type, name it, choose where it
|
|
4
|
+
* goes, get an empty file of that type back.
|
|
5
|
+
*
|
|
6
|
+
* ## Why the type list is not in this file
|
|
7
|
+
*
|
|
8
|
+
* The obvious implementation is a constant array of extensions right here.
|
|
9
|
+
* It would be wrong in two directions at once.
|
|
10
|
+
*
|
|
11
|
+
* Downwards: the server is the only party that knows whether it can PRODUCE
|
|
12
|
+
* the bytes. An empty `.md` is an empty file, but an empty `.docx` is a ZIP of
|
|
13
|
+
* XML parts, and if this deployment's binary holds no template for a format
|
|
14
|
+
* then offering it creates a file no program will open. That answer arrives in
|
|
15
|
+
* `capabilities.newdoc_types` (internal/newdoc), and it is the `types` prop.
|
|
16
|
+
*
|
|
17
|
+
* Upwards: the server does NOT know whether this browser can OPEN the result.
|
|
18
|
+
* OnlyOffice may be unconfigured, may be configured but failing its probe, or
|
|
19
|
+
* may be overridden by the embedder to a URL the backend cannot see. So each
|
|
20
|
+
* row names the service its editor needs (`requires`) and the host answers
|
|
21
|
+
* with `onlyOfficeReady` / `drawioReady` — the same resolution the explorer
|
|
22
|
+
* already does for the viewers themselves.
|
|
23
|
+
*
|
|
24
|
+
* ⚠ The rule that falls out of this: **a type nobody here can open is not
|
|
25
|
+
* offered**. Creating `report.docx` on an install with no document server
|
|
26
|
+
* hands somebody a file and then refuses to open it, which is a worse outcome
|
|
27
|
+
* than the entry not being there. When a whole family is withheld this way the
|
|
28
|
+
* dialog says so in one quiet line, because "why is Word missing" should not
|
|
29
|
+
* require reading the source.
|
|
30
|
+
*
|
|
31
|
+
* ## Where "where" is asked
|
|
32
|
+
*
|
|
33
|
+
* ⚠ Not here. This dialog grew its own one-level folder browser — an Up
|
|
34
|
+
* button, a row list, a "Save here" — and `modals/DestinationPickerModal.vue`
|
|
35
|
+
* was then written for Move to / Copy to, which is the same question with the
|
|
36
|
+
* same rules (what counts as writable, whether an unwritable folder is hidden
|
|
37
|
+
* or greyed, what sits above a storage root). Two choosers is how those rules
|
|
38
|
+
* start disagreeing, so there is one: this mounts the picker and keeps only
|
|
39
|
+
* the answer.
|
|
40
|
+
*
|
|
41
|
+
* ## Where the write is checked
|
|
42
|
+
*
|
|
43
|
+
* In three places, and only the last one counts. The browser hides folders the
|
|
44
|
+
* person cannot write to; the dialog refuses to submit into one; the SERVER
|
|
45
|
+
* checks ACL and read-only state and refuses regardless of what arrived. The
|
|
46
|
+
* first two are courtesies that keep somebody from typing a name for thirty
|
|
47
|
+
* seconds before being told no.
|
|
48
|
+
*
|
|
49
|
+
* Name collisions are the same shape: this dialog asks the destination listing
|
|
50
|
+
* first and says "already here" BEFORE the write, and the create endpoint
|
|
51
|
+
* answers 409 anyway.
|
|
52
|
+
*/
|
|
53
|
+
import { computed, nextTick, ref, watch } from 'vue';
|
|
54
|
+
import type { LocaleCode, ThemeMode } from '../types/ExplorerConfig';
|
|
55
|
+
import type { NewDocType } from '../types/FileNode';
|
|
56
|
+
import type { FileApi, ManagerResponse, NewFileResponse } from '../composables/useFileApi';
|
|
57
|
+
import { useLocale } from '../composables/useLocale';
|
|
58
|
+
import { iconTile, iconFamilyFor, typeLabelFor } from '../lib/fileIcons';
|
|
59
|
+
import { crumbsOfWire, permAllowsWrite, splitWire } from '../lib/destinationTree';
|
|
60
|
+
import Modal from './Modal.vue';
|
|
61
|
+
import DestinationPickerModal from './DestinationPickerModal.vue';
|
|
62
|
+
|
|
63
|
+
const props = defineProps<{
|
|
64
|
+
open: boolean;
|
|
65
|
+
locale: LocaleCode;
|
|
66
|
+
theme?: ThemeMode;
|
|
67
|
+
/** The host's API wrapper — the dialog does its own listing + create. */
|
|
68
|
+
api: FileApi;
|
|
69
|
+
/**
|
|
70
|
+
* `capabilities.newdoc_types`. Undefined/null on a server that predates the
|
|
71
|
+
* feature; the host should not offer the entry at all in that case, and if
|
|
72
|
+
* it does anyway the dialog degrades to its empty state rather than guessing.
|
|
73
|
+
*/
|
|
74
|
+
types?: NewDocType[] | null;
|
|
75
|
+
/** Adapter-qualified folder in view, e.g. `main://docs`. Empty at the root. */
|
|
76
|
+
currentPath: string;
|
|
77
|
+
/** Storage names, for the destination browser's top level. */
|
|
78
|
+
storages?: string[];
|
|
79
|
+
/** Resolved by the host: is there a usable OnlyOffice / drawio? */
|
|
80
|
+
onlyOfficeReady?: boolean;
|
|
81
|
+
drawioReady?: boolean;
|
|
82
|
+
}>();
|
|
83
|
+
|
|
84
|
+
const emit = defineEmits<{
|
|
85
|
+
(e: 'close'): void;
|
|
86
|
+
(e: 'created', file: NewFileResponse): void;
|
|
87
|
+
(e: 'error', payload: { message: string }): void;
|
|
88
|
+
}>();
|
|
89
|
+
|
|
90
|
+
const { t } = useLocale(() => props.locale);
|
|
91
|
+
|
|
92
|
+
/* ================================================================== types */
|
|
93
|
+
|
|
94
|
+
function satisfied(ty: NewDocType): boolean {
|
|
95
|
+
if (ty.requires === 'onlyoffice') return !!props.onlyOfficeReady;
|
|
96
|
+
if (ty.requires === 'drawio') return !!props.drawioReady;
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const offered = computed(() => (props.types ?? []).filter(satisfied));
|
|
101
|
+
|
|
102
|
+
/** Services that are holding a type back, so the dialog can say which. */
|
|
103
|
+
const withheld = computed(() => {
|
|
104
|
+
const out: string[] = [];
|
|
105
|
+
for (const ty of props.types ?? []) {
|
|
106
|
+
if (!satisfied(ty) && ty.requires && !out.includes(ty.requires)) out.push(ty.requires);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The withheld services as one sentence.
|
|
113
|
+
*
|
|
114
|
+
* ⚠ Joined in script rather than looped in the template with a `<span> </span>`
|
|
115
|
+
* separator: Vue's compiler drops a whitespace-only text node between
|
|
116
|
+
* elements, so the two sentences rendered welded together
|
|
117
|
+
* ("...here.Diagrams need...") — measured in a browser, invisible in a unit
|
|
118
|
+
* test that asserts on textContent.
|
|
119
|
+
*/
|
|
120
|
+
const withheldText = computed(() =>
|
|
121
|
+
withheld.value.map((svc) => t('newdoc.withheld.' + svc)).join(' '),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
/** Sections, in the order the server listed them. */
|
|
125
|
+
const groups = computed(() => {
|
|
126
|
+
const order: string[] = [];
|
|
127
|
+
const bucket = new Map<string, NewDocType[]>();
|
|
128
|
+
for (const ty of offered.value) {
|
|
129
|
+
if (!bucket.has(ty.group)) {
|
|
130
|
+
bucket.set(ty.group, []);
|
|
131
|
+
order.push(ty.group);
|
|
132
|
+
}
|
|
133
|
+
bucket.get(ty.group)!.push(ty);
|
|
134
|
+
}
|
|
135
|
+
return order.map((g) => ({ group: g, types: bucket.get(g)! }));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const selected = ref<string>('');
|
|
139
|
+
const selectedType = computed(() => offered.value.find((ty) => ty.ext === selected.value) ?? null);
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The words for a type come from `typeLabelFor` — the same table the listing's
|
|
143
|
+
* Type column reads. A second table here would be a second answer to "what is
|
|
144
|
+
* a .csv", and the two would disagree the first week somebody edited one.
|
|
145
|
+
*/
|
|
146
|
+
function labelFor(ext: string): string {
|
|
147
|
+
return typeLabelFor({ type: 'file', extension: ext }, t);
|
|
148
|
+
}
|
|
149
|
+
function tileFor(ext: string): string {
|
|
150
|
+
return iconTile(iconFamilyFor({ type: 'file', extension: ext, basename: 'x.' + ext }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* =========================================================== destinations */
|
|
154
|
+
|
|
155
|
+
const dest = ref('');
|
|
156
|
+
/** The picker is up. Also gates Create: a half-answered "where" is not a where. */
|
|
157
|
+
const pickingDest = ref(false);
|
|
158
|
+
|
|
159
|
+
/** Listing cache for the name check. The picker keeps its own for its rows. */
|
|
160
|
+
const dirCache = new Map<string, ManagerResponse>();
|
|
161
|
+
|
|
162
|
+
function canWrite(resp: ManagerResponse | undefined): boolean {
|
|
163
|
+
if (!resp) return false;
|
|
164
|
+
if (resp.read_only) return false;
|
|
165
|
+
return permAllowsWrite(resp.perm as string | undefined);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function loadDir(p: string): Promise<ManagerResponse | undefined> {
|
|
169
|
+
if (dirCache.has(p)) return dirCache.get(p);
|
|
170
|
+
try {
|
|
171
|
+
const resp = await props.api.index(p);
|
|
172
|
+
dirCache.set(p, resp);
|
|
173
|
+
return resp;
|
|
174
|
+
} catch {
|
|
175
|
+
// A folder that cannot be listed is a folder we cannot offer. Silence is
|
|
176
|
+
// right here: the caller renders "you cannot write here" / "no subfolders",
|
|
177
|
+
// and a listing failure inside a picker is not an error the host should toast.
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** `main://a/b` → `main / a / b` — the trail, from the one place that
|
|
183
|
+
* decomposes a wire path (lib/destinationTree). */
|
|
184
|
+
const destLabel = computed(() =>
|
|
185
|
+
dest.value ? crumbsOfWire(dest.value).map((c) => c.label).join(' / ') : '',
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const destWritable = ref(true);
|
|
189
|
+
const destChecked = ref(false);
|
|
190
|
+
|
|
191
|
+
/* ================================================================== naming */
|
|
192
|
+
|
|
193
|
+
const name = ref('');
|
|
194
|
+
const nameTouched = ref(false);
|
|
195
|
+
const collision = ref(false);
|
|
196
|
+
const busy = ref(false);
|
|
197
|
+
const failure = ref<string | null>(null);
|
|
198
|
+
|
|
199
|
+
const finalName = computed(() => {
|
|
200
|
+
const base = name.value.trim();
|
|
201
|
+
const ext = selectedType.value?.ext ?? '';
|
|
202
|
+
if (!base || !ext) return base;
|
|
203
|
+
return base.toLowerCase().endsWith('.' + ext) ? base : base + '.' + ext;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const nameHasSeparator = computed(() => /[\\/]/.test(name.value));
|
|
207
|
+
|
|
208
|
+
function freeName(taken: Set<string>, ext: string): string {
|
|
209
|
+
const base = t('newdoc.untitled');
|
|
210
|
+
if (!taken.has((base + '.' + ext).toLowerCase())) return base;
|
|
211
|
+
for (let i = 2; i < 100; i++) {
|
|
212
|
+
const cand = `${base} (${i})`;
|
|
213
|
+
if (!taken.has((cand + '.' + ext).toLowerCase())) return cand;
|
|
214
|
+
}
|
|
215
|
+
return base;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function takenNames(resp: ManagerResponse | undefined): Set<string> {
|
|
219
|
+
const s = new Set<string>();
|
|
220
|
+
for (const f of resp?.files ?? []) s.add(f.basename.toLowerCase());
|
|
221
|
+
return s;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
let checkSeq = 0;
|
|
225
|
+
let checkTimer: ReturnType<typeof setTimeout> | null = null;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Ask the destination what is already in it: the write permission and the
|
|
229
|
+
* names, in one listing. Debounced because it runs on every keystroke.
|
|
230
|
+
*/
|
|
231
|
+
async function refreshDestination(opts: { suggest?: boolean } = {}) {
|
|
232
|
+
const seq = ++checkSeq;
|
|
233
|
+
const target = dest.value;
|
|
234
|
+
if (!target) {
|
|
235
|
+
destChecked.value = false;
|
|
236
|
+
collision.value = false;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const resp = await loadDir(target);
|
|
240
|
+
if (seq !== checkSeq || target !== dest.value) return;
|
|
241
|
+
destWritable.value = canWrite(resp);
|
|
242
|
+
destChecked.value = true;
|
|
243
|
+
const taken = takenNames(resp);
|
|
244
|
+
const ext = selectedType.value?.ext ?? '';
|
|
245
|
+
if (opts.suggest && !nameTouched.value && ext) {
|
|
246
|
+
name.value = freeName(taken, ext);
|
|
247
|
+
}
|
|
248
|
+
collision.value = !!finalName.value && taken.has(finalName.value.toLowerCase());
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function scheduleCheck() {
|
|
252
|
+
if (checkTimer) clearTimeout(checkTimer);
|
|
253
|
+
checkTimer = setTimeout(() => void refreshDestination(), 220);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* ================================================================ lifecycle */
|
|
257
|
+
|
|
258
|
+
function defaultDestination(): string {
|
|
259
|
+
const cur = (props.currentPath || '').trim();
|
|
260
|
+
const [adapter] = splitWire(cur);
|
|
261
|
+
// Standing in a real folder: that folder. Making somebody navigate to where
|
|
262
|
+
// they already are is the small insult that makes a feature feel unfinished.
|
|
263
|
+
if (adapter) return cur;
|
|
264
|
+
const names = props.storages ?? [];
|
|
265
|
+
if (names.length === 1) return names[0] + '://';
|
|
266
|
+
return '';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
watch(
|
|
270
|
+
() => props.open,
|
|
271
|
+
async (isOpen) => {
|
|
272
|
+
if (!isOpen) return;
|
|
273
|
+
dirCache.clear();
|
|
274
|
+
failure.value = null;
|
|
275
|
+
collision.value = false;
|
|
276
|
+
nameTouched.value = false;
|
|
277
|
+
busy.value = false;
|
|
278
|
+
name.value = '';
|
|
279
|
+
selected.value = offered.value[0]?.ext ?? '';
|
|
280
|
+
dest.value = defaultDestination();
|
|
281
|
+
destChecked.value = false;
|
|
282
|
+
destWritable.value = true;
|
|
283
|
+
/* Nowhere to write yet (several storages and no folder in view): ask
|
|
284
|
+
* first, because a name typed against no destination is a name typed
|
|
285
|
+
* twice. */
|
|
286
|
+
pickingDest.value = !dest.value;
|
|
287
|
+
await refreshDestination({ suggest: true });
|
|
288
|
+
},
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// A different type means a different extension, so the suggested name has to
|
|
292
|
+
// be re-asked against the destination (Untitled.md may be free where
|
|
293
|
+
// Untitled.docx is not).
|
|
294
|
+
watch(selected, () => {
|
|
295
|
+
void refreshDestination({ suggest: true });
|
|
296
|
+
});
|
|
297
|
+
watch(name, () => {
|
|
298
|
+
collision.value = false;
|
|
299
|
+
scheduleCheck();
|
|
300
|
+
});
|
|
301
|
+
watch(dest, () => {
|
|
302
|
+
void refreshDestination({ suggest: true });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
/* ================================================================== actions */
|
|
306
|
+
|
|
307
|
+
function pick(ext: string) {
|
|
308
|
+
selected.value = ext;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Roving focus across the tiles — a radiogroup is expected to move on arrows. */
|
|
312
|
+
function onTileKey(e: KeyboardEvent, ext: string) {
|
|
313
|
+
const list = offered.value.map((ty) => ty.ext);
|
|
314
|
+
const i = list.indexOf(ext);
|
|
315
|
+
let next = -1;
|
|
316
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = (i + 1) % list.length;
|
|
317
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = (i - 1 + list.length) % list.length;
|
|
318
|
+
else if (e.key === 'Home') next = 0;
|
|
319
|
+
else if (e.key === 'End') next = list.length - 1;
|
|
320
|
+
if (next < 0) return;
|
|
321
|
+
e.preventDefault();
|
|
322
|
+
selected.value = list[next];
|
|
323
|
+
void nextTick(() => {
|
|
324
|
+
const el = document.querySelector<HTMLElement>(`[data-newdoc-ext="${list[next]}"]`);
|
|
325
|
+
el?.focus();
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function onDestinationPicked(path: string) {
|
|
330
|
+
dest.value = path;
|
|
331
|
+
pickingDest.value = false;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* ⚠ Escape with the picker open must close the PICKER, not this dialog.
|
|
336
|
+
*
|
|
337
|
+
* `Modal` listens for Escape on `document`, and there are two of them on
|
|
338
|
+
* screen; the outer one registered first, so it hears it first. Swallowing
|
|
339
|
+
* that first close here is what stops one keypress from throwing away a name
|
|
340
|
+
* somebody just typed. (The picker's own Modal then fires, closes the picker,
|
|
341
|
+
* and the flag is already down — the second close is a no-op.)
|
|
342
|
+
*/
|
|
343
|
+
function onOuterClose() {
|
|
344
|
+
if (pickingDest.value) {
|
|
345
|
+
pickingDest.value = false;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
emit('close');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const canCreate = computed(
|
|
352
|
+
() =>
|
|
353
|
+
!busy.value &&
|
|
354
|
+
!!selectedType.value &&
|
|
355
|
+
!!dest.value &&
|
|
356
|
+
!pickingDest.value &&
|
|
357
|
+
!!name.value.trim() &&
|
|
358
|
+
!nameHasSeparator.value &&
|
|
359
|
+
!collision.value &&
|
|
360
|
+
(!destChecked.value || destWritable.value),
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
async function create() {
|
|
364
|
+
if (!canCreate.value || !selectedType.value) return;
|
|
365
|
+
busy.value = true;
|
|
366
|
+
failure.value = null;
|
|
367
|
+
try {
|
|
368
|
+
const res = await props.api.newFile(dest.value, name.value.trim(), selectedType.value.ext);
|
|
369
|
+
emit('created', res);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
const err = e as Error & { status?: number };
|
|
372
|
+
if (err.status === 409) {
|
|
373
|
+
// The server refused for the reason the dialog warns about. Say it in
|
|
374
|
+
// the same place rather than throwing a toast over the form.
|
|
375
|
+
collision.value = true;
|
|
376
|
+
} else {
|
|
377
|
+
failure.value = err.message || String(e);
|
|
378
|
+
emit('error', { message: failure.value });
|
|
379
|
+
}
|
|
380
|
+
} finally {
|
|
381
|
+
busy.value = false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
</script>
|
|
385
|
+
|
|
386
|
+
<template>
|
|
387
|
+
<Modal
|
|
388
|
+
:open="open"
|
|
389
|
+
:title="t('newdoc.title')"
|
|
390
|
+
size="lg"
|
|
391
|
+
:theme="theme"
|
|
392
|
+
@close="onOuterClose"
|
|
393
|
+
>
|
|
394
|
+
<div class="fe-newdoc" data-testid="newdoc-modal">
|
|
395
|
+
<!-- Nothing to offer. Either the server sent no types (too old) or every
|
|
396
|
+
one of them needs a service this install has not got. -->
|
|
397
|
+
<div v-if="!offered.length" class="fe-newdoc__empty" data-testid="newdoc-empty">
|
|
398
|
+
<p class="fe-newdoc__empty-title">{{ t('newdoc.empty.title') }}</p>
|
|
399
|
+
<p class="fe-newdoc__empty-body">
|
|
400
|
+
{{ withheld.length ? t('newdoc.empty.blocked') : t('newdoc.empty.body') }}
|
|
401
|
+
</p>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<template v-else>
|
|
405
|
+
<div
|
|
406
|
+
class="fe-newdoc__types"
|
|
407
|
+
role="radiogroup"
|
|
408
|
+
:aria-label="t('newdoc.type.label')"
|
|
409
|
+
>
|
|
410
|
+
<section v-for="g in groups" :key="g.group" class="fe-newdoc__group">
|
|
411
|
+
<h3 class="fe-newdoc__grouplabel">{{ t('newdoc.group.' + g.group) }}</h3>
|
|
412
|
+
<div class="fe-newdoc__grid">
|
|
413
|
+
<button
|
|
414
|
+
v-for="ty in g.types"
|
|
415
|
+
:key="ty.ext"
|
|
416
|
+
type="button"
|
|
417
|
+
role="radio"
|
|
418
|
+
class="fe-newdoc__type"
|
|
419
|
+
:class="{ 'is-selected': selected === ty.ext }"
|
|
420
|
+
:aria-checked="selected === ty.ext"
|
|
421
|
+
:tabindex="selected === ty.ext ? 0 : -1"
|
|
422
|
+
:data-newdoc-ext="ty.ext"
|
|
423
|
+
:data-testid="'newdoc-type-' + ty.ext"
|
|
424
|
+
@click="pick(ty.ext)"
|
|
425
|
+
@keydown="onTileKey($event, ty.ext)"
|
|
426
|
+
>
|
|
427
|
+
<span class="fe-newdoc__tile" aria-hidden="true" v-html="tileFor(ty.ext)"></span>
|
|
428
|
+
<span class="fe-newdoc__ext">.{{ ty.ext }}</span>
|
|
429
|
+
<span class="fe-newdoc__kind">{{ labelFor(ty.ext) }}</span>
|
|
430
|
+
</button>
|
|
431
|
+
</div>
|
|
432
|
+
</section>
|
|
433
|
+
|
|
434
|
+
<!-- Why a family is missing. One quiet line, only when something was
|
|
435
|
+
actually withheld — an operator reading "Office documents need a
|
|
436
|
+
document server" knows what to do; an empty space teaches nobody. -->
|
|
437
|
+
<p v-if="withheldText" class="fe-newdoc__withheld" data-testid="newdoc-withheld">
|
|
438
|
+
{{ withheldText }}
|
|
439
|
+
</p>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
442
|
+
<div class="fe-newdoc__field">
|
|
443
|
+
<label class="fe-newdoc__label" for="fe-newdoc-name">{{ t('newdoc.name') }}</label>
|
|
444
|
+
<div class="fe-newdoc__nameline">
|
|
445
|
+
<input
|
|
446
|
+
id="fe-newdoc-name"
|
|
447
|
+
v-model="name"
|
|
448
|
+
type="text"
|
|
449
|
+
class="fe-input fe-newdoc__name"
|
|
450
|
+
autocomplete="off"
|
|
451
|
+
spellcheck="false"
|
|
452
|
+
data-testid="newdoc-name"
|
|
453
|
+
:placeholder="t('newdoc.name.placeholder')"
|
|
454
|
+
@input="nameTouched = true"
|
|
455
|
+
@keydown.enter.prevent="create"
|
|
456
|
+
/>
|
|
457
|
+
<span v-if="selectedType" class="fe-newdoc__suffix">.{{ selectedType.ext }}</span>
|
|
458
|
+
</div>
|
|
459
|
+
<p v-if="nameHasSeparator" class="fe-form__error">{{ t('newdoc.err.slash') }}</p>
|
|
460
|
+
<p v-else-if="collision" class="fe-form__error" data-testid="newdoc-collision">
|
|
461
|
+
{{ t('newdoc.err.exists', { name: finalName }) }}
|
|
462
|
+
</p>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
<div class="fe-newdoc__field">
|
|
466
|
+
<span class="fe-newdoc__label">{{ t('newdoc.location') }}</span>
|
|
467
|
+
<div class="fe-newdoc__dest">
|
|
468
|
+
<span class="fe-newdoc__destpath" data-testid="newdoc-dest">
|
|
469
|
+
{{ dest ? destLabel : t('newdoc.location.none') }}
|
|
470
|
+
</span>
|
|
471
|
+
<button
|
|
472
|
+
type="button"
|
|
473
|
+
class="fe-newdoc__changebtn"
|
|
474
|
+
data-testid="newdoc-change"
|
|
475
|
+
@click="pickingDest = true"
|
|
476
|
+
>
|
|
477
|
+
{{ t('newdoc.location.change') }}
|
|
478
|
+
</button>
|
|
479
|
+
</div>
|
|
480
|
+
<p
|
|
481
|
+
v-if="destChecked && !destWritable"
|
|
482
|
+
class="fe-form__error"
|
|
483
|
+
data-testid="newdoc-noaccess"
|
|
484
|
+
>
|
|
485
|
+
{{ t('newdoc.location.noaccess') }}
|
|
486
|
+
</p>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<p v-if="failure" class="fe-form__error" data-testid="newdoc-failure">{{ failure }}</p>
|
|
490
|
+
</template>
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
<template #actions>
|
|
494
|
+
<button type="button" class="fe-btn" @click="onOuterClose">
|
|
495
|
+
{{ t('newdoc.cancel') }}
|
|
496
|
+
</button>
|
|
497
|
+
<button
|
|
498
|
+
v-if="offered.length"
|
|
499
|
+
type="button"
|
|
500
|
+
class="fe-btn fe-btn--primary"
|
|
501
|
+
:disabled="!canCreate"
|
|
502
|
+
data-testid="newdoc-create"
|
|
503
|
+
@click="create"
|
|
504
|
+
>
|
|
505
|
+
{{ busy ? t('newdoc.creating') : t('newdoc.create') }}
|
|
506
|
+
</button>
|
|
507
|
+
</template>
|
|
508
|
+
</Modal>
|
|
509
|
+
|
|
510
|
+
<!-- tasi:m1 — THE folder chooser, the same component Move to / Copy to
|
|
511
|
+
mount. A SIBLING of the dialog above rather than a child of it: `Modal`
|
|
512
|
+
is not teleported, so nesting would put a `position: fixed` overlay
|
|
513
|
+
inside a card that clips its overflow, and a backdrop click meant for
|
|
514
|
+
the picker would bubble into the dialog's own dismiss. -->
|
|
515
|
+
<DestinationPickerModal
|
|
516
|
+
:open="pickingDest"
|
|
517
|
+
:api="api"
|
|
518
|
+
:locale="locale"
|
|
519
|
+
mode="choose"
|
|
520
|
+
:storages="storages"
|
|
521
|
+
:start-at="dest || currentPath"
|
|
522
|
+
@close="pickingDest = false"
|
|
523
|
+
@pick="onDestinationPicked"
|
|
524
|
+
/>
|
|
525
|
+
</template>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { ref, watch } from 'vue';
|
|
3
3
|
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
4
4
|
import { useLocale } from '../composables/useLocale';
|
|
5
|
+
import { actionIconSvg } from '../lib/actionIcons'; /* ikon:emoji */
|
|
5
6
|
import Modal from './Modal.vue';
|
|
6
7
|
|
|
7
8
|
const props = defineProps<{
|
|
@@ -48,14 +49,17 @@ function submit() {
|
|
|
48
49
|
<template>
|
|
49
50
|
<Modal :open="open" :title="t('modal.newfolder.title')" size="sm" @close="emit('close')">
|
|
50
51
|
<form @submit.prevent="submit">
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
<!-- A visible name, as in the encrypted-folder dialog this one opens. -->
|
|
53
|
+
<label class="fe-field">
|
|
54
|
+
<span class="fe-field__label">{{ t('modal.newfolder.placeholder') }}</span>
|
|
55
|
+
<input
|
|
56
|
+
v-model="name"
|
|
57
|
+
type="text"
|
|
58
|
+
class="fe-input"
|
|
59
|
+
autocomplete="off"
|
|
60
|
+
@keydown.enter.prevent="submit"
|
|
61
|
+
/>
|
|
62
|
+
</label>
|
|
59
63
|
<p v-if="err" class="fe-form__error">{{ err }}</p>
|
|
60
64
|
<!-- wiring:e2 — encrypted-folder entry point lives inside the normal
|
|
61
65
|
new-folder flow so every trigger (toolbar / context menu / palette)
|
|
@@ -66,7 +70,9 @@ function submit() {
|
|
|
66
70
|
class="fe-e2e-optlink"
|
|
67
71
|
@click="emit('encrypted')"
|
|
68
72
|
>
|
|
69
|
-
|
|
73
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
74
|
+
<span class="fe-e2e-optlink__icon" aria-hidden="true" v-html="actionIconSvg('lock')"></span>
|
|
75
|
+
{{ t('e2e.create.option') }}
|
|
70
76
|
</button>
|
|
71
77
|
<!-- /wiring:e2 -->
|
|
72
78
|
</form>
|