@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.
- package/README.md +32 -25
- package/dist/ArchiveViewer-BNqukFg8.js +124 -0
- package/dist/ArchiveViewer-BNqukFg8.js.map +1 -0
- package/dist/CsvViewer-ii_-MgmG.js +141 -0
- package/dist/CsvViewer-ii_-MgmG.js.map +1 -0
- package/dist/{DrawioViewer-BNALOB04.js → DrawioViewer-B2tuu4rX.js} +48 -42
- package/dist/DrawioViewer-B2tuu4rX.js.map +1 -0
- package/dist/EpubViewer-p4B8iiWb.js +163 -0
- package/dist/EpubViewer-p4B8iiWb.js.map +1 -0
- package/dist/IpynbViewer-D_qYRJhu.js +184 -0
- package/dist/IpynbViewer-D_qYRJhu.js.map +1 -0
- package/dist/MermaidViewer-3ZkfNM8v.js +137 -0
- package/dist/MermaidViewer-3ZkfNM8v.js.map +1 -0
- package/dist/PsdViewer-CuV-OPSd.js +122 -0
- package/dist/PsdViewer-CuV-OPSd.js.map +1 -0
- package/dist/TiffViewer-BLeoF4Be.js +142 -0
- package/dist/TiffViewer-BLeoF4Be.js.map +1 -0
- package/dist/Viewer3D-DHhhU76E.js +75 -0
- package/dist/Viewer3D-DHhhU76E.js.map +1 -0
- package/dist/filex-core.js +217 -20077
- 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-BraG7Cz4.js +26664 -0
- package/dist/index-BraG7Cz4.js.map +1 -0
- package/dist/index.d.ts +4276 -851
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +2170 -492
- 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 +57 -2
- package/src/components/EncryptedFolderModal.vue +31 -25
- package/src/components/FilePane.vue +939 -0
- package/src/components/FilterBar.vue +568 -72
- package/src/components/GalleryView.vue +162 -30
- package/src/components/GridView.vue +322 -58
- package/src/components/HomeView.vue +331 -0
- package/src/components/InspectorPanel.vue +296 -53
- package/src/components/ListView.vue +1158 -167
- package/src/components/NFSExportsPanel.vue +7 -4
- package/src/components/OnboardingTour.vue +156 -33
- package/src/components/QuickLook.vue +34 -5
- 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 +43 -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 +127 -0
- package/src/components/TimeZonePicker.vue +696 -0
- package/src/components/TokensPanel.vue +16 -4
- package/src/components/Toolbar.vue +965 -469
- package/src/components/ViewSwitcher.vue +9 -4
- package/src/composables/useConnections.ts +35 -4
- package/src/composables/useExplorerTimeZone.ts +85 -0
- package/src/composables/useFileApi.ts +53 -4
- package/src/composables/useKeyboardShortcuts.ts +122 -0
- package/src/composables/useLocale.ts +365 -25
- package/src/index.ts +182 -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 +11 -1
- package/src/lib/sortOrder.ts +459 -0
- package/src/lib/themes.ts +152 -23
- package/src/lib/timezone.ts +456 -0
- package/src/lib/uiProfile.ts +82 -0
- package/src/lib/viewPrefs.ts +1036 -0
- package/src/locales/en.ts +432 -25
- package/src/locales/tr.ts +425 -25
- 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 +543 -473
- package/src/modals/PreviewModal.vue +516 -156
- package/src/styles/base.css +5435 -8
- package/src/styles/variables.css +140 -60
- 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,870 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* gorunum:v1-advsearch — the Advanced search dialog.
|
|
4
|
+
*
|
|
5
|
+
* ⚠⚠ The rule this component was built under: **only offer what the backend
|
|
6
|
+
* can actually do.** A control wired to a parameter the server never reads
|
|
7
|
+
* looks identical to one that works and quietly changes nothing, which is
|
|
8
|
+
* worse than not shipping it (the same sentence already governs the filter
|
|
9
|
+
* row — see the header of `lib/fileFilters.ts`).
|
|
10
|
+
*
|
|
11
|
+
* The split is still real, and it is still said ON SCREEN — but as one
|
|
12
|
+
* sentence under the count rather than as two half-page captions over two
|
|
13
|
+
* stacked halves (the reference shell's layout, owner's call 2026-09-12:
|
|
14
|
+
* "görüntü birebir yapalım"). What the halves are:
|
|
15
|
+
*
|
|
16
|
+
* 1. What the SERVER answers — the text, the tag filters and the scope.
|
|
17
|
+
* Those are the whole of filex's search surface (`handlers/search.go`
|
|
18
|
+
* `searchRequest`: q/query, storage_id, limit, scope; the query text may
|
|
19
|
+
* carry `tag:` / `-tag:`). Drawn at the top: one wide field, one row of
|
|
20
|
+
* scope tabs, and the tag boxes.
|
|
21
|
+
* 2. What NARROWS THE ROWS THAT CAME BACK — type, modified, size, folder.
|
|
22
|
+
* No endpoint reads a date, size, mime or path parameter, so these run
|
|
23
|
+
* here, over the hits. Drawn in the two columns, and named for what they
|
|
24
|
+
* are by `advsearch.count.cost` directly under the number they produced:
|
|
25
|
+
* "Type, date, size and folder are applied to the rows that come back,
|
|
26
|
+
* not by the server."
|
|
27
|
+
*
|
|
28
|
+
* ⚠ Moving that sentence, or letting a redesign drop it, turns four honest
|
|
29
|
+
* controls into four that quietly promise a search the server never runs.
|
|
30
|
+
*
|
|
31
|
+
* **Owner IS drawn now, and the reason it was not is gone.** This block used
|
|
32
|
+
* to read "there is no per-node owner on the wire". Migration 00038 made
|
|
33
|
+
* ownership a real fact on the row and the listing projection carries
|
|
34
|
+
* `owner_id` / `owner_name` / `owner_self` on every row that has one, so the
|
|
35
|
+
* Owner select answers from a field the rows ALREADY carry — the same bargain
|
|
36
|
+
* as type, date and size, and it is named in the same sentence under the
|
|
37
|
+
* count. ⚠ It is offered on NAME searches only: the explorer maps a content
|
|
38
|
+
* hit onto the listing shape by hand (`FileExplorer.advHitToNode`) and that
|
|
39
|
+
* mapping does not carry the owner through, so under the content scopes every
|
|
40
|
+
* row would read as "System" — a wrong answer, which is worse than a control
|
|
41
|
+
* that says why it is off.
|
|
42
|
+
*
|
|
43
|
+
* Still not drawn, each for a reason that was MEASURED against the reference
|
|
44
|
+
* build on 2026-09-13 rather than assumed:
|
|
45
|
+
* - **A "Paths" scope tab.** The reference sends `scope:"path"` and its own
|
|
46
|
+
* server answers it with the byte-identical result set `scope:"name"`
|
|
47
|
+
* returns — measured over four queries incl. `Code/api`, which matches a
|
|
48
|
+
* path and no name. Paths are matched INSIDE the name scope (the index
|
|
49
|
+
* queries the `path` field there, `search.nameQuery`), so the tab renames
|
|
50
|
+
* a search rather than adding one.
|
|
51
|
+
* - **A "Tags" scope tab.** `search.ParseScope` is name|content|all; the
|
|
52
|
+
* reference's `scope:"tags"` returns nothing for every query on its own
|
|
53
|
+
* install. Ours has the stronger thing already: two boxes that become
|
|
54
|
+
* `tag:` / `-tag:` tokens the backend resolves against the database.
|
|
55
|
+
* - **"Match whole phrase".** The query language has no phrase operator. The
|
|
56
|
+
* only quoting the parser knows keeps a `tag:"two words"` value together
|
|
57
|
+
* (`search.splitQuery`); quotes around free text stay in the text.
|
|
58
|
+
* - **A storage chooser and a "search in / all storages" scope.** Both need
|
|
59
|
+
* the explorer to send the search somewhere else — `api.search(target, …)`
|
|
60
|
+
* builds the target from where the explorer is standing, and only the
|
|
61
|
+
* multi-storage virtual root searches across drives. A picker drawn here
|
|
62
|
+
* could narrow the rows that came back, which for a single-storage search
|
|
63
|
+
* can only ever mean "the drive you are already on". The endpoint half is
|
|
64
|
+
* ready (see `handlers/search.go`: a hit now carries its storage NAME);
|
|
65
|
+
* the routing half is `FileExplorer.advTarget`, and is not this
|
|
66
|
+
* component's to write.
|
|
67
|
+
*
|
|
68
|
+
* The live count is a REAL query, debounced — the same search the button runs,
|
|
69
|
+
* counted after the client-side half. That costs a round trip, so the dialog
|
|
70
|
+
* prints that it does instead of presenting the number as free.
|
|
71
|
+
*/
|
|
72
|
+
import { computed, ref, watch, onBeforeUnmount } from 'vue';
|
|
73
|
+
import type { LocaleCode } from '../types/ExplorerConfig';
|
|
74
|
+
import { useLocale } from '../composables/useLocale';
|
|
75
|
+
import Modal from '../modals/Modal.vue';
|
|
76
|
+
import { actionIconSvg } from '../lib/actionIcons';
|
|
77
|
+
import {
|
|
78
|
+
EMPTY_FILTERS,
|
|
79
|
+
type AroundSpan,
|
|
80
|
+
type ModifiedFilter,
|
|
81
|
+
type PathMode,
|
|
82
|
+
type PeopleFilter,
|
|
83
|
+
type PeopleOption,
|
|
84
|
+
type SizeFilter,
|
|
85
|
+
type TypeFilter,
|
|
86
|
+
} from '../lib/fileFilters';
|
|
87
|
+
import {
|
|
88
|
+
advQueryString,
|
|
89
|
+
advSearchEmpty,
|
|
90
|
+
emptyAdvSearch,
|
|
91
|
+
parseTagList,
|
|
92
|
+
type AdvCountResult,
|
|
93
|
+
type AdvScope,
|
|
94
|
+
type AdvSearchRequest,
|
|
95
|
+
} from '../lib/advSearch';
|
|
96
|
+
|
|
97
|
+
const props = defineProps<{
|
|
98
|
+
open: boolean;
|
|
99
|
+
locale: LocaleCode;
|
|
100
|
+
theme?: 'light' | 'dark' | 'auto';
|
|
101
|
+
/** Seeded from the toolbar field so the dialog continues the search in progress. */
|
|
102
|
+
initialQuery?: string;
|
|
103
|
+
/** Human name of the folder the dialog was opened from ("Documents"). */
|
|
104
|
+
folderLabel?: string;
|
|
105
|
+
/** Adapter-qualified folder the "only here" / "skip here" choice measures
|
|
106
|
+
* against (`qldemo://Documents`). Captured at open: a search rebases the
|
|
107
|
+
* explorer to the storage root, so by the time the filter runs the folder
|
|
108
|
+
* the user meant is no longer where the explorer is standing. */
|
|
109
|
+
pathBase?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Whether the content scopes may be offered.
|
|
112
|
+
*
|
|
113
|
+
* ⚠ Not a preference. Content search exists only on `/api/files/search`,
|
|
114
|
+
* and a hit from there reaches the explorer without the drive it came from —
|
|
115
|
+
* with more than one storage mounted the explorer cannot tell which drive a
|
|
116
|
+
* hit is on, and a row labelled with the wrong drive is worse than a missing
|
|
117
|
+
* feature. With one storage every hit is necessarily that storage's, so the
|
|
118
|
+
* scopes are exact. The caller decides; this component only draws it.
|
|
119
|
+
*
|
|
120
|
+
* ⚠ The ENDPOINT half of that gap is closed as of 2026-09-13: a hit now
|
|
121
|
+
* carries `storage` (the drive's name) as well as its id, the same way the
|
|
122
|
+
* starred and recently-opened handlers have always answered
|
|
123
|
+
* (`handlers/search.go` `describeHits`). What is left is the explorer's own
|
|
124
|
+
* hand-written mapping, `FileExplorer.advHitToNode`, which builds a listing
|
|
125
|
+
* row from the hit and does not read that field — so the caller still has to
|
|
126
|
+
* pass `false` on a multi-storage install until it does.
|
|
127
|
+
*/
|
|
128
|
+
contentSearch?: boolean;
|
|
129
|
+
/** Runs the real query and returns what it found. Owned by the explorer,
|
|
130
|
+
* which is the only thing holding the API client. */
|
|
131
|
+
count: (req: AdvSearchRequest) => Promise<AdvCountResult>;
|
|
132
|
+
}>();
|
|
133
|
+
|
|
134
|
+
const emit = defineEmits<{
|
|
135
|
+
(e: 'close'): void;
|
|
136
|
+
(e: 'submit', req: AdvSearchRequest): void;
|
|
137
|
+
}>();
|
|
138
|
+
|
|
139
|
+
const { t } = useLocale(() => props.locale);
|
|
140
|
+
|
|
141
|
+
/* ── form state ──────────────────────────────────────────────────────────── */
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The query box, focused when the dialog opens.
|
|
145
|
+
*
|
|
146
|
+
* ⚠ Not a flourish — it replaces something that broke. Modal autofocuses the
|
|
147
|
+
* first focusable element inside its card, which used to be the × in its own
|
|
148
|
+
* title bar; this dialog folds that bar away (it draws its own head), and a
|
|
149
|
+
* `display:none` button cannot take focus, so opening the dialog left focus on
|
|
150
|
+
* the opener and nothing in the dialog was focused at all. The field is the
|
|
151
|
+
* right answer anyway: a search dialog opens ready to be typed into. The delay
|
|
152
|
+
* clears Modal's own 30ms autofocus attempt rather than racing it.
|
|
153
|
+
*/
|
|
154
|
+
const queryEl = ref<HTMLInputElement | null>(null);
|
|
155
|
+
|
|
156
|
+
const text = ref('');
|
|
157
|
+
const scope = ref<AdvScope>('name');
|
|
158
|
+
const tagsRaw = ref('');
|
|
159
|
+
const excludeRaw = ref('');
|
|
160
|
+
const type = ref<TypeFilter>('any');
|
|
161
|
+
const modified = ref<ModifiedFilter>('any');
|
|
162
|
+
const aroundDate = ref('');
|
|
163
|
+
const aroundSpan = ref<AroundSpan>('d1');
|
|
164
|
+
/**
|
|
165
|
+
* Whose rows to keep.
|
|
166
|
+
*
|
|
167
|
+
* ⚠ The same `PeopleFilter` the filter row's People pill uses, narrowed by the
|
|
168
|
+
* same `matchesPeople` predicate — two definitions of "files you put here"
|
|
169
|
+
* would be two products. `any` / `me` / `system` are offered unconditionally
|
|
170
|
+
* because a menu whose members appear and disappear is a menu nobody can
|
|
171
|
+
* learn; the per-account members are the ones that would be inert, so they are
|
|
172
|
+
* the ones that have to come from data (`peopleOptions`, below, reads them off
|
|
173
|
+
* the run that produced the count).
|
|
174
|
+
*/
|
|
175
|
+
const people = ref<PeopleFilter>('any');
|
|
176
|
+
const size = ref<SizeFilter>('any');
|
|
177
|
+
/* ⚠ `string | number`, not `string`. Vue's v-model casts the value of an
|
|
178
|
+
`<input type="number">` to a NUMBER on its own — no `.number` modifier
|
|
179
|
+
needed (`runtime-dom`: `castToNumber = number || vnode.props.type ===
|
|
180
|
+
'number'`) — and hands back the raw string only when it does not parse, so
|
|
181
|
+
an empty field is `''` and a typed one is `1`. Typing these as `string` and
|
|
182
|
+
calling a string method on the value is not a type error the compiler can
|
|
183
|
+
see, and the failure is silent: see `bytes()` below. */
|
|
184
|
+
const sizeFrom = ref<string | number>('');
|
|
185
|
+
const sizeTo = ref<string | number>('');
|
|
186
|
+
const sizeUnit = ref<'kb' | 'mb' | 'gb'>('mb');
|
|
187
|
+
const pathMode = ref<PathMode>('any');
|
|
188
|
+
|
|
189
|
+
const TYPE_OPTIONS: TypeFilter[] = [
|
|
190
|
+
'any', 'folder', 'document', 'spreadsheet', 'presentation', 'pdf',
|
|
191
|
+
'image', 'video', 'audio', 'archive', 'code',
|
|
192
|
+
];
|
|
193
|
+
const MODIFIED_OPTIONS: ModifiedFilter[] = ['any', 'today', '7d', '30d', 'year', 'around'];
|
|
194
|
+
const SIZE_OPTIONS: SizeFilter[] = ['any', 'lt1', '1to10', '10to100', 'gt100', 'range'];
|
|
195
|
+
const SPAN_OPTIONS: AroundSpan[] = ['h1', 'd1', 'w1'];
|
|
196
|
+
const UNIT_FACTOR: Record<'kb' | 'mb' | 'gb', number> = {
|
|
197
|
+
kb: 1024,
|
|
198
|
+
mb: 1024 ** 2,
|
|
199
|
+
gb: 1024 ** 3,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Scopes in the order they are OFFERED — which is not the same thing as which
|
|
204
|
+
* one is selected. `reset()` picks `name` either way, because it is the only
|
|
205
|
+
* scope the folder-search endpoint can serve; the order below is the reference
|
|
206
|
+
* shell's (widest first), and when content search is off the list collapses to
|
|
207
|
+
* the single scope that exists rather than drawing two tabs that would do the
|
|
208
|
+
* same thing.
|
|
209
|
+
*/
|
|
210
|
+
const scopeOptions = computed<AdvScope[]>(() =>
|
|
211
|
+
props.contentSearch ? ['all', 'content', 'name'] : ['name'],
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The tab glyph. Keys from `lib/actionIcons` only — the reference shell puts a
|
|
216
|
+
* mark on every scope tab, and an icon per tab drawn from the shared set keeps
|
|
217
|
+
* this row in the same voice as the menus rather than inventing a second one.
|
|
218
|
+
*/
|
|
219
|
+
const SCOPE_ICON: Record<AdvScope, string> = {
|
|
220
|
+
all: 'search',
|
|
221
|
+
content: 'preview',
|
|
222
|
+
name: 'copy-path',
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** The three answers to "where does the folder come into it". */
|
|
226
|
+
const PATH_MODES: PathMode[] = ['any', 'here', 'skip'];
|
|
227
|
+
|
|
228
|
+
/** The People members that need no data behind them. Named by the catalogue,
|
|
229
|
+
* not by the directory — which is also why offering them cannot leak one. */
|
|
230
|
+
const PEOPLE_FIXED: PeopleFilter[] = ['any', 'me', 'system'];
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Whether the Owner select can be trusted right now.
|
|
234
|
+
*
|
|
235
|
+
* ⚠ A capability check, not a preference — the same shape as `contentSearch`.
|
|
236
|
+
* A NAME search comes back through the manager's search action, whose rows are
|
|
237
|
+
* the listing projection and carry `owner_id` / `owner_name` / `owner_self`.
|
|
238
|
+
* The content scopes come back through `/api/files/search`, and the explorer
|
|
239
|
+
* maps those hits onto the listing shape by hand; that mapping carries no
|
|
240
|
+
* owner, so every row would read as "System" and both `me` and `system` would
|
|
241
|
+
* answer the wrong question confidently. Off, and it says so, until the
|
|
242
|
+
* mapping carries it (`FileExplorer.advHitToNode`).
|
|
243
|
+
*/
|
|
244
|
+
const ownerFilterable = computed(() => scope.value === 'name');
|
|
245
|
+
|
|
246
|
+
/** Leaving the name scope takes the Owner filter with it — a disabled control
|
|
247
|
+
* that goes on filtering is the dishonest half of disabling it. */
|
|
248
|
+
watch(ownerFilterable, (ok) => {
|
|
249
|
+
if (!ok) people.value = 'any';
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
function reset() {
|
|
253
|
+
text.value = '';
|
|
254
|
+
scope.value = 'name';
|
|
255
|
+
tagsRaw.value = '';
|
|
256
|
+
excludeRaw.value = '';
|
|
257
|
+
type.value = 'any';
|
|
258
|
+
modified.value = 'any';
|
|
259
|
+
aroundDate.value = '';
|
|
260
|
+
aroundSpan.value = 'd1';
|
|
261
|
+
size.value = 'any';
|
|
262
|
+
sizeFrom.value = '';
|
|
263
|
+
sizeTo.value = '';
|
|
264
|
+
sizeUnit.value = 'mb';
|
|
265
|
+
pathMode.value = 'any';
|
|
266
|
+
people.value = 'any';
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/* ── continuing the search that is actually running ───────────────────────
|
|
270
|
+
*
|
|
271
|
+
* ⚠⚠ The bug this exists for. Opening the dialog over a live advanced filter
|
|
272
|
+
* called `reset()`, so it came up saying `Type = any` and printed a count for
|
|
273
|
+
* a search NOBODY had run — measured at "14 matching items" over a listing
|
|
274
|
+
* holding 1. Worse than the wrong number: pressing Search from that state
|
|
275
|
+
* submitted the reset form and silently dropped the filter that was in force.
|
|
276
|
+
* The dialog could restart a search and never continue one.
|
|
277
|
+
*
|
|
278
|
+
* The fix is self-contained on purpose. The applied state lives in the
|
|
279
|
+
* explorer (`advFilters` / `advScope`) and could be handed back as a prop, but
|
|
280
|
+
* then the dialog would only remember in the ONE shell that passed it — the
|
|
281
|
+
* desktop app, the fm.example.com explorer and the work/fishapp embeds all mount
|
|
282
|
+
* this same component, and a continuity that works in one of them is two
|
|
283
|
+
* products. So the dialog remembers what IT submitted.
|
|
284
|
+
*
|
|
285
|
+
* `query` is the honesty check, not a nicety. The toolbar field holds exactly
|
|
286
|
+
* the wire query a submit produced (`applyAdvancedSearch` writes it there),
|
|
287
|
+
* and typing in that field CLEARS the advanced filters (`onToolbarSearch`).
|
|
288
|
+
* So "the seed still equals what I submitted" is the strongest available
|
|
289
|
+
* evidence that the filter we remember is the filter in force; anything else
|
|
290
|
+
* — a cleared box, an edited query, a plain search — falls back to `reset()`.
|
|
291
|
+
*/
|
|
292
|
+
interface FormState {
|
|
293
|
+
text: string;
|
|
294
|
+
scope: AdvScope;
|
|
295
|
+
tagsRaw: string;
|
|
296
|
+
excludeRaw: string;
|
|
297
|
+
type: TypeFilter;
|
|
298
|
+
modified: ModifiedFilter;
|
|
299
|
+
aroundDate: string;
|
|
300
|
+
aroundSpan: AroundSpan;
|
|
301
|
+
size: SizeFilter;
|
|
302
|
+
sizeFrom: string | number;
|
|
303
|
+
sizeTo: string | number;
|
|
304
|
+
sizeUnit: 'kb' | 'mb' | 'gb';
|
|
305
|
+
pathMode: PathMode;
|
|
306
|
+
people: PeopleFilter;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function snapshot(): FormState {
|
|
310
|
+
return {
|
|
311
|
+
text: text.value,
|
|
312
|
+
scope: scope.value,
|
|
313
|
+
tagsRaw: tagsRaw.value,
|
|
314
|
+
excludeRaw: excludeRaw.value,
|
|
315
|
+
type: type.value,
|
|
316
|
+
modified: modified.value,
|
|
317
|
+
aroundDate: aroundDate.value,
|
|
318
|
+
aroundSpan: aroundSpan.value,
|
|
319
|
+
size: size.value,
|
|
320
|
+
sizeFrom: sizeFrom.value,
|
|
321
|
+
sizeTo: sizeTo.value,
|
|
322
|
+
sizeUnit: sizeUnit.value,
|
|
323
|
+
pathMode: pathMode.value,
|
|
324
|
+
people: people.value,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function restore(s: FormState) {
|
|
329
|
+
text.value = s.text;
|
|
330
|
+
scope.value = s.scope;
|
|
331
|
+
tagsRaw.value = s.tagsRaw;
|
|
332
|
+
excludeRaw.value = s.excludeRaw;
|
|
333
|
+
type.value = s.type;
|
|
334
|
+
modified.value = s.modified;
|
|
335
|
+
aroundDate.value = s.aroundDate;
|
|
336
|
+
aroundSpan.value = s.aroundSpan;
|
|
337
|
+
size.value = s.size;
|
|
338
|
+
sizeFrom.value = s.sizeFrom;
|
|
339
|
+
sizeTo.value = s.sizeTo;
|
|
340
|
+
sizeUnit.value = s.sizeUnit;
|
|
341
|
+
pathMode.value = s.pathMode;
|
|
342
|
+
people.value = s.people;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The form as it stood when Search was last pressed, with the wire query it
|
|
346
|
+
* produced. Not a ref: nothing renders it, and a reactive copy of the whole
|
|
347
|
+
* form would re-run the count watcher for a value the user cannot see. */
|
|
348
|
+
let submitted: { query: string; form: FormState } | null = null;
|
|
349
|
+
|
|
350
|
+
// Opening either CONTINUES the advanced search that is running, or seeds a
|
|
351
|
+
// fresh one from whatever the toolbar held.
|
|
352
|
+
watch(
|
|
353
|
+
() => props.open,
|
|
354
|
+
(v) => {
|
|
355
|
+
if (!v) return;
|
|
356
|
+
const seed = props.initialQuery ?? '';
|
|
357
|
+
if (submitted && submitted.query !== '' && submitted.query === seed) {
|
|
358
|
+
restore(submitted.form);
|
|
359
|
+
} else {
|
|
360
|
+
reset();
|
|
361
|
+
text.value = seed;
|
|
362
|
+
}
|
|
363
|
+
counted.value = null;
|
|
364
|
+
countError.value = false;
|
|
365
|
+
setTimeout(() => queryEl.value?.focus(), 60);
|
|
366
|
+
},
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Bytes for one end of the custom range, or null when it is left open.
|
|
371
|
+
*
|
|
372
|
+
* ⚠⚠ Takes `unknown` on purpose. This read `raw.trim()` and typed the
|
|
373
|
+
* parameter `string`, which is what the ref was declared as — and Vue casts a
|
|
374
|
+
* number input's value to a NUMBER, so `trim` was not a function. The throw
|
|
375
|
+
* happened inside the `request` computed, which is what the count watcher
|
|
376
|
+
* reads, so the watcher's getter threw before it could fire: the dialog kept
|
|
377
|
+
* printing the PREVIOUS count and looked like a filter that simply did not
|
|
378
|
+
* narrow. Measured in a real browser on 2026-09-12 — "to = 0 MB" over two
|
|
379
|
+
* files of 16 and 184 bytes still said "2 matching items". Nothing in the
|
|
380
|
+
* console of the page under test, nothing in vue-tsc, nothing in the unit
|
|
381
|
+
* suite: a filter that is a no-op is indistinguishable from a filter that
|
|
382
|
+
* matched everything unless you check it against rows you know it must drop.
|
|
383
|
+
*
|
|
384
|
+
* An empty field is `''` and must stay "open end", never `Number('') === 0` —
|
|
385
|
+
* a zero ceiling means nothing passes.
|
|
386
|
+
*/
|
|
387
|
+
function bytes(raw: unknown): number | null {
|
|
388
|
+
const s = String(raw ?? '').trim();
|
|
389
|
+
if (!s) return null;
|
|
390
|
+
const n = Number(s);
|
|
391
|
+
if (!Number.isFinite(n) || n < 0) return null;
|
|
392
|
+
return Math.round(n * UNIT_FACTOR[sizeUnit.value]);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const request = computed<AdvSearchRequest>(() => ({
|
|
396
|
+
...emptyAdvSearch(props.pathBase ?? ''),
|
|
397
|
+
text: text.value,
|
|
398
|
+
tags: parseTagList(tagsRaw.value),
|
|
399
|
+
excludeTags: parseTagList(excludeRaw.value),
|
|
400
|
+
scope: scope.value,
|
|
401
|
+
filters: {
|
|
402
|
+
...EMPTY_FILTERS,
|
|
403
|
+
type: type.value,
|
|
404
|
+
modified: modified.value,
|
|
405
|
+
aroundDate: aroundDate.value,
|
|
406
|
+
aroundSpan: aroundSpan.value,
|
|
407
|
+
size: size.value,
|
|
408
|
+
sizeMin: size.value === 'range' ? bytes(sizeFrom.value) : null,
|
|
409
|
+
sizeMax: size.value === 'range' ? bytes(sizeTo.value) : null,
|
|
410
|
+
pathMode: pathMode.value,
|
|
411
|
+
pathBase: props.pathBase ?? '',
|
|
412
|
+
/* ⚠ Read through the guard, not off the ref. The `ownerFilterable` watch
|
|
413
|
+
below already clears the choice when the scope leaves `name`, so this is
|
|
414
|
+
the SECOND lock, not the first: a disabled select cannot be changed but
|
|
415
|
+
it can still hold a value chosen before the scope moved, and a filter
|
|
416
|
+
that outlives its own control narrows a search nobody asked it to. The
|
|
417
|
+
two are worth keeping apart — one is what the user sees change, the
|
|
418
|
+
other is what actually reaches the predicate. */
|
|
419
|
+
people: ownerFilterable.value ? people.value : 'any',
|
|
420
|
+
},
|
|
421
|
+
}));
|
|
422
|
+
|
|
423
|
+
const nothingToAsk = computed(() => advSearchEmpty(request.value));
|
|
424
|
+
|
|
425
|
+
/** Exactly what would go on the wire, shown to the user. The tag box becomes
|
|
426
|
+
* `tag:` tokens and there is no reason to hide that — somebody who learns the
|
|
427
|
+
* syntax here can type it straight into the toolbar next time. */
|
|
428
|
+
const wireQuery = computed(() => advQueryString(request.value));
|
|
429
|
+
|
|
430
|
+
/* ── the live count: a real search, debounced ────────────────────────────── */
|
|
431
|
+
|
|
432
|
+
const counted = ref<AdvCountResult | null>(null);
|
|
433
|
+
const counting = ref(false);
|
|
434
|
+
const countError = ref(false);
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The per-account People members, offered only when a real search actually
|
|
438
|
+
* returned rows belonging to somebody else.
|
|
439
|
+
*
|
|
440
|
+
* ⚠ Derived from the count run, never from a directory. The dialog holds no
|
|
441
|
+
* listing of its own, and offering every account on the install would put
|
|
442
|
+
* names in the menu that cannot narrow anything here — the same reason
|
|
443
|
+
* `peopleOptions` in `lib/fileFilters.ts` derives the pill's members from the
|
|
444
|
+
* rows in hand. A count result that does not report them (the field is
|
|
445
|
+
* optional, and the explorer does not fill it yet) simply offers the three
|
|
446
|
+
* that need no data.
|
|
447
|
+
*/
|
|
448
|
+
const peopleOptions = computed<PeopleOption[]>(() => [
|
|
449
|
+
...PEOPLE_FIXED.map((value) => ({ value })),
|
|
450
|
+
...(counted.value?.people ?? []),
|
|
451
|
+
]);
|
|
452
|
+
|
|
453
|
+
/** The label for one member: the fixed three are named by the catalogue, an
|
|
454
|
+
* account by itself. A nameless account falls back to the neutral word rather
|
|
455
|
+
* than printing `u:7` at somebody. */
|
|
456
|
+
function peopleLabel(o: PeopleOption): string {
|
|
457
|
+
if (o.value === 'any' || o.value === 'me' || o.value === 'system') {
|
|
458
|
+
return t(`filter.people.${o.value}`);
|
|
459
|
+
}
|
|
460
|
+
return o.name || t('filter.people.someone');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* ⚠ A member can leave the menu — a later count over different rows may not
|
|
465
|
+
* contain that account at all — and a `<select>` whose value is no longer one
|
|
466
|
+
* of its options renders BLANK while it goes on filtering. Falling back to
|
|
467
|
+
* `any` keeps what is shown and what is applied the same thing.
|
|
468
|
+
*/
|
|
469
|
+
watch(peopleOptions, (opts) => {
|
|
470
|
+
if (!opts.some((o) => o.value === people.value)) people.value = 'any';
|
|
471
|
+
});
|
|
472
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
473
|
+
/** Only the newest run may write the answer — a slow early query landing after
|
|
474
|
+
* a fast later one would print a count for a search nobody is looking at. */
|
|
475
|
+
let seq = 0;
|
|
476
|
+
|
|
477
|
+
function scheduleCount() {
|
|
478
|
+
if (timer) clearTimeout(timer);
|
|
479
|
+
countError.value = false;
|
|
480
|
+
if (nothingToAsk.value) {
|
|
481
|
+
counted.value = null;
|
|
482
|
+
counting.value = false;
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
counting.value = true;
|
|
486
|
+
timer = setTimeout(run, 450);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
async function run() {
|
|
490
|
+
const mine = ++seq;
|
|
491
|
+
const req = request.value;
|
|
492
|
+
try {
|
|
493
|
+
const res = await props.count(req);
|
|
494
|
+
if (mine !== seq) return;
|
|
495
|
+
counted.value = res;
|
|
496
|
+
} catch {
|
|
497
|
+
if (mine !== seq) return;
|
|
498
|
+
counted.value = null;
|
|
499
|
+
countError.value = true;
|
|
500
|
+
} finally {
|
|
501
|
+
if (mine === seq) counting.value = false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* ⚠ The guard the `bytes()` bug earned. A throw while building `request` used
|
|
507
|
+
* to happen inside this watcher's own getter, so the watcher never fired and
|
|
508
|
+
* the dialog went on showing the count from before the change — a broken
|
|
509
|
+
* filter wearing the face of a filter that matched everything. A failure is
|
|
510
|
+
* now REPORTED (the count line says it could not run) instead of leaving a
|
|
511
|
+
* stale number on screen. `'ERR'` is a constant, not a timestamp: a value that
|
|
512
|
+
* changed every evaluation would re-trigger the watcher forever.
|
|
513
|
+
*/
|
|
514
|
+
watch(
|
|
515
|
+
() => {
|
|
516
|
+
if (!props.open) return '';
|
|
517
|
+
try {
|
|
518
|
+
return JSON.stringify(request.value);
|
|
519
|
+
} catch {
|
|
520
|
+
return 'ERR';
|
|
521
|
+
}
|
|
522
|
+
},
|
|
523
|
+
(v) => {
|
|
524
|
+
if (!v) return;
|
|
525
|
+
if (v === 'ERR') {
|
|
526
|
+
if (timer) clearTimeout(timer);
|
|
527
|
+
seq++;
|
|
528
|
+
counted.value = null;
|
|
529
|
+
counting.value = false;
|
|
530
|
+
countError.value = true;
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
scheduleCount();
|
|
534
|
+
},
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
onBeforeUnmount(() => {
|
|
538
|
+
if (timer) clearTimeout(timer);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The count sentence.
|
|
543
|
+
*
|
|
544
|
+
* ⚠ This dialog's most common real answer on a small folder IS one, and "1
|
|
545
|
+
* matching items" is what a bare `{n}` message produces. Both keys have a
|
|
546
|
+
* `_one` form, and `t()` picks it from `n` (composables/useLocale →
|
|
547
|
+
* countedKey) — the rule that used to be written out here, once per caller.
|
|
548
|
+
*/
|
|
549
|
+
const countLabel = computed(() => {
|
|
550
|
+
const c = counted.value;
|
|
551
|
+
if (!c) return '';
|
|
552
|
+
return t(c.capped ? 'advsearch.count.capped' : 'advsearch.count.result', { n: String(c.count) });
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
function submit() {
|
|
556
|
+
if (nothingToAsk.value) return;
|
|
557
|
+
// Remember BEFORE emitting: the parent closes the dialog synchronously, and
|
|
558
|
+
// the `open` watcher that reads this runs on the next opening.
|
|
559
|
+
submitted = { query: wireQuery.value, form: snapshot() };
|
|
560
|
+
emit('submit', request.value);
|
|
561
|
+
}
|
|
562
|
+
</script>
|
|
563
|
+
|
|
564
|
+
<template>
|
|
565
|
+
<Modal
|
|
566
|
+
:open="open"
|
|
567
|
+
:title="t('advsearch.title')"
|
|
568
|
+
size="lg"
|
|
569
|
+
:theme="theme"
|
|
570
|
+
@close="emit('close')"
|
|
571
|
+
>
|
|
572
|
+
<form class="fe-advsearch" @submit.prevent="submit">
|
|
573
|
+
<!-- ── the dialog's own head ───────────────────────────────────────
|
|
574
|
+
Modal draws a plain title bar; this dialog needs the mark, the
|
|
575
|
+
title and the sentence under it as ONE block across the full
|
|
576
|
+
width, so it draws its own and the stylesheet folds Modal's away
|
|
577
|
+
(`.fe-modal__card:has(.fe-advsearch) .fe-modal__head`). The h2 up
|
|
578
|
+
there stays in the DOM on purpose — it is what `aria-labelledby`
|
|
579
|
+
on the dialog points at, so the name survives the fold. -->
|
|
580
|
+
<header class="fe-advsearch__head">
|
|
581
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
582
|
+
<span class="fe-advsearch__head-icon" aria-hidden="true" v-html="actionIconSvg('search')"></span>
|
|
583
|
+
<div class="fe-advsearch__head-text">
|
|
584
|
+
<p class="fe-advsearch__head-title">{{ t('advsearch.title') }}</p>
|
|
585
|
+
<p class="fe-advsearch__head-sub">{{ t('advsearch.subtitle') }}</p>
|
|
586
|
+
</div>
|
|
587
|
+
<button
|
|
588
|
+
type="button"
|
|
589
|
+
class="fe-advsearch__head-close"
|
|
590
|
+
:title="t('advsearch.close')"
|
|
591
|
+
:aria-label="t('advsearch.close')"
|
|
592
|
+
@click="emit('close')"
|
|
593
|
+
>
|
|
594
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
595
|
+
<span aria-hidden="true" v-html="actionIconSvg('close')"></span>
|
|
596
|
+
</button>
|
|
597
|
+
</header>
|
|
598
|
+
|
|
599
|
+
<!-- ── what the server answers: the text and the scope ─────────────
|
|
600
|
+
One wide field and one row of tabs, because this is the whole of
|
|
601
|
+
the question that leaves the browser. -->
|
|
602
|
+
<label class="fe-advsearch__box">
|
|
603
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
604
|
+
<span class="fe-advsearch__glass" aria-hidden="true" v-html="actionIconSvg('search')"></span>
|
|
605
|
+
<input
|
|
606
|
+
ref="queryEl"
|
|
607
|
+
v-model="text"
|
|
608
|
+
type="search"
|
|
609
|
+
class="fe-advsearch__input"
|
|
610
|
+
:placeholder="t('advsearch.query.placeholder')"
|
|
611
|
+
:aria-label="t('advsearch.query.label')"
|
|
612
|
+
data-testid="advsearch-query"
|
|
613
|
+
/>
|
|
614
|
+
</label>
|
|
615
|
+
|
|
616
|
+
<div
|
|
617
|
+
class="fe-advsearch__seg fe-advsearch__seg--tabs"
|
|
618
|
+
role="tablist"
|
|
619
|
+
:aria-label="t('advsearch.scope.label')"
|
|
620
|
+
>
|
|
621
|
+
<button
|
|
622
|
+
v-for="s in scopeOptions"
|
|
623
|
+
:key="s"
|
|
624
|
+
type="button"
|
|
625
|
+
role="tab"
|
|
626
|
+
class="fe-advsearch__seg-btn"
|
|
627
|
+
:class="{ 'is-active': scope === s }"
|
|
628
|
+
:aria-selected="scope === s"
|
|
629
|
+
:data-testid="`advsearch-scope-${s}`"
|
|
630
|
+
@click="scope = s"
|
|
631
|
+
>
|
|
632
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
633
|
+
<span class="fe-advsearch__seg-icon" aria-hidden="true" v-html="actionIconSvg(SCOPE_ICON[s])"></span>
|
|
634
|
+
{{ t(`advsearch.scope.${s}`) }}
|
|
635
|
+
</button>
|
|
636
|
+
</div>
|
|
637
|
+
|
|
638
|
+
<!-- ── the two columns ─────────────────────────────────────────────
|
|
639
|
+
⚠ Every control below the tabs narrows the rows that CAME BACK —
|
|
640
|
+
no endpoint reads a date, a size, a mime or a path. The dialog
|
|
641
|
+
says so in one sentence under the count rather than shouting it
|
|
642
|
+
over two half-page captions; the sentence is the part that has to
|
|
643
|
+
survive, not the caption. -->
|
|
644
|
+
<div class="fe-advsearch__cols">
|
|
645
|
+
<div class="fe-advsearch__col">
|
|
646
|
+
<div class="fe-advsearch__group">
|
|
647
|
+
<label class="fe-advsearch__field">
|
|
648
|
+
<span class="fe-advsearch__label">{{ t('filter.modified') }}</span>
|
|
649
|
+
<select v-model="modified" class="fe-input" data-testid="advsearch-modified">
|
|
650
|
+
<option v-for="o in MODIFIED_OPTIONS" :key="o" :value="o">
|
|
651
|
+
{{ t(`filter.modified.${o}`) }}
|
|
652
|
+
</option>
|
|
653
|
+
</select>
|
|
654
|
+
</label>
|
|
655
|
+
<div v-if="modified === 'around'" class="fe-advsearch__field">
|
|
656
|
+
<span class="fe-advsearch__sublabel">{{ t('advsearch.around.label') }}</span>
|
|
657
|
+
<div class="fe-advsearch__pair">
|
|
658
|
+
<input
|
|
659
|
+
v-model="aroundDate"
|
|
660
|
+
type="datetime-local"
|
|
661
|
+
class="fe-input"
|
|
662
|
+
:aria-label="t('advsearch.around.date')"
|
|
663
|
+
data-testid="advsearch-around-date"
|
|
664
|
+
/>
|
|
665
|
+
<div class="fe-advsearch__seg" role="group" :aria-label="t('advsearch.around.label')">
|
|
666
|
+
<button
|
|
667
|
+
v-for="s in SPAN_OPTIONS"
|
|
668
|
+
:key="s"
|
|
669
|
+
type="button"
|
|
670
|
+
class="fe-advsearch__seg-btn"
|
|
671
|
+
:class="{ 'is-active': aroundSpan === s }"
|
|
672
|
+
:aria-pressed="aroundSpan === s"
|
|
673
|
+
@click="aroundSpan = s"
|
|
674
|
+
>
|
|
675
|
+
{{ t(`advsearch.around.${s}`) }}
|
|
676
|
+
</button>
|
|
677
|
+
</div>
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
682
|
+
<label class="fe-advsearch__field">
|
|
683
|
+
<span class="fe-advsearch__label">{{ t('filter.type') }}</span>
|
|
684
|
+
<select v-model="type" class="fe-input" data-testid="advsearch-type">
|
|
685
|
+
<option v-for="o in TYPE_OPTIONS" :key="o" :value="o">{{ t(`filter.type.${o}`) }}</option>
|
|
686
|
+
</select>
|
|
687
|
+
</label>
|
|
688
|
+
|
|
689
|
+
<!-- Whose it is. Same union and same predicate as the filter row's
|
|
690
|
+
People pill, so "files you put here" means one thing in this
|
|
691
|
+
product. Off under the content scopes, and it says why rather
|
|
692
|
+
than vanishing — see `ownerFilterable`. -->
|
|
693
|
+
<div class="fe-advsearch__group">
|
|
694
|
+
<label class="fe-advsearch__field">
|
|
695
|
+
<span class="fe-advsearch__label">{{ t('filter.people') }}</span>
|
|
696
|
+
<select
|
|
697
|
+
v-model="people"
|
|
698
|
+
class="fe-input"
|
|
699
|
+
:disabled="!ownerFilterable"
|
|
700
|
+
data-testid="advsearch-people"
|
|
701
|
+
>
|
|
702
|
+
<option v-for="o in peopleOptions" :key="o.value" :value="o.value">
|
|
703
|
+
{{ peopleLabel(o) }}
|
|
704
|
+
</option>
|
|
705
|
+
</select>
|
|
706
|
+
</label>
|
|
707
|
+
<p v-if="!ownerFilterable" class="fe-advsearch__hint fe-advsearch__hint--block">
|
|
708
|
+
{{ t('advsearch.people.name_only') }}
|
|
709
|
+
</p>
|
|
710
|
+
</div>
|
|
711
|
+
|
|
712
|
+
<div class="fe-advsearch__group">
|
|
713
|
+
<label class="fe-advsearch__field">
|
|
714
|
+
<span class="fe-advsearch__label">{{ t('advsearch.tags.label') }}</span>
|
|
715
|
+
<input
|
|
716
|
+
v-model="tagsRaw"
|
|
717
|
+
type="text"
|
|
718
|
+
class="fe-input"
|
|
719
|
+
:placeholder="t('advsearch.tags.placeholder')"
|
|
720
|
+
data-testid="advsearch-tags"
|
|
721
|
+
/>
|
|
722
|
+
</label>
|
|
723
|
+
<label class="fe-advsearch__field">
|
|
724
|
+
<span class="fe-advsearch__label">{{ t('advsearch.tags.exclude') }}</span>
|
|
725
|
+
<input
|
|
726
|
+
v-model="excludeRaw"
|
|
727
|
+
type="text"
|
|
728
|
+
class="fe-input"
|
|
729
|
+
:placeholder="t('advsearch.tags.exclude_placeholder')"
|
|
730
|
+
data-testid="advsearch-extags"
|
|
731
|
+
/>
|
|
732
|
+
</label>
|
|
733
|
+
<p class="fe-advsearch__hint fe-advsearch__hint--block">{{ t('advsearch.tags.hint') }}</p>
|
|
734
|
+
</div>
|
|
735
|
+
</div>
|
|
736
|
+
|
|
737
|
+
<div class="fe-advsearch__col">
|
|
738
|
+
<div class="fe-advsearch__group">
|
|
739
|
+
<label class="fe-advsearch__field">
|
|
740
|
+
<span class="fe-advsearch__label">{{ t('filter.size') }}</span>
|
|
741
|
+
<select v-model="size" class="fe-input" data-testid="advsearch-size">
|
|
742
|
+
<option v-for="o in SIZE_OPTIONS" :key="o" :value="o">{{ t(`filter.size.${o}`) }}</option>
|
|
743
|
+
</select>
|
|
744
|
+
</label>
|
|
745
|
+
<div v-if="size === 'range'" class="fe-advsearch__field">
|
|
746
|
+
<span class="fe-advsearch__sublabel">{{ t('advsearch.size.range_label') }}</span>
|
|
747
|
+
<div class="fe-advsearch__pair">
|
|
748
|
+
<input
|
|
749
|
+
v-model="sizeFrom"
|
|
750
|
+
type="number"
|
|
751
|
+
min="0"
|
|
752
|
+
class="fe-input fe-advsearch__num"
|
|
753
|
+
:placeholder="t('advsearch.size.from')"
|
|
754
|
+
:aria-label="t('advsearch.size.from')"
|
|
755
|
+
data-testid="advsearch-size-from"
|
|
756
|
+
/>
|
|
757
|
+
<span class="fe-advsearch__dash" aria-hidden="true">–</span>
|
|
758
|
+
<input
|
|
759
|
+
v-model="sizeTo"
|
|
760
|
+
type="number"
|
|
761
|
+
min="0"
|
|
762
|
+
class="fe-input fe-advsearch__num"
|
|
763
|
+
:placeholder="t('advsearch.size.to')"
|
|
764
|
+
:aria-label="t('advsearch.size.to')"
|
|
765
|
+
data-testid="advsearch-size-to"
|
|
766
|
+
/>
|
|
767
|
+
<select v-model="sizeUnit" class="fe-input fe-advsearch__unit" :aria-label="t('filter.size')">
|
|
768
|
+
<option value="kb">{{ t('unit.kb') }}</option>
|
|
769
|
+
<option value="mb">{{ t('unit.mb') }}</option>
|
|
770
|
+
<option value="gb">{{ t('unit.gb') }}</option>
|
|
771
|
+
</select>
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
</div>
|
|
775
|
+
|
|
776
|
+
<!-- The folder. A segmented trio rather than a menu, because the
|
|
777
|
+
three answers are the whole of the choice and the sentence
|
|
778
|
+
under it then names the folder the choice is about — the
|
|
779
|
+
folder captured when the dialog opened, not wherever the
|
|
780
|
+
explorer has since been rebased to. -->
|
|
781
|
+
<div class="fe-advsearch__group">
|
|
782
|
+
<span class="fe-advsearch__label">{{ t('advsearch.where.label') }}</span>
|
|
783
|
+
<div
|
|
784
|
+
class="fe-advsearch__seg fe-advsearch__seg--wide"
|
|
785
|
+
role="group"
|
|
786
|
+
:aria-label="t('advsearch.where.label')"
|
|
787
|
+
data-testid="advsearch-where"
|
|
788
|
+
>
|
|
789
|
+
<button
|
|
790
|
+
v-for="m in PATH_MODES"
|
|
791
|
+
:key="m"
|
|
792
|
+
type="button"
|
|
793
|
+
class="fe-advsearch__seg-btn"
|
|
794
|
+
:class="{ 'is-active': pathMode === m }"
|
|
795
|
+
:aria-pressed="pathMode === m"
|
|
796
|
+
:data-testid="`advsearch-where-${m}`"
|
|
797
|
+
@click="pathMode = m"
|
|
798
|
+
>
|
|
799
|
+
{{ t(`advsearch.where.${m}_short`) }}
|
|
800
|
+
</button>
|
|
801
|
+
</div>
|
|
802
|
+
<span class="fe-advsearch__hint">
|
|
803
|
+
{{ t(`advsearch.where.${pathMode}`, { folder: folderLabel || '' }) }}
|
|
804
|
+
</span>
|
|
805
|
+
</div>
|
|
806
|
+
|
|
807
|
+
<!-- What "contents" means here. This is the 200 KB sentence, kept
|
|
808
|
+
where the reference shell keeps its content options: a reader
|
|
809
|
+
who picks the Content tab has to learn what the index covers
|
|
810
|
+
BEFORE an empty result teaches them. -->
|
|
811
|
+
<div class="fe-advsearch__group">
|
|
812
|
+
<p class="fe-advsearch__grouphead">
|
|
813
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
814
|
+
<span class="fe-advsearch__grouphead-icon" aria-hidden="true" v-html="actionIconSvg('preview')"></span>
|
|
815
|
+
{{ t('advsearch.content.heading') }}
|
|
816
|
+
</p>
|
|
817
|
+
<p class="fe-advsearch__hint fe-advsearch__hint--block">
|
|
818
|
+
{{ contentSearch ? t('advsearch.scope.hint') : t('advsearch.scope.name_only') }}
|
|
819
|
+
</p>
|
|
820
|
+
</div>
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
|
|
824
|
+
<!-- ── the live count ──────────────────────────────────────────────
|
|
825
|
+
A real query, debounced, and the line under it says both what
|
|
826
|
+
that cost and which half of the form the server never saw. -->
|
|
827
|
+
<div class="fe-advsearch__count" data-testid="advsearch-count">
|
|
828
|
+
<div class="fe-advsearch__count-top">
|
|
829
|
+
<p class="fe-advsearch__count-line">
|
|
830
|
+
<span v-if="nothingToAsk">{{ t('advsearch.count.idle') }}</span>
|
|
831
|
+
<span v-else-if="counting">{{ t('advsearch.count.counting') }}</span>
|
|
832
|
+
<span v-else-if="countError">{{ t('advsearch.count.error') }}</span>
|
|
833
|
+
<strong v-else-if="counted">{{ countLabel }}</strong>
|
|
834
|
+
</p>
|
|
835
|
+
<button
|
|
836
|
+
v-if="!nothingToAsk"
|
|
837
|
+
type="button"
|
|
838
|
+
class="fe-advsearch__viewall"
|
|
839
|
+
data-testid="advsearch-viewall"
|
|
840
|
+
@click="submit"
|
|
841
|
+
>{{ t('advsearch.viewall') }}</button>
|
|
842
|
+
</div>
|
|
843
|
+
<p class="fe-advsearch__hint fe-advsearch__hint--block">{{ t('advsearch.count.cost') }}</p>
|
|
844
|
+
<p v-if="wireQuery" class="fe-advsearch__wire">
|
|
845
|
+
{{ t('advsearch.wire') }} <code>{{ wireQuery }}</code>
|
|
846
|
+
</p>
|
|
847
|
+
</div>
|
|
848
|
+
</form>
|
|
849
|
+
|
|
850
|
+
<template #actions>
|
|
851
|
+
<button type="button" class="fe-btn fe-advsearch__reset" @click="reset">
|
|
852
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
853
|
+
<span class="fe-advsearch__btn-icon" aria-hidden="true" v-html="actionIconSvg('refresh')"></span>
|
|
854
|
+
{{ t('advsearch.reset') }}
|
|
855
|
+
</button>
|
|
856
|
+
<button type="button" class="fe-btn" @click="emit('close')">{{ t('advsearch.cancel') }}</button>
|
|
857
|
+
<button
|
|
858
|
+
type="button"
|
|
859
|
+
class="fe-btn fe-btn--primary"
|
|
860
|
+
:disabled="nothingToAsk"
|
|
861
|
+
data-testid="advsearch-submit"
|
|
862
|
+
@click="submit"
|
|
863
|
+
>
|
|
864
|
+
<!-- eslint-disable-next-line vue/no-v-html — static markup from lib/actionIcons -->
|
|
865
|
+
<span class="fe-advsearch__btn-icon" aria-hidden="true" v-html="actionIconSvg('search')"></span>
|
|
866
|
+
{{ t('advsearch.submit') }}
|
|
867
|
+
</button>
|
|
868
|
+
</template>
|
|
869
|
+
</Modal>
|
|
870
|
+
</template>
|