@xelqun/forms 0.1.0 → 0.2.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 +46 -61
- package/package.json +32 -2
- package/resources/css/xelqun.css +121 -0
- package/resources/js/components/XqAction.vue +143 -0
- package/resources/js/components/XqFieldSlot.vue +20 -0
- package/resources/js/components/XqFieldWrapper.vue +74 -30
- package/resources/js/components/XqForm.vue +36 -9
- package/resources/js/components/XqNode.vue +8 -1
- package/resources/js/components/XqUnknownNode.vue +2 -1
- package/resources/js/components/defaults.ts +36 -0
- package/resources/js/components/fields/XqBlockPicker.vue +68 -0
- package/resources/js/components/fields/XqCheckboxField.vue +35 -9
- package/resources/js/components/fields/XqCheckboxListField.vue +115 -0
- package/resources/js/components/fields/XqCodeEditorField.vue +92 -0
- package/resources/js/components/fields/XqColorPickerField.vue +156 -0
- package/resources/js/components/fields/XqDatePickerField.vue +149 -53
- package/resources/js/components/fields/XqFileUploadField.vue +524 -165
- package/resources/js/components/fields/XqImageEditor.vue +165 -0
- package/resources/js/components/fields/XqKeyValueField.vue +168 -0
- package/resources/js/components/fields/XqMarkdownEditorField.vue +245 -0
- package/resources/js/components/fields/XqRadioField.vue +9 -5
- package/resources/js/components/fields/XqRepeaterField.vue +314 -79
- package/resources/js/components/fields/XqRepeaterRowActions.vue +101 -0
- package/resources/js/components/fields/XqRichEditorField.vue +280 -0
- package/resources/js/components/fields/XqSelectField.vue +29 -12
- package/resources/js/components/fields/XqSliderField.vue +47 -0
- package/resources/js/components/fields/XqTagsInputField.vue +127 -0
- package/resources/js/components/fields/XqTextInputField.vue +121 -12
- package/resources/js/components/fields/XqTextareaField.vue +39 -16
- package/resources/js/components/fields/XqToggleButtonsField.vue +75 -0
- package/resources/js/components/fields/XqToggleField.vue +33 -9
- package/resources/js/components/layouts/XqActions.vue +19 -0
- package/resources/js/components/layouts/XqBlock.vue +52 -0
- package/resources/js/components/layouts/XqCallout.vue +46 -0
- package/resources/js/components/layouts/XqEmptyState.vue +37 -0
- package/resources/js/components/layouts/XqFlex.vue +23 -0
- package/resources/js/components/layouts/XqFusedGroup.vue +48 -0
- package/resources/js/components/layouts/XqIconNode.vue +32 -0
- package/resources/js/components/layouts/XqImageNode.vue +30 -0
- package/resources/js/components/layouts/XqSection.vue +3 -1
- package/resources/js/components/layouts/XqTabsLayout.vue +3 -21
- package/resources/js/components/layouts/XqTextNode.vue +49 -0
- package/resources/js/components/layouts/XqUnorderedList.vue +18 -0
- package/resources/js/components/layouts/XqWizard.vue +168 -0
- package/resources/js/composables/context.ts +14 -0
- package/resources/js/composables/useField.ts +21 -3
- package/resources/js/composables/usePanelErrors.ts +37 -0
- package/resources/js/composables/useSortable.ts +70 -0
- package/resources/js/composables/useUnsavedChangesAlert.ts +48 -0
- package/resources/js/core/dates.ts +38 -0
- package/resources/js/core/images.ts +110 -0
- package/resources/js/core/log.ts +1 -1
- package/resources/js/core/markdown.ts +61 -0
- package/resources/js/core/mask.ts +42 -0
- package/resources/js/core/patch.ts +6 -3
- package/resources/js/core/paths.ts +11 -14
- package/resources/js/core/store.ts +515 -25
- package/resources/js/core/translate.ts +17 -0
- package/resources/js/core/validation.ts +126 -0
- package/resources/js/index.ts +4 -3
- package/resources/js/lib/utils.ts +15 -0
- package/resources/js/plugin.ts +103 -6
- package/resources/js/schema/types.ts +102 -0
- package/resources/js/testing.ts +50 -0
- package/resources/js/ui/XqCalendar.vue +4 -0
- package/resources/js/ui/XqCombobox.vue +89 -32
- package/resources/js/ui/XqConfirmDialog.vue +58 -0
- package/resources/js/ui/XqDialog.vue +46 -0
- package/resources/js/ui/XqIcon.vue +28 -0
- package/resources/js/ui/XqSelect.vue +38 -18
- package/resources/js/ui/XqTabs.vue +1 -1
- package/resources/js/ui/XqTimeField.vue +66 -0
- package/resources/js/ui/classes.ts +33 -0
- package/resources/js/ui/icons.ts +95 -0
- package/resources/js/ui/index.ts +5 -0
- package/resources/js/ui/options.ts +46 -0
|
@@ -1,140 +1,362 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
ChevronDown,
|
|
4
|
+
ChevronUp,
|
|
5
|
+
Download,
|
|
6
|
+
ExternalLink,
|
|
7
|
+
FileText,
|
|
8
|
+
GripVertical,
|
|
9
|
+
LoaderCircle,
|
|
10
|
+
Pencil,
|
|
11
|
+
UploadCloud,
|
|
12
|
+
X,
|
|
13
|
+
} from 'lucide-vue-next';
|
|
14
|
+
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, ref } from 'vue';
|
|
4
15
|
import { useField } from '../../composables/useField';
|
|
16
|
+
import { prepareImage } from '../../core/images';
|
|
17
|
+
import { useSortable } from '../../composables/useSortable';
|
|
5
18
|
import { HttpError } from '../../core/http';
|
|
6
|
-
import { cn, formatBytes } from '../../lib/utils';
|
|
19
|
+
import { cn, focusFirstEnabled, formatBytes } from '../../lib/utils';
|
|
7
20
|
import type { SchemaNode } from '../../schema/types';
|
|
21
|
+
import { moveItem } from '../../ui/options';
|
|
8
22
|
import XqButton from '../../ui/XqButton.vue';
|
|
9
23
|
import XqFieldWrapper from '../XqFieldWrapper.vue';
|
|
24
|
+
import { useTranslator } from '../../composables/context';
|
|
25
|
+
|
|
26
|
+
const t = useTranslator();
|
|
27
|
+
const XqImageEditor = defineAsyncComponent(() => import('./XqImageEditor.vue'));
|
|
28
|
+
|
|
29
|
+
interface PendingFile {
|
|
30
|
+
id: number;
|
|
31
|
+
file: File;
|
|
32
|
+
name: string;
|
|
33
|
+
size: number;
|
|
34
|
+
percent: number;
|
|
35
|
+
status: 'waiting' | 'uploading' | 'finished';
|
|
36
|
+
token: string | null;
|
|
37
|
+
controller: AbortController;
|
|
38
|
+
}
|
|
10
39
|
|
|
11
40
|
const props = defineProps<{ node: SchemaNode }>();
|
|
12
|
-
const { store, path, value, error, describedBy, required, disabled, id } = useField<
|
|
13
|
-
|
|
14
|
-
);
|
|
41
|
+
const { store, path, value, error, describedBy, required, disabled, id } = useField<
|
|
42
|
+
string | string[] | null
|
|
43
|
+
>(props.node);
|
|
15
44
|
const options = computed(() => props.node.props ?? {});
|
|
45
|
+
const isMultiple = computed(() => !!options.value.multiple);
|
|
46
|
+
// One at a time by default: on SQLite two uploads at once hit "database is locked" in the rate limiter.
|
|
47
|
+
const parallelLimit = computed<number>(() => options.value.maxParallelUploads ?? 1);
|
|
16
48
|
|
|
17
49
|
const input = ref<HTMLInputElement | null>(null);
|
|
50
|
+
const root = ref<HTMLElement | null>(null);
|
|
51
|
+
const fileList = ref<HTMLElement | null>(null);
|
|
18
52
|
const dragging = ref(false);
|
|
19
|
-
const progress = ref<{ name: string; size: number; percent: number } | null>(null);
|
|
20
53
|
const uploadError = ref<string | null>(null);
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
54
|
+
const pending = ref<PendingFile[]>([]);
|
|
55
|
+
const editing = ref<ReturnType<typeof describe> | null>(null);
|
|
56
|
+
/** What this page knows about files it uploaded, before they have a stored path. */
|
|
57
|
+
const uploaded = ref(new Map<string, { name: string; size: number; preview: string | null }>());
|
|
58
|
+
let nextPendingId = 0;
|
|
59
|
+
let running = 0;
|
|
25
60
|
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
return
|
|
61
|
+
const values = computed<string[]>(() => {
|
|
62
|
+
if (Array.isArray(value.value)) {
|
|
63
|
+
return value.value;
|
|
29
64
|
}
|
|
30
65
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
name: local.value.name,
|
|
34
|
-
size: local.value.size,
|
|
35
|
-
url: local.value.preview,
|
|
36
|
-
isNew: true,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
66
|
+
return value.value ? [value.value] : [];
|
|
67
|
+
});
|
|
39
68
|
|
|
40
|
-
|
|
69
|
+
function setValues(next: string[]): void {
|
|
70
|
+
value.value = isMultiple.value ? next : (next[0] ?? null);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function describe(file: string) {
|
|
74
|
+
const local = uploaded.value.get(file);
|
|
75
|
+
const known = options.value.files?.[file];
|
|
76
|
+
const name = local?.name ?? known?.name ?? file.split('/').pop() ?? t('File');
|
|
77
|
+
const url: string | null = local?.preview ?? known?.url ?? null;
|
|
41
78
|
|
|
42
79
|
return {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
80
|
+
value: file,
|
|
81
|
+
name,
|
|
82
|
+
size: local?.size ?? known?.size ?? null,
|
|
83
|
+
url,
|
|
84
|
+
isNew: !!local,
|
|
85
|
+
isImage:
|
|
86
|
+
!!url &&
|
|
87
|
+
options.value.previewable !== false &&
|
|
88
|
+
(options.value.image || /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(name)),
|
|
47
89
|
};
|
|
48
|
-
}
|
|
90
|
+
}
|
|
49
91
|
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
92
|
+
const files = computed(() => values.value.map(describe));
|
|
93
|
+
const hasRoom = computed(() =>
|
|
94
|
+
isMultiple.value
|
|
95
|
+
? values.value.length + pending.value.length < (options.value.maxFiles ?? Infinity)
|
|
96
|
+
: values.value.length === 0 && pending.value.length === 0,
|
|
97
|
+
);
|
|
98
|
+
const canReorder = computed(
|
|
99
|
+
() => !!options.value.reorderable && isMultiple.value && !disabled.value,
|
|
54
100
|
);
|
|
55
|
-
|
|
56
101
|
const inputDescribedBy = computed(() => (uploadError.value ? `${id}-error` : describedBy.value));
|
|
102
|
+
const isAvatar = computed(() => !!options.value.avatar);
|
|
57
103
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
104
|
+
useSortable(fileList, {
|
|
105
|
+
draggable: '> [data-xq-file]',
|
|
106
|
+
handle: `[data-xq-drag="${path}"]`,
|
|
107
|
+
enabled: () => canReorder.value && values.value.length > 1,
|
|
108
|
+
onMove: (from, to) => move(values.value[from], to - from),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function move(file: string, offset: number): void {
|
|
112
|
+
setValues(moveItem(values.value, file, offset));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fileSelector(file: string): string {
|
|
116
|
+
return `[data-xq-file="${CSS.escape(file)}"]`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The button moved with its row, and a move ending at the edge disables it. */
|
|
120
|
+
async function moveWithButton(file: string, offset: number): Promise<void> {
|
|
121
|
+
move(file, offset);
|
|
122
|
+
|
|
123
|
+
await nextTick();
|
|
124
|
+
|
|
125
|
+
focusFirstEnabled(root.value, [
|
|
126
|
+
`${fileSelector(file)} [data-xq-move="${offset}"]`,
|
|
127
|
+
`${fileSelector(file)} [data-xq-move="${-offset}"]`,
|
|
128
|
+
]);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function forget(file: string): void {
|
|
132
|
+
const local = uploaded.value.get(file);
|
|
133
|
+
|
|
134
|
+
if (local?.preview) {
|
|
135
|
+
URL.revokeObjectURL(local.preview);
|
|
61
136
|
}
|
|
137
|
+
|
|
138
|
+
uploaded.value.delete(file);
|
|
62
139
|
}
|
|
63
140
|
|
|
64
|
-
|
|
65
|
-
|
|
141
|
+
function canRemove(file: { isNew: boolean }): boolean {
|
|
142
|
+
return !disabled.value && (file.isNew || options.value.deletable !== false);
|
|
143
|
+
}
|
|
66
144
|
|
|
145
|
+
function sizeProblem(picked: File): string | null {
|
|
67
146
|
if (options.value.maxSize && picked.size > options.value.maxSize * 1024) {
|
|
68
|
-
|
|
147
|
+
return t('The file may not be larger than :size.', {
|
|
148
|
+
size: formatBytes(options.value.maxSize * 1024),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
69
151
|
|
|
70
|
-
|
|
152
|
+
if (options.value.minSize && picked.size < options.value.minSize * 1024) {
|
|
153
|
+
return t('The file must be at least :size.', {
|
|
154
|
+
size: formatBytes(options.value.minSize * 1024),
|
|
155
|
+
});
|
|
71
156
|
}
|
|
72
157
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Add finished files in the order they were picked, whichever finished first.
|
|
163
|
+
*/
|
|
164
|
+
function collectFinished(): void {
|
|
165
|
+
while (pending.value[0]?.status === 'finished') {
|
|
166
|
+
const { token } = pending.value.shift()!;
|
|
167
|
+
|
|
168
|
+
if (token) {
|
|
169
|
+
setValues(
|
|
170
|
+
isMultiple.value && options.value.appendFiles !== false
|
|
171
|
+
? [...values.value, token]
|
|
172
|
+
: [token, ...values.value],
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function uploadOne(item: PendingFile): Promise<void> {
|
|
179
|
+
item.status = 'uploading';
|
|
76
180
|
|
|
77
181
|
try {
|
|
182
|
+
const file = await prepareImage(item.file, options.value.imageResize);
|
|
78
183
|
const result = await store.upload(
|
|
79
184
|
path,
|
|
80
|
-
|
|
81
|
-
(percent) =>
|
|
82
|
-
controller.signal,
|
|
185
|
+
file,
|
|
186
|
+
(percent) => (item.percent = percent),
|
|
187
|
+
item.controller.signal,
|
|
83
188
|
);
|
|
84
189
|
|
|
85
|
-
|
|
86
|
-
local.value = {
|
|
87
|
-
token: result.token,
|
|
190
|
+
uploaded.value.set(result.token, {
|
|
88
191
|
name: result.name,
|
|
89
192
|
size: result.size,
|
|
90
|
-
preview:
|
|
91
|
-
};
|
|
92
|
-
|
|
193
|
+
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
|
|
194
|
+
});
|
|
195
|
+
item.token = result.token;
|
|
93
196
|
} catch (failure) {
|
|
94
|
-
if (controller
|
|
95
|
-
|
|
197
|
+
if (!item.controller.signal.aborted) {
|
|
198
|
+
uploadError.value =
|
|
199
|
+
failure instanceof HttpError
|
|
200
|
+
? (failure.validationErrors.file?.[0] ?? failure.message)
|
|
201
|
+
: t('The upload failed. Please try again.');
|
|
96
202
|
}
|
|
203
|
+
} finally {
|
|
204
|
+
item.status = 'finished';
|
|
205
|
+
collectFinished();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function work(): Promise<void> {
|
|
210
|
+
running++;
|
|
97
211
|
|
|
212
|
+
for (
|
|
213
|
+
let item = pending.value.find(({ status }) => status === 'waiting');
|
|
214
|
+
item;
|
|
215
|
+
item = pending.value.find(({ status }) => status === 'waiting')
|
|
216
|
+
) {
|
|
217
|
+
await uploadOne(item);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
running--;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function addFiles(picked: File[]): void {
|
|
224
|
+
uploadError.value = null;
|
|
225
|
+
|
|
226
|
+
const room = isMultiple.value
|
|
227
|
+
? (options.value.maxFiles ?? Infinity) - values.value.length - pending.value.length
|
|
228
|
+
: 1;
|
|
229
|
+
|
|
230
|
+
if (picked.length > room) {
|
|
98
231
|
uploadError.value =
|
|
99
|
-
|
|
100
|
-
? (
|
|
101
|
-
: '
|
|
102
|
-
}
|
|
103
|
-
|
|
232
|
+
room === 1
|
|
233
|
+
? t('You can add 1 more file.')
|
|
234
|
+
: t('You can add :count more files.', { count: room });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const file of picked.slice(0, room)) {
|
|
238
|
+
const problem = sizeProblem(file);
|
|
239
|
+
|
|
240
|
+
if (problem) {
|
|
241
|
+
uploadError.value = problem;
|
|
104
242
|
|
|
105
|
-
|
|
106
|
-
input.value.value = '';
|
|
243
|
+
continue;
|
|
107
244
|
}
|
|
245
|
+
|
|
246
|
+
pending.value.push({
|
|
247
|
+
id: nextPendingId++,
|
|
248
|
+
file,
|
|
249
|
+
name: file.name,
|
|
250
|
+
size: file.size,
|
|
251
|
+
percent: 0,
|
|
252
|
+
status: 'waiting',
|
|
253
|
+
token: null,
|
|
254
|
+
controller: new AbortController(),
|
|
255
|
+
});
|
|
108
256
|
}
|
|
109
|
-
}
|
|
110
257
|
|
|
111
|
-
|
|
112
|
-
|
|
258
|
+
if (input.value) {
|
|
259
|
+
input.value.value = '';
|
|
260
|
+
}
|
|
113
261
|
|
|
114
|
-
|
|
115
|
-
|
|
262
|
+
const workers = Math.min(
|
|
263
|
+
parallelLimit.value - running,
|
|
264
|
+
pending.value.filter(({ status }) => status === 'waiting').length,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
for (let count = 0; count < workers; count++) {
|
|
268
|
+
store.trackUpload(work());
|
|
116
269
|
}
|
|
117
270
|
}
|
|
118
271
|
|
|
272
|
+
function onChange(event: Event): void {
|
|
273
|
+
addFiles([...((event.target as HTMLInputElement).files ?? [])]);
|
|
274
|
+
}
|
|
275
|
+
|
|
119
276
|
function onDrop(event: DragEvent): void {
|
|
120
277
|
dragging.value = false;
|
|
121
|
-
const picked = event.dataTransfer?.files?.[0];
|
|
122
278
|
|
|
123
|
-
if (
|
|
124
|
-
|
|
279
|
+
if (!disabled.value) {
|
|
280
|
+
addFiles([...(event.dataTransfer?.files ?? [])]);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function onPaste(event: ClipboardEvent): void {
|
|
285
|
+
const pasted = [...(event.clipboardData?.files ?? [])];
|
|
286
|
+
|
|
287
|
+
if (options.value.pasteable !== false && pasted.length && !disabled.value && hasRoom.value) {
|
|
288
|
+
event.preventDefault();
|
|
289
|
+
addFiles(pasted);
|
|
125
290
|
}
|
|
126
291
|
}
|
|
127
292
|
|
|
128
|
-
function
|
|
129
|
-
controller
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
293
|
+
function cancel(item: PendingFile): void {
|
|
294
|
+
item.controller.abort();
|
|
295
|
+
pending.value = pending.value.filter((other) => other.id !== item.id);
|
|
296
|
+
collectFinished();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function cancelAll(): void {
|
|
300
|
+
pending.value.forEach((item) => item.controller.abort());
|
|
301
|
+
pending.value = [];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The edited image uploads as a new file, in the place of the old one. The server
|
|
306
|
+
* gives a stored file back if it may not go, so that one can't be edited.
|
|
307
|
+
*/
|
|
308
|
+
function canEdit(file: ReturnType<typeof describe>): boolean {
|
|
309
|
+
return (
|
|
310
|
+
!!options.value.imageEditor &&
|
|
311
|
+
file.isImage &&
|
|
312
|
+
!disabled.value &&
|
|
313
|
+
(file.isNew || options.value.deletable !== false)
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function saveEdit(edited: File): Promise<void> {
|
|
318
|
+
const original = editing.value!.value;
|
|
319
|
+
|
|
320
|
+
editing.value = null;
|
|
321
|
+
uploadError.value = null;
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const result = await store.upload(path, edited, () => {});
|
|
325
|
+
|
|
326
|
+
uploaded.value.set(result.token, {
|
|
327
|
+
name: result.name,
|
|
328
|
+
size: result.size,
|
|
329
|
+
preview: URL.createObjectURL(edited),
|
|
330
|
+
});
|
|
331
|
+
setValues(values.value.map((file) => (file === original ? result.token : file)));
|
|
332
|
+
forget(original);
|
|
333
|
+
} catch (failure) {
|
|
334
|
+
uploadError.value =
|
|
335
|
+
failure instanceof HttpError
|
|
336
|
+
? (failure.validationErrors.file?.[0] ?? failure.message)
|
|
337
|
+
: t('The upload failed. Please try again.');
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function remove(file: string): Promise<void> {
|
|
342
|
+
const index = values.value.indexOf(file);
|
|
343
|
+
|
|
344
|
+
forget(file);
|
|
345
|
+
setValues(values.value.filter((other) => other !== file));
|
|
346
|
+
|
|
347
|
+
await nextTick();
|
|
348
|
+
|
|
349
|
+
const next = values.value[Math.min(index, values.value.length - 1)];
|
|
350
|
+
|
|
351
|
+
focusFirstEnabled(root.value, [
|
|
352
|
+
...(next ? [`${fileSelector(next)} [data-xq-remove]`] : []),
|
|
353
|
+
'input[type="file"]',
|
|
354
|
+
]);
|
|
133
355
|
}
|
|
134
356
|
|
|
135
357
|
onBeforeUnmount(() => {
|
|
136
|
-
|
|
137
|
-
|
|
358
|
+
cancelAll();
|
|
359
|
+
[...uploaded.value.keys()].forEach(forget);
|
|
138
360
|
});
|
|
139
361
|
</script>
|
|
140
362
|
|
|
@@ -146,102 +368,239 @@ onBeforeUnmount(() => {
|
|
|
146
368
|
:required="required"
|
|
147
369
|
:announce-error="!!uploadError"
|
|
148
370
|
>
|
|
149
|
-
<div
|
|
150
|
-
v-if="file && !progress"
|
|
151
|
-
class="gap-3 p-2 pr-3 flex items-center rounded-xq-md border border-xq-border bg-xq-background"
|
|
152
|
-
>
|
|
153
|
-
<img
|
|
154
|
-
v-if="isImage"
|
|
155
|
-
:src="file.url!"
|
|
156
|
-
alt=""
|
|
157
|
-
class="size-10 shrink-0 rounded-xq-sm object-cover"
|
|
158
|
-
/>
|
|
371
|
+
<div ref="root" class="gap-2 grid" @paste="onPaste">
|
|
159
372
|
<div
|
|
160
|
-
v-
|
|
161
|
-
class="size-
|
|
373
|
+
v-if="isAvatar && files[0]"
|
|
374
|
+
class="size-24 relative"
|
|
375
|
+
:data-xq-file="files[0].value"
|
|
162
376
|
>
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
class="
|
|
377
|
+
<img
|
|
378
|
+
v-if="files[0].isImage"
|
|
379
|
+
:src="files[0].url!"
|
|
380
|
+
:alt="files[0].name"
|
|
381
|
+
class="size-24 rounded-full object-cover"
|
|
382
|
+
/>
|
|
383
|
+
<div
|
|
384
|
+
v-else
|
|
385
|
+
class="size-24 flex items-center justify-center rounded-full bg-xq-muted"
|
|
386
|
+
>
|
|
387
|
+
<FileText class="size-6 text-xq-muted-foreground" />
|
|
388
|
+
</div>
|
|
389
|
+
<XqButton
|
|
390
|
+
v-if="canEdit(files[0])"
|
|
391
|
+
type="button"
|
|
392
|
+
variant="outline"
|
|
393
|
+
size="icon-sm"
|
|
394
|
+
class="right-0 bottom-0 absolute rounded-full"
|
|
395
|
+
:aria-label="t('Edit :name', { name: files[0].name })"
|
|
396
|
+
data-xq-edit-image
|
|
397
|
+
@click="editing = files[0]"
|
|
398
|
+
>
|
|
399
|
+
<Pencil />
|
|
400
|
+
</XqButton>
|
|
401
|
+
<XqButton
|
|
402
|
+
v-if="canRemove(files[0])"
|
|
403
|
+
type="button"
|
|
404
|
+
variant="outline"
|
|
405
|
+
size="icon-sm"
|
|
406
|
+
class="top-0 right-0 absolute rounded-full"
|
|
407
|
+
:aria-label="t('Remove :name', { name: files[0].name })"
|
|
408
|
+
data-xq-remove
|
|
409
|
+
@click="remove(files[0].value)"
|
|
172
410
|
>
|
|
173
|
-
|
|
174
|
-
</
|
|
175
|
-
<p v-else class="text-sm font-medium truncate">{{ file.name }}</p>
|
|
176
|
-
<p class="text-xs text-xq-muted-foreground">
|
|
177
|
-
{{ file.isNew ? 'Uploaded, saved when you submit' : 'Stored'
|
|
178
|
-
}}<template v-if="file.size"> · {{ formatBytes(file.size) }}</template>
|
|
179
|
-
</p>
|
|
411
|
+
<X />
|
|
412
|
+
</XqButton>
|
|
180
413
|
</div>
|
|
181
|
-
<
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
@click="remove"
|
|
188
|
-
>
|
|
189
|
-
<X />
|
|
190
|
-
</XqButton>
|
|
191
|
-
</div>
|
|
192
|
-
<label
|
|
193
|
-
v-else
|
|
194
|
-
:for="id"
|
|
195
|
-
:class="
|
|
196
|
-
cn(
|
|
197
|
-
'gap-1.5 px-4 py-5 flex cursor-pointer flex-col items-center justify-center rounded-xq-md border border-dashed border-xq-input bg-xq-background text-center transition-[color,background-color,border-color,box-shadow] outline-none hover:bg-xq-muted/50 has-[input:focus-visible]:border-xq-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-xq-ring/25',
|
|
198
|
-
dragging && 'border-xq-primary bg-xq-primary/5',
|
|
199
|
-
(error || uploadError) && 'border-xq-destructive',
|
|
200
|
-
disabled && 'pointer-events-none opacity-60',
|
|
201
|
-
)
|
|
202
|
-
"
|
|
203
|
-
@dragover.prevent="dragging = true"
|
|
204
|
-
@dragleave.prevent="dragging = false"
|
|
205
|
-
@drop.prevent="onDrop"
|
|
206
|
-
>
|
|
207
|
-
<template v-if="progress">
|
|
208
|
-
<LoaderCircle class="size-5 motion-safe:animate-spin text-xq-primary" />
|
|
209
|
-
<span class="text-sm font-medium max-w-full truncate">{{ progress.name }}</span>
|
|
210
|
-
<span
|
|
211
|
-
role="progressbar"
|
|
212
|
-
aria-valuemin="0"
|
|
213
|
-
aria-valuemax="100"
|
|
214
|
-
:aria-valuenow="progress.percent"
|
|
215
|
-
:aria-label="`Uploading ${progress.name}`"
|
|
216
|
-
class="h-1.5 w-40 overflow-hidden rounded-full bg-xq-muted"
|
|
414
|
+
<div v-else-if="files.length" ref="fileList" class="gap-2 grid">
|
|
415
|
+
<div
|
|
416
|
+
v-for="(file, index) in files"
|
|
417
|
+
:key="file.value"
|
|
418
|
+
:data-xq-file="file.value"
|
|
419
|
+
class="gap-3 p-2 pr-3 flex items-center rounded-xq-md border border-xq-border bg-xq-background"
|
|
217
420
|
>
|
|
218
421
|
<span
|
|
219
|
-
|
|
220
|
-
|
|
422
|
+
v-if="canReorder && files.length > 1"
|
|
423
|
+
aria-hidden="true"
|
|
424
|
+
:data-xq-drag="path"
|
|
425
|
+
class="cursor-grab touch-none text-xq-muted-foreground"
|
|
426
|
+
>
|
|
427
|
+
<GripVertical class="size-4" />
|
|
428
|
+
</span>
|
|
429
|
+
<img
|
|
430
|
+
v-if="file.isImage"
|
|
431
|
+
:src="file.url!"
|
|
432
|
+
alt=""
|
|
433
|
+
class="size-10 shrink-0 rounded-xq-sm object-cover"
|
|
221
434
|
/>
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
435
|
+
<div
|
|
436
|
+
v-else
|
|
437
|
+
class="size-10 flex shrink-0 items-center justify-center rounded-xq-sm bg-xq-muted"
|
|
438
|
+
>
|
|
439
|
+
<FileText class="size-5 text-xq-muted-foreground" />
|
|
440
|
+
</div>
|
|
441
|
+
<div class="min-w-0 flex-1">
|
|
442
|
+
<p class="text-sm font-medium truncate">{{ file.name }}</p>
|
|
443
|
+
<p class="text-xs text-xq-muted-foreground">
|
|
444
|
+
{{ file.isNew ? t('Uploaded, saved when you submit') : t('Stored')
|
|
445
|
+
}}<template v-if="file.size"> · {{ formatBytes(file.size) }}</template>
|
|
446
|
+
</p>
|
|
447
|
+
</div>
|
|
448
|
+
<template v-if="canReorder && files.length > 1">
|
|
449
|
+
<XqButton
|
|
450
|
+
type="button"
|
|
451
|
+
variant="ghost"
|
|
452
|
+
size="icon-sm"
|
|
453
|
+
:disabled="index === 0"
|
|
454
|
+
:aria-label="t('Move :name up', { name: file.name })"
|
|
455
|
+
data-xq-move="-1"
|
|
456
|
+
@click="moveWithButton(file.value, -1)"
|
|
457
|
+
>
|
|
458
|
+
<ChevronUp />
|
|
459
|
+
</XqButton>
|
|
460
|
+
<XqButton
|
|
461
|
+
type="button"
|
|
462
|
+
variant="ghost"
|
|
463
|
+
size="icon-sm"
|
|
464
|
+
:disabled="index === files.length - 1"
|
|
465
|
+
:aria-label="t('Move :name down', { name: file.name })"
|
|
466
|
+
data-xq-move="1"
|
|
467
|
+
@click="moveWithButton(file.value, 1)"
|
|
468
|
+
>
|
|
469
|
+
<ChevronDown />
|
|
470
|
+
</XqButton>
|
|
471
|
+
</template>
|
|
472
|
+
<XqButton
|
|
473
|
+
v-if="canEdit(file)"
|
|
474
|
+
type="button"
|
|
475
|
+
variant="ghost"
|
|
476
|
+
size="icon-sm"
|
|
477
|
+
:aria-label="t('Edit :name', { name: file.name })"
|
|
478
|
+
data-xq-edit-image
|
|
479
|
+
@click="editing = file"
|
|
480
|
+
>
|
|
481
|
+
<Pencil />
|
|
482
|
+
</XqButton>
|
|
483
|
+
<XqButton
|
|
484
|
+
v-if="options.openable && file.url && !file.isNew"
|
|
485
|
+
as="a"
|
|
486
|
+
:href="file.url"
|
|
487
|
+
target="_blank"
|
|
488
|
+
rel="noopener"
|
|
489
|
+
variant="ghost"
|
|
490
|
+
size="icon-sm"
|
|
491
|
+
:aria-label="t('Open :name', { name: file.name })"
|
|
492
|
+
>
|
|
493
|
+
<ExternalLink />
|
|
494
|
+
</XqButton>
|
|
495
|
+
<XqButton
|
|
496
|
+
v-if="options.downloadable && file.url && !file.isNew"
|
|
497
|
+
as="a"
|
|
498
|
+
:href="file.url"
|
|
499
|
+
:download="file.name"
|
|
500
|
+
variant="ghost"
|
|
501
|
+
size="icon-sm"
|
|
502
|
+
:aria-label="t('Download :name', { name: file.name })"
|
|
503
|
+
>
|
|
504
|
+
<Download />
|
|
505
|
+
</XqButton>
|
|
506
|
+
<XqButton
|
|
507
|
+
v-if="canRemove(file)"
|
|
508
|
+
type="button"
|
|
509
|
+
variant="ghost"
|
|
510
|
+
size="icon-sm"
|
|
511
|
+
:aria-label="
|
|
512
|
+
isMultiple ? t('Remove :name', { name: file.name }) : t('Remove file')
|
|
513
|
+
"
|
|
514
|
+
data-xq-remove
|
|
515
|
+
@click="remove(file.value)"
|
|
516
|
+
>
|
|
517
|
+
<X />
|
|
518
|
+
</XqButton>
|
|
519
|
+
</div>
|
|
520
|
+
</div>
|
|
521
|
+
<div
|
|
522
|
+
v-for="item in pending"
|
|
523
|
+
:key="item.id"
|
|
524
|
+
class="gap-3 p-2 pr-3 flex items-center rounded-xq-md border border-dashed border-xq-border bg-xq-background"
|
|
525
|
+
>
|
|
526
|
+
<LoaderCircle class="size-5 motion-safe:animate-spin shrink-0 text-xq-primary" />
|
|
527
|
+
<div class="min-w-0 gap-1 grid flex-1">
|
|
528
|
+
<span class="text-sm font-medium truncate">{{ item.name }}</span>
|
|
529
|
+
<span
|
|
530
|
+
role="progressbar"
|
|
531
|
+
aria-valuemin="0"
|
|
532
|
+
aria-valuemax="100"
|
|
533
|
+
:aria-valuenow="item.percent"
|
|
534
|
+
:aria-label="t('Uploading :name', { name: item.name })"
|
|
535
|
+
class="h-1.5 w-full overflow-hidden rounded-full bg-xq-muted"
|
|
536
|
+
>
|
|
537
|
+
<span
|
|
538
|
+
class="block h-full bg-xq-primary transition-[width]"
|
|
539
|
+
:style="{ width: `${item.percent}%` }"
|
|
540
|
+
/>
|
|
541
|
+
</span>
|
|
542
|
+
</div>
|
|
543
|
+
<XqButton
|
|
544
|
+
type="button"
|
|
545
|
+
variant="ghost"
|
|
546
|
+
size="icon-sm"
|
|
547
|
+
:aria-label="t('Cancel :name', { name: item.name })"
|
|
548
|
+
@click="cancel(item)"
|
|
232
549
|
>
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
:
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
550
|
+
<X />
|
|
551
|
+
</XqButton>
|
|
552
|
+
</div>
|
|
553
|
+
<label
|
|
554
|
+
v-if="hasRoom"
|
|
555
|
+
:for="id"
|
|
556
|
+
:class="
|
|
557
|
+
cn(
|
|
558
|
+
'gap-1.5 px-4 py-5 flex cursor-pointer flex-col items-center justify-center border border-dashed border-xq-input bg-xq-background text-center transition-[color,background-color,border-color,box-shadow] outline-none hover:bg-xq-muted/50 has-[input:focus-visible]:border-xq-ring has-[input:focus-visible]:ring-[3px] has-[input:focus-visible]:ring-xq-ring/25',
|
|
559
|
+
isAvatar ? 'size-24 rounded-full' : 'rounded-xq-md',
|
|
560
|
+
dragging && 'border-xq-primary bg-xq-primary/5',
|
|
561
|
+
(error || uploadError) && 'border-xq-destructive',
|
|
562
|
+
disabled && 'pointer-events-none opacity-60',
|
|
563
|
+
)
|
|
564
|
+
"
|
|
565
|
+
@dragover.prevent="dragging = true"
|
|
566
|
+
@dragleave.prevent="dragging = false"
|
|
567
|
+
@drop.prevent="onDrop"
|
|
568
|
+
>
|
|
569
|
+
<UploadCloud class="size-5 text-xq-muted-foreground" />
|
|
570
|
+
<template v-if="!isAvatar">
|
|
571
|
+
<span class="text-sm"
|
|
572
|
+
><span class="font-medium text-xq-primary">{{
|
|
573
|
+
isMultiple ? t('Choose files') : t('Choose a file')
|
|
574
|
+
}}</span>
|
|
575
|
+
{{ isMultiple ? t('or drag them here') : t('or drag it here') }}</span
|
|
576
|
+
>
|
|
577
|
+
<span class="text-xs text-xq-muted-foreground">{{
|
|
578
|
+
t('Up to :size', { size: formatBytes((options.maxSize ?? 10240) * 1024) })
|
|
579
|
+
}}</span>
|
|
580
|
+
</template>
|
|
581
|
+
<span v-else class="sr-only">{{ t('Choose an image') }}</span>
|
|
582
|
+
<input
|
|
583
|
+
:id="id"
|
|
584
|
+
ref="input"
|
|
585
|
+
type="file"
|
|
586
|
+
class="sr-only"
|
|
587
|
+
:multiple="isMultiple"
|
|
588
|
+
:accept="options.accept ?? undefined"
|
|
589
|
+
:disabled="disabled"
|
|
590
|
+
:aria-invalid="error || uploadError ? true : undefined"
|
|
591
|
+
:aria-describedby="inputDescribedBy"
|
|
592
|
+
@change="onChange"
|
|
593
|
+
/>
|
|
594
|
+
</label>
|
|
595
|
+
</div>
|
|
596
|
+
<XqImageEditor
|
|
597
|
+
v-if="editing"
|
|
598
|
+
:src="editing.url!"
|
|
599
|
+
:name="editing.name"
|
|
600
|
+
:aspect-ratios="options.imageEditor?.aspectRatios"
|
|
601
|
+
:fill-color="options.imageEditor?.fillColor"
|
|
602
|
+
@save="saveEdit"
|
|
603
|
+
@cancel="editing = null"
|
|
604
|
+
/>
|
|
246
605
|
</XqFieldWrapper>
|
|
247
606
|
</template>
|