@cloudron/pankow 3.1.8
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/.gitlab-ci.yml +30 -0
- package/.jshintrc +8 -0
- package/LICENSE +21 -0
- package/README.md +20 -0
- package/components/BottomBar.vue +22 -0
- package/components/Breadcrumb.vue +64 -0
- package/components/Button.vue +243 -0
- package/components/ButtonGroup.vue +37 -0
- package/components/Checkbox.vue +112 -0
- package/components/Dialog.vue +178 -0
- package/components/DirectoryView.vue +772 -0
- package/components/DirectoryViewListItem.vue +412 -0
- package/components/EmailInput.vue +22 -0
- package/components/FileUploader.vue +204 -0
- package/components/FormGroup.vue +26 -0
- package/components/Icon.vue +12 -0
- package/components/InputDialog.vue +170 -0
- package/components/InputGroup.vue +32 -0
- package/components/MainLayout.vue +63 -0
- package/components/Menu.vue +284 -0
- package/components/MenuItem.vue +106 -0
- package/components/MenuItemLink.vue +52 -0
- package/components/MultiSelect.vue +202 -0
- package/components/Notification.vue +163 -0
- package/components/NumberInput.vue +31 -0
- package/components/OfflineBanner.vue +47 -0
- package/components/PasswordInput.vue +86 -0
- package/components/Popover.vue +185 -0
- package/components/ProgressBar.vue +75 -0
- package/components/Radiobutton.vue +128 -0
- package/components/SideBar.vue +104 -0
- package/components/SingleSelect.vue +190 -0
- package/components/Spinner.vue +67 -0
- package/components/Switch.vue +94 -0
- package/components/TabView.vue +161 -0
- package/components/TableView.vue +187 -0
- package/components/TagInput.vue +104 -0
- package/components/TextInput.vue +58 -0
- package/components/TopBar.vue +88 -0
- package/fallbackImage.js +29 -0
- package/fetcher.js +81 -0
- package/gallery/CustomMenuItem.vue +40 -0
- package/gallery/DirectoryViewDemo.vue +73 -0
- package/gallery/Index.vue +790 -0
- package/gallery/folder.svg +151 -0
- package/gallery/index.html +25 -0
- package/gallery/index.js +10 -0
- package/gallery/logo.png +0 -0
- package/gallery/vite.config.mjs +9 -0
- package/gestures.js +60 -0
- package/index.js +86 -0
- package/logo.png +0 -0
- package/logo.svg +78 -0
- package/package.json +26 -0
- package/style.css +351 -0
- package/tooltip.js +83 -0
- package/utils.js +383 -0
- package/viewers/GenericViewer.vue +84 -0
- package/viewers/ImageViewer.vue +239 -0
- package/viewers/PdfViewer.vue +82 -0
- package/viewers/TextViewer.vue +221 -0
- package/viewers.js +11 -0
package/utils.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
|
|
2
|
+
import { filesize } from 'filesize';
|
|
3
|
+
import { customRef } from 'vue';
|
|
4
|
+
import moment from 'moment';
|
|
5
|
+
|
|
6
|
+
// https://vuejs.org/api/reactivity-advanced.html#customref
|
|
7
|
+
function useDebouncedRef(value, delay = 300) {
|
|
8
|
+
let timeout
|
|
9
|
+
return customRef((track, trigger) => {
|
|
10
|
+
return {
|
|
11
|
+
get() {
|
|
12
|
+
track()
|
|
13
|
+
return value
|
|
14
|
+
},
|
|
15
|
+
set(newValue) {
|
|
16
|
+
clearTimeout(timeout)
|
|
17
|
+
timeout = setTimeout(() => {
|
|
18
|
+
value = newValue
|
|
19
|
+
trigger()
|
|
20
|
+
}, delay)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getFileTypeGroup(item) {
|
|
27
|
+
if (typeof item.mimeType !== 'string') throw 'item must have mimeType string property';
|
|
28
|
+
return item.mimeType.split('/')[0];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isValidDomainOrURL(domain) {
|
|
32
|
+
try {
|
|
33
|
+
domain = new URL(input).hostname;
|
|
34
|
+
} catch (e) {}
|
|
35
|
+
|
|
36
|
+
return isValidDomain(domain);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isValidDomain(domain) {
|
|
40
|
+
const domainRegex = /^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/;
|
|
41
|
+
return domainRegex.test(domain);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// this currently does not match: "john.doe"@example.com, user@[192.168.1.1], john.doe(comment)@example.com or 用户@例子.世界
|
|
45
|
+
function isValidEmail(email) {
|
|
46
|
+
if (!email || typeof email !== 'string') return false;
|
|
47
|
+
|
|
48
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
49
|
+
return emailRegex.test(email);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
// https://en.wikipedia.org/wiki/Binary_prefix
|
|
54
|
+
// binary units (IEC) 1024 based
|
|
55
|
+
function prettyBinarySize(size, fallback) {
|
|
56
|
+
if (!size) return fallback || 0;
|
|
57
|
+
if (size === -1) return 'Unlimited';
|
|
58
|
+
|
|
59
|
+
// we can also use KB here (JEDEC)
|
|
60
|
+
var i = Math.floor(Math.log(size) / Math.log(1024));
|
|
61
|
+
return (size / Math.pow(1024, i)).toFixed(3) * 1 + ' ' + ['B', 'KiB', 'MiB', 'GiB', 'TiB'][i];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// decimal units (SI) 1000 based
|
|
65
|
+
function prettyDecimalSize(size, fallback) {
|
|
66
|
+
if (!size) return fallback || 0;
|
|
67
|
+
|
|
68
|
+
var i = Math.floor(Math.log(size) / Math.log(1000));
|
|
69
|
+
return (size / Math.pow(1000, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// this will print a human friendly datetime offset from now
|
|
73
|
+
function prettyDate(value) {
|
|
74
|
+
if (!value) return 'never';
|
|
75
|
+
return moment(value).fromNow();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function prettyLongDate(value) {
|
|
79
|
+
if (!value) return 'unknown';
|
|
80
|
+
|
|
81
|
+
const date = new Date(value);
|
|
82
|
+
return date.toLocaleString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function prettyFileSize(value) {
|
|
86
|
+
if (typeof value !== 'number') return 'unknown';
|
|
87
|
+
|
|
88
|
+
return filesize(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function prettyEmailAddresses(addresses) {
|
|
92
|
+
if (!addresses) return '';
|
|
93
|
+
if (addresses === '<>') return '<>';
|
|
94
|
+
if (Array.isArray(addresses)) return addresses.map(function (a) { return a.slice(1, -1); }).join(', ');
|
|
95
|
+
return addresses.slice(1, -1);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function sanitize(path) {
|
|
99
|
+
path = '/' + path;
|
|
100
|
+
return path.replace(/\/+/g, '/');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function pathJoin() {
|
|
104
|
+
return sanitize(Array.from(arguments).join('/'));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function download(entries, name) {
|
|
108
|
+
if (!entries.length) return;
|
|
109
|
+
|
|
110
|
+
if (entries.length === 1) {
|
|
111
|
+
if (entries[0].share) window.location.href = '/api/v1/shares/' + entries[0].share.id + '?type=download&path=' + encodeURIComponent(entries[0].filePath);
|
|
112
|
+
else window.location.href = '/api/v1/files?type=download&path=' + encodeURIComponent(entries[0].filePath);
|
|
113
|
+
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const params = new URLSearchParams();
|
|
118
|
+
|
|
119
|
+
// be a bit smart about the archive name and folder tree
|
|
120
|
+
const folderPath = entries[0].filePath.slice(0, -entries[0].fileName.length);
|
|
121
|
+
const archiveName = name || folderPath.slice(folderPath.slice(0, -1).lastIndexOf('/')+1).slice(0, -1);
|
|
122
|
+
params.append('name', archiveName);
|
|
123
|
+
params.append('skipPath', folderPath);
|
|
124
|
+
|
|
125
|
+
params.append('entries', JSON.stringify(entries.map(function (entry) {
|
|
126
|
+
return {
|
|
127
|
+
filePath: entry.filePath,
|
|
128
|
+
shareId: entry.share ? entry.share.id : undefined
|
|
129
|
+
};
|
|
130
|
+
})));
|
|
131
|
+
|
|
132
|
+
window.location.href = '/api/v1/download?' + params.toString();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// simple extension detection, does not work with double extension like .tar.gz
|
|
136
|
+
function getExtension(entry) {
|
|
137
|
+
if (entry.isFile) return entry.fileName.slice(entry.fileName.lastIndexOf('.') + 1);
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function copyToClipboard(value) {
|
|
142
|
+
var elem = document.createElement('input');
|
|
143
|
+
elem.value = value;
|
|
144
|
+
document.body.append(elem);
|
|
145
|
+
elem.select();
|
|
146
|
+
document.execCommand('copy');
|
|
147
|
+
elem.remove();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function urlSearchQuery() {
|
|
151
|
+
return decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// those paths contain the internal type and path reference eg. shares/:shareId/folder/filename or files/folder/filename
|
|
155
|
+
function parseResourcePath(resourcePath) {
|
|
156
|
+
var result = {
|
|
157
|
+
type: '',
|
|
158
|
+
path: '',
|
|
159
|
+
shareId: '',
|
|
160
|
+
apiPath: '',
|
|
161
|
+
resourcePath: ''
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (resourcePath.indexOf('files/') === 0) {
|
|
165
|
+
result.type = 'files';
|
|
166
|
+
result.path = resourcePath.slice('files'.length) || '/';
|
|
167
|
+
result.apiPath = '/api/v1/files';
|
|
168
|
+
result.resourcePath = result.type + result.path;
|
|
169
|
+
} else if (resourcePath.indexOf('shares/') === 0) {
|
|
170
|
+
result.type = 'shares';
|
|
171
|
+
result.shareId = resourcePath.split('/')[1];
|
|
172
|
+
result.path = resourcePath.slice((result.type + '/' + result.shareId).length) || '/';
|
|
173
|
+
result.apiPath = '/api/v1/shares/' + result.shareId;
|
|
174
|
+
// without shareId we show the root (share listing)
|
|
175
|
+
result.resourcePath = result.type + '/' + (result.shareId ? (result.shareId + result.path) : '');
|
|
176
|
+
} else {
|
|
177
|
+
console.error('Unknown resource path', resourcePath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function getEntryIdentifier(entry) {
|
|
184
|
+
return (entry.share ? (entry.share.id + '/') : '') + entry.filePath;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function entryListSort(list, prop, desc) {
|
|
188
|
+
var tmp = list.sort(function (a, b) {
|
|
189
|
+
var av = a[prop];
|
|
190
|
+
var bv = b[prop];
|
|
191
|
+
|
|
192
|
+
if (typeof av === 'string') return (av.toUpperCase() < bv.toUpperCase()) ? -1 : 1;
|
|
193
|
+
else return (av < bv) ? -1 : 1;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (desc) return tmp;
|
|
197
|
+
return tmp.reverse();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function sleep(ms) {
|
|
201
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// this is from the Cloudron dashboard translation project en.json
|
|
205
|
+
const fallbackTranslations = {
|
|
206
|
+
"main": {
|
|
207
|
+
"dialog": {
|
|
208
|
+
"close": "Close"
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
"filemanager": {
|
|
212
|
+
"title": "File Manager",
|
|
213
|
+
"removeDialog": {
|
|
214
|
+
"reallyDelete": "Really delete the following?"
|
|
215
|
+
},
|
|
216
|
+
"newDirectoryDialog": {
|
|
217
|
+
"title": "New Folder",
|
|
218
|
+
"create": "Create"
|
|
219
|
+
},
|
|
220
|
+
"newFileDialog": {
|
|
221
|
+
"title": "New File",
|
|
222
|
+
"create": "Create"
|
|
223
|
+
},
|
|
224
|
+
"renameDialog": {
|
|
225
|
+
"title": "Rename {{ fileName }}",
|
|
226
|
+
"newName": "New Name",
|
|
227
|
+
"rename": "Rename"
|
|
228
|
+
},
|
|
229
|
+
"chownDialog": {
|
|
230
|
+
"title": "Change ownership",
|
|
231
|
+
"newOwner": "New Owner",
|
|
232
|
+
"change": "Change Owner",
|
|
233
|
+
"recursiveCheckbox": "Change ownership recursively"
|
|
234
|
+
},
|
|
235
|
+
"uploadingDialog": {
|
|
236
|
+
"title": "Uploading files ({{ countDone }}/{{ count }})",
|
|
237
|
+
"errorAlreadyExists": "One or more files already exist.",
|
|
238
|
+
"errorFailed": "Failed to upload one or more files. Please try again.",
|
|
239
|
+
"closeWarning": "Do not refresh the page until upload has finished.",
|
|
240
|
+
"retry": "Retry",
|
|
241
|
+
"overwrite": "Overwrite"
|
|
242
|
+
},
|
|
243
|
+
"extractDialog": {
|
|
244
|
+
"title": "Extracting {{ fileName }}",
|
|
245
|
+
"closeWarning": "Do not refresh the page until extract has finished."
|
|
246
|
+
},
|
|
247
|
+
"textEditorCloseDialog": {
|
|
248
|
+
"title": "File has unsaved changes",
|
|
249
|
+
"details": "Your changes will be lost if you don't save them",
|
|
250
|
+
"dontSave": "Don't Save"
|
|
251
|
+
},
|
|
252
|
+
"notFound": "Not found",
|
|
253
|
+
"toolbar": {
|
|
254
|
+
"new": "New",
|
|
255
|
+
"upload": "Upload",
|
|
256
|
+
"newFile": "New File",
|
|
257
|
+
"newFolder": "New Folder",
|
|
258
|
+
"uploadFolder": "Upload Folder",
|
|
259
|
+
"uploadFile": "Upload File",
|
|
260
|
+
"restartApp": "Restart App",
|
|
261
|
+
"openTerminal": "Open Terminal",
|
|
262
|
+
"openLogs": "Open Logs"
|
|
263
|
+
},
|
|
264
|
+
"list": {
|
|
265
|
+
"name": "Name",
|
|
266
|
+
"size": "Size",
|
|
267
|
+
"owner": "Owner",
|
|
268
|
+
"empty": "No files",
|
|
269
|
+
"symlink": "symlink to {{ target }}",
|
|
270
|
+
"menu": {
|
|
271
|
+
"rename": "Rename",
|
|
272
|
+
"chown": "Change Ownership",
|
|
273
|
+
"extract": "Extract Here",
|
|
274
|
+
"download": "Download",
|
|
275
|
+
"delete": "Delete",
|
|
276
|
+
"edit": "Edit",
|
|
277
|
+
"cut": "Cut",
|
|
278
|
+
"copy": "Copy",
|
|
279
|
+
"paste": "Paste",
|
|
280
|
+
"selectAll": "Select All",
|
|
281
|
+
"share": "Share",
|
|
282
|
+
"open": "Open"
|
|
283
|
+
},
|
|
284
|
+
"mtime": "Modified"
|
|
285
|
+
},
|
|
286
|
+
"extract": {
|
|
287
|
+
"error": "Failed to extract: {{ message }}"
|
|
288
|
+
},
|
|
289
|
+
"newDirectory": {
|
|
290
|
+
"errorAlreadyExists": "Already exists"
|
|
291
|
+
},
|
|
292
|
+
"newFile": {
|
|
293
|
+
"errorAlreadyExists": "Already exists"
|
|
294
|
+
},
|
|
295
|
+
"status": {
|
|
296
|
+
"restartingApp": "restarting app"
|
|
297
|
+
},
|
|
298
|
+
"uploader": {
|
|
299
|
+
"uploading": "Uploading",
|
|
300
|
+
"exitWarning": "Upload still in progress. Really close this page?"
|
|
301
|
+
},
|
|
302
|
+
"textEditor": {
|
|
303
|
+
"undo": "Undo",
|
|
304
|
+
"redo": "Redo",
|
|
305
|
+
"save": "Save"
|
|
306
|
+
},
|
|
307
|
+
"extractionInProgress": "Extraction in progress",
|
|
308
|
+
"pasteInProgress": "Pasting in progress",
|
|
309
|
+
"deleteInProgress": "Deletion in progress"
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
function translation(id) {
|
|
314
|
+
let value;
|
|
315
|
+
try {
|
|
316
|
+
value = id.split('.').reduce((a, b) => a[b], fallbackTranslations);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
console.warn(`Translation ${id} does not exist`);
|
|
319
|
+
value = id;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return value;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function uuidv4() {
|
|
326
|
+
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, c =>
|
|
327
|
+
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// named exports
|
|
332
|
+
export {
|
|
333
|
+
getFileTypeGroup,
|
|
334
|
+
translation,
|
|
335
|
+
useDebouncedRef,
|
|
336
|
+
isValidDomain,
|
|
337
|
+
isValidDomainOrURL,
|
|
338
|
+
isValidEmail,
|
|
339
|
+
prettyBinarySize,
|
|
340
|
+
prettyDecimalSize,
|
|
341
|
+
prettyDate,
|
|
342
|
+
prettyLongDate,
|
|
343
|
+
prettyFileSize,
|
|
344
|
+
prettyEmailAddresses,
|
|
345
|
+
sanitize,
|
|
346
|
+
pathJoin,
|
|
347
|
+
download,
|
|
348
|
+
getExtension,
|
|
349
|
+
copyToClipboard,
|
|
350
|
+
urlSearchQuery,
|
|
351
|
+
parseResourcePath,
|
|
352
|
+
getEntryIdentifier,
|
|
353
|
+
entryListSort,
|
|
354
|
+
sleep,
|
|
355
|
+
uuidv4
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// default export
|
|
359
|
+
export default {
|
|
360
|
+
getFileTypeGroup,
|
|
361
|
+
translation,
|
|
362
|
+
useDebouncedRef,
|
|
363
|
+
isValidDomain,
|
|
364
|
+
isValidDomainOrURL,
|
|
365
|
+
isValidEmail,
|
|
366
|
+
prettyBinarySize,
|
|
367
|
+
prettyDecimalSize,
|
|
368
|
+
prettyDate,
|
|
369
|
+
prettyLongDate,
|
|
370
|
+
prettyFileSize,
|
|
371
|
+
prettyEmailAddresses,
|
|
372
|
+
sanitize,
|
|
373
|
+
pathJoin,
|
|
374
|
+
download,
|
|
375
|
+
getExtension,
|
|
376
|
+
copyToClipboard,
|
|
377
|
+
urlSearchQuery,
|
|
378
|
+
parseResourcePath,
|
|
379
|
+
getEntryIdentifier,
|
|
380
|
+
entryListSort,
|
|
381
|
+
sleep,
|
|
382
|
+
uuidv4
|
|
383
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<MainLayout :gap="false">
|
|
3
|
+
<template #header>
|
|
4
|
+
<TopBar class="navbar" :gap="false">
|
|
5
|
+
<template #left>
|
|
6
|
+
<div>{{ entry.fileName }}</div>
|
|
7
|
+
</template>
|
|
8
|
+
<template #right>
|
|
9
|
+
<Button icon="fa-solid fa-xmark" @click="onClose">{{ tr('main.dialog.close') }}</Button>
|
|
10
|
+
</template>
|
|
11
|
+
</TopBar>
|
|
12
|
+
</template>
|
|
13
|
+
<template #body>
|
|
14
|
+
<div class="content">
|
|
15
|
+
<div>
|
|
16
|
+
<img class="preview-icon" :src="entry.previewUrl" />
|
|
17
|
+
<h1>{{ entry.fileName }}</h1>
|
|
18
|
+
<Button icon="fa-solid fa-download" :href="entry.downloadFileUrl">Download</Button>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
22
|
+
</MainLayout>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<script>
|
|
26
|
+
|
|
27
|
+
import Button from '../components/Button.vue';
|
|
28
|
+
import MainLayout from '../components/MainLayout.vue';
|
|
29
|
+
import TopBar from '../components/TopBar.vue';
|
|
30
|
+
import utils from '../utils.js';
|
|
31
|
+
|
|
32
|
+
export default {
|
|
33
|
+
name: 'GenericViewer',
|
|
34
|
+
components: {
|
|
35
|
+
Button,
|
|
36
|
+
TopBar,
|
|
37
|
+
MainLayout
|
|
38
|
+
},
|
|
39
|
+
props: {
|
|
40
|
+
tr: {
|
|
41
|
+
type: Function,
|
|
42
|
+
default(id) { console.warn('Missing tr for GenericViewer'); return utils.translation(id); }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
emits: [ 'close' ],
|
|
46
|
+
data() {
|
|
47
|
+
return {
|
|
48
|
+
entry: {}
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
methods: {
|
|
52
|
+
async open(entry) {
|
|
53
|
+
if (!entry) return;
|
|
54
|
+
|
|
55
|
+
this.entry = entry;
|
|
56
|
+
},
|
|
57
|
+
onClose() {
|
|
58
|
+
this.$emit('close');
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
mounted() {
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<style scoped>
|
|
68
|
+
|
|
69
|
+
.preview-icon {
|
|
70
|
+
width: 128px;
|
|
71
|
+
height: 128px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.content {
|
|
75
|
+
text-align: center;
|
|
76
|
+
background-color: var(--pankow-color-background);
|
|
77
|
+
width: 100%;
|
|
78
|
+
min-height: 100%;
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
justify-content: center;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
</style>
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
|
|
2
|
+
<template>
|
|
3
|
+
<div @mousemove="onMouseMove" class="full-page" @keyup.esc.stop="onClose" @keyup.left.stop="onPrev" @keyup.right.stop="onNext" tabindex="1" :style="{ opacity: dismissDistance ? (200 - dismissDistance)/100 : 1 }">
|
|
4
|
+
<div class="image-layer" ref="imageContainer" @scroll="onScroll($event)">
|
|
5
|
+
<div class="image" v-for="(entry, index) in entries" :key="entry.id" v-bind:id="`image${index}`" @touchstart="onTouchStart" @touchmove="onTouchMove" @touchend="onTouchEnd" :style="{ marginTop: dismissDistance ? (dismissDistance + 'px') : 0 }">
|
|
6
|
+
<img :src="entry.fullFileUrl" v-if="getFileTypeGroup(entry) === 'image'" draggable="false"/>
|
|
7
|
+
<video v-if="getFileTypeGroup(entry) !== 'image'" ref="videoElements" controls>
|
|
8
|
+
<source :src="entry.fullFileUrl" />
|
|
9
|
+
</video>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
12
|
+
<Button icon="fa-solid fa-download" v-show="showOverlay && hasDownloadHandler" secondary large plain tool @click="onDownload" class="download-button"/>
|
|
13
|
+
<Button icon="fa-solid fa-xmark" v-show="showOverlay" secondary large plain tool @click="onClose" class="close-button"/>
|
|
14
|
+
<Button icon="fa-solid fa-chevron-left" secondary large plain tool @click="onPrev" v-show="showOverlay && curIndex > 0" class="prev-button"/>
|
|
15
|
+
<Button icon="fa-solid fa-chevron-right" secondary large plain tool @click="onNext" v-show="showOverlay && curIndex < (entries.length-1)" class="next-button"/>
|
|
16
|
+
<video style="display: none" ref="typeTestVideo"></video>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
19
|
+
|
|
20
|
+
<script>
|
|
21
|
+
|
|
22
|
+
import { getFileTypeGroup } from '../utils.js';
|
|
23
|
+
import Button from '../components/Button.vue';
|
|
24
|
+
|
|
25
|
+
function defaultDownloadHandler() {
|
|
26
|
+
console.warn('Missing downloadHandler for ImageViewer');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let touchDist = 0;
|
|
30
|
+
let startY = 0;
|
|
31
|
+
const dismissDistanceThreshold = 150;
|
|
32
|
+
const dismissTriggerThreshold = 50;
|
|
33
|
+
|
|
34
|
+
export default {
|
|
35
|
+
name: 'ImageViewer',
|
|
36
|
+
emits: [ 'close', 'saved' ],
|
|
37
|
+
props: {
|
|
38
|
+
downloadHandler: {
|
|
39
|
+
type: Function,
|
|
40
|
+
default: defaultDownloadHandler
|
|
41
|
+
},
|
|
42
|
+
navigationHandler: {
|
|
43
|
+
type: Function,
|
|
44
|
+
default: (toEntry) => {
|
|
45
|
+
this.$router.replace(toEntry.resourcePath);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
components: {
|
|
50
|
+
Button
|
|
51
|
+
},
|
|
52
|
+
data() {
|
|
53
|
+
return {
|
|
54
|
+
showOverlay: false,
|
|
55
|
+
showOverlayTimer: null,
|
|
56
|
+
lockScroll: false,
|
|
57
|
+
curIndex: -1,
|
|
58
|
+
lastMousePosition: {},
|
|
59
|
+
entry: {},
|
|
60
|
+
entries: [],
|
|
61
|
+
dismissDistance: 0
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
computed: {
|
|
65
|
+
hasDownloadHandler() { return this.downloadHandler !== defaultDownloadHandler }
|
|
66
|
+
},
|
|
67
|
+
methods: {
|
|
68
|
+
getFileTypeGroup,
|
|
69
|
+
keepOverlayAlive() {
|
|
70
|
+
this.showOverlay = true;
|
|
71
|
+
clearTimeout(this.showOverlayTimer);
|
|
72
|
+
this.showOverlayTimer = setTimeout(() => {
|
|
73
|
+
// avoid hiding if cursor is above a button
|
|
74
|
+
// this crashes sometimes, not yet sure how to better prevent for now "Document.elementFromPoint: Argument 1 is not a finite floating-point value."
|
|
75
|
+
try {
|
|
76
|
+
if (document.elementFromPoint(this.lastMousePosition.x || 0, this.lastMousePosition.y || 0) !== this.$refs.imageContainer) return;
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('ImageViewer crash. lastMousePosition', this.lastMousePosition, e);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
this.showOverlay = false;
|
|
82
|
+
}, 2000);
|
|
83
|
+
},
|
|
84
|
+
onMouseMove() {
|
|
85
|
+
this.lastMousePosition = { x: event.pageX, y: event.pageY };
|
|
86
|
+
this.keepOverlayAlive();
|
|
87
|
+
},
|
|
88
|
+
canHandle(entry) {
|
|
89
|
+
return (getFileTypeGroup(entry) === 'image' || this.$refs.typeTestVideo.canPlayType(entry.mimeType))
|
|
90
|
+
&& !(entry.mimeType === 'image/heif') // supported on apple it seems
|
|
91
|
+
&& !(entry.mimeType === 'image/vnd.adobe.photoshop');
|
|
92
|
+
},
|
|
93
|
+
onScroll() {
|
|
94
|
+
if (this.lockScroll) return;
|
|
95
|
+
|
|
96
|
+
for (let i = 1; i < this.entries.length; i++) {
|
|
97
|
+
const elem = document.getElementById('image'+i);
|
|
98
|
+
const rect = elem.getBoundingClientRect();
|
|
99
|
+
const midX = (rect.x+(rect.width/2));
|
|
100
|
+
if (midX >= (rect.width/4) && midX <= 3*(rect.width/4)) {
|
|
101
|
+
this.entry = this.entries[i];
|
|
102
|
+
|
|
103
|
+
if (this.curIndex !== i) {
|
|
104
|
+
this.curIndex = i;
|
|
105
|
+
this.navigationHandler(this.entry);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
onTouchStart(event) {
|
|
113
|
+
this.dismissDistance = 0;
|
|
114
|
+
touchDist = 0;
|
|
115
|
+
startY = event.changedTouches[0].pageY;
|
|
116
|
+
},
|
|
117
|
+
onTouchMove(event) {
|
|
118
|
+
touchDist = event.changedTouches[0].pageY - startY // get total dist traveled by finger while in contact with surface
|
|
119
|
+
if (touchDist > dismissTriggerThreshold) this.dismissDistance = touchDist - dismissTriggerThreshold;
|
|
120
|
+
},
|
|
121
|
+
onTouchEnd() {
|
|
122
|
+
if (touchDist > dismissDistanceThreshold) this.onClose();
|
|
123
|
+
touchDist = 0;
|
|
124
|
+
this.dismissDistance = 0;
|
|
125
|
+
},
|
|
126
|
+
open(entry, entries, instant = true) {
|
|
127
|
+
if (!entry || entry.isDirectory || !this.canHandle(entry)) return;
|
|
128
|
+
|
|
129
|
+
this.lockScroll = true;
|
|
130
|
+
this.keepOverlayAlive();
|
|
131
|
+
this.entry = entry;
|
|
132
|
+
this.entries = entries;
|
|
133
|
+
this.curIndex = entries.findIndex((item) => item.fileName === entry.fileName);
|
|
134
|
+
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
document.getElementById(`image${this.curIndex}`).scrollIntoView({ behavior: instant ? 'instant' : 'smooth' });
|
|
137
|
+
this.$el.focus();
|
|
138
|
+
}, 0);
|
|
139
|
+
|
|
140
|
+
setTimeout(() => {
|
|
141
|
+
this.lockScroll = false;
|
|
142
|
+
}, 500);
|
|
143
|
+
},
|
|
144
|
+
onDownload() {
|
|
145
|
+
if (!this.hasDownloadHandler) return;
|
|
146
|
+
this.downloadHandler(this.entry);
|
|
147
|
+
},
|
|
148
|
+
onClose() {
|
|
149
|
+
this.$emit('close');
|
|
150
|
+
|
|
151
|
+
// pause/stop video and audio playback on close
|
|
152
|
+
if (this.$refs.videoElements && this.$refs.videoElements.length) this.$refs.videoElements.forEach(e => e.pause());
|
|
153
|
+
|
|
154
|
+
// given some time for closing to keep DOM elements alive
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
this.entry = {};
|
|
157
|
+
}, 500);
|
|
158
|
+
},
|
|
159
|
+
onPrev() {
|
|
160
|
+
if (this.curIndex < 1) return;
|
|
161
|
+
this.navigationHandler(this.entries[this.curIndex-1]);
|
|
162
|
+
this.open(this.entries[this.curIndex-1], this.entries, false);
|
|
163
|
+
},
|
|
164
|
+
onNext() {
|
|
165
|
+
if (this.curIndex >= this.entries.length) return;
|
|
166
|
+
this.navigationHandler(this.entries[this.curIndex+1]);
|
|
167
|
+
this.open(this.entries[this.curIndex+1], this.entries, false);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
mounted() {}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
</script>
|
|
174
|
+
|
|
175
|
+
<style scoped>
|
|
176
|
+
|
|
177
|
+
.full-page {
|
|
178
|
+
position: absolute;
|
|
179
|
+
top: 0;
|
|
180
|
+
left: 0;
|
|
181
|
+
width: 100%;
|
|
182
|
+
height: 100%;
|
|
183
|
+
background-color: black;
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.download-button {
|
|
189
|
+
position: absolute;
|
|
190
|
+
bottom: 10px;
|
|
191
|
+
right: 10px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.close-button {
|
|
195
|
+
position: absolute;
|
|
196
|
+
top: 10px;
|
|
197
|
+
right: 10px;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.prev-button {
|
|
201
|
+
position: absolute;
|
|
202
|
+
top: 50%;
|
|
203
|
+
left: 10px;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.next-button {
|
|
207
|
+
position: absolute;
|
|
208
|
+
top: 50%;
|
|
209
|
+
right: 10px;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.image-layer {
|
|
213
|
+
display: flex;
|
|
214
|
+
position: fixed;
|
|
215
|
+
height: 100%;
|
|
216
|
+
width: 100%;
|
|
217
|
+
overflow-y: hidden;
|
|
218
|
+
overflow-x: auto;
|
|
219
|
+
scroll-snap-type: x mandatory;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.image {
|
|
223
|
+
flex: 0 0 auto;
|
|
224
|
+
width: 100%;
|
|
225
|
+
height: 100%;
|
|
226
|
+
scroll-snap-align: center;
|
|
227
|
+
scroll-snap-stop: always;
|
|
228
|
+
display: inline-block;
|
|
229
|
+
user-select: none;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
img, video {
|
|
233
|
+
object-fit: contain;
|
|
234
|
+
width: 100%;
|
|
235
|
+
height: 100%;
|
|
236
|
+
user-select: none;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
</style>
|