@axium/storage 0.16.7 → 0.18.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/db.json +12 -0
- package/dist/client/frontend.d.ts +2 -0
- package/dist/client/frontend.js +6 -0
- package/dist/polyfills.d.ts +11 -4
- package/dist/polyfills.js +36 -2
- package/dist/server/db.d.ts +3 -17
- package/lib/List.svelte +53 -167
- package/lib/Preview.svelte +247 -0
- package/lib/index.ts +6 -5
- package/lib/tsconfig.json +1 -2
- package/package.json +2 -2
- package/routes/f/[id]/+page.ts +9 -0
- package/routes/files/+layout.svelte +2 -2
- package/routes/files/+page.svelte +3 -3
- package/routes/files/[id]/+page.svelte +102 -17
- package/routes/files/shared/+page.svelte +2 -2
- package/routes/files/usage/+page.svelte +2 -2
- package/routes/+layout.ts +0 -1
package/db.json
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { copy } from '@axium/client/clipboard';
|
|
2
|
+
import { encodeUUID } from 'utilium';
|
|
3
|
+
export function copyShortURL(item) {
|
|
4
|
+
const { href } = new URL('/f/' + encodeUUID(item.id).toBase64({ alphabet: 'base64url', omitPadding: true }), location.origin);
|
|
5
|
+
return copy('text/plain', href);
|
|
6
|
+
}
|
package/dist/polyfills.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ https://github.com/microsoft/TypeScript/issues/61695
|
|
|
7
7
|
|
|
8
8
|
@todo Remove when TypeScript 5.9 is released
|
|
9
9
|
*/
|
|
10
|
+
interface FromBase64Options {
|
|
11
|
+
alphabet?: 'base64' | 'base64url';
|
|
12
|
+
lastChunkHandling?: 'loose' | 'strict' | 'stop-before-partial';
|
|
13
|
+
}
|
|
10
14
|
declare global {
|
|
11
15
|
interface Uint8ArrayConstructor {
|
|
12
16
|
/**
|
|
@@ -17,12 +21,12 @@ declare global {
|
|
|
17
21
|
* @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last
|
|
18
22
|
* chunk is inconsistent with the `lastChunkHandling` option.
|
|
19
23
|
*/
|
|
20
|
-
fromBase64: (string: string) => Uint8Array
|
|
24
|
+
fromBase64: (string: string, options?: FromBase64Options) => Uint8Array<ArrayBuffer>;
|
|
21
25
|
/**
|
|
22
26
|
* Creates a new `Uint8Array` from a base16-encoded string.
|
|
23
27
|
* @returns A new `Uint8Array` instance.
|
|
24
28
|
*/
|
|
25
|
-
fromHex: (string: string) => Uint8Array
|
|
29
|
+
fromHex: (string: string) => Uint8Array<ArrayBuffer>;
|
|
26
30
|
}
|
|
27
31
|
interface Uint8Array {
|
|
28
32
|
/**
|
|
@@ -30,7 +34,10 @@ declare global {
|
|
|
30
34
|
* @param options If provided, sets the alphabet and padding behavior used.
|
|
31
35
|
* @returns A base64-encoded string.
|
|
32
36
|
*/
|
|
33
|
-
toBase64: (
|
|
37
|
+
toBase64: (options?: {
|
|
38
|
+
alphabet?: 'base64' | 'base64url';
|
|
39
|
+
omitPadding?: boolean;
|
|
40
|
+
}) => string;
|
|
34
41
|
/**
|
|
35
42
|
* Sets the `Uint8Array` from a base64-encoded string.
|
|
36
43
|
* @param string The base64-encoded string.
|
|
@@ -39,7 +46,7 @@ declare global {
|
|
|
39
46
|
* @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last
|
|
40
47
|
* chunk is inconsistent with the `lastChunkHandling` option.
|
|
41
48
|
*/
|
|
42
|
-
setFromBase64?: (string: string) => {
|
|
49
|
+
setFromBase64?: (string: string, options?: FromBase64Options) => {
|
|
43
50
|
read: number;
|
|
44
51
|
written: number;
|
|
45
52
|
};
|
package/dist/polyfills.js
CHANGED
|
@@ -15,8 +15,13 @@ Uint8Array.prototype.toHex ??=
|
|
|
15
15
|
});
|
|
16
16
|
Uint8Array.prototype.toBase64 ??=
|
|
17
17
|
(debug('Using a polyfill of Uint8Array.prototype.toBase64'),
|
|
18
|
-
function toBase64() {
|
|
19
|
-
|
|
18
|
+
function toBase64(options = {}) {
|
|
19
|
+
let base64 = btoa(String.fromCharCode(...this));
|
|
20
|
+
if (options.omitPadding)
|
|
21
|
+
base64 = base64.replaceAll('=', '');
|
|
22
|
+
if (options.alphabet == 'base64url')
|
|
23
|
+
base64 = base64.replaceAll('+', '-').replaceAll('/', '_');
|
|
24
|
+
return base64;
|
|
20
25
|
});
|
|
21
26
|
Uint8Array.fromHex ??=
|
|
22
27
|
(debug('Using a polyfill of Uint8Array.fromHex'),
|
|
@@ -27,3 +32,32 @@ Uint8Array.fromHex ??=
|
|
|
27
32
|
}
|
|
28
33
|
return bytes;
|
|
29
34
|
});
|
|
35
|
+
Uint8Array.fromBase64 ??=
|
|
36
|
+
(debug('Using a polyfill of Uint8Array.fromBase64'),
|
|
37
|
+
function fromBase64(base64, options) {
|
|
38
|
+
if (options?.alphabet == 'base64url')
|
|
39
|
+
base64 = base64.replaceAll('-', '+').replaceAll('_', '/');
|
|
40
|
+
const lastChunkBytes = base64.length % 4; // # bytes in last chunk if it is partial
|
|
41
|
+
switch (options?.lastChunkHandling) {
|
|
42
|
+
case 'loose':
|
|
43
|
+
if (lastChunkBytes)
|
|
44
|
+
base64 += '='.repeat(4 - lastChunkBytes);
|
|
45
|
+
break;
|
|
46
|
+
case 'strict':
|
|
47
|
+
if (lastChunkBytes)
|
|
48
|
+
throw new SyntaxError('unexpected incomplete base64 chunk');
|
|
49
|
+
break;
|
|
50
|
+
case 'stop-before-partial':
|
|
51
|
+
if (!lastChunkBytes)
|
|
52
|
+
break;
|
|
53
|
+
if (lastChunkBytes == 2 && base64.at(-1) == '=' && base64.at(-2) != '=')
|
|
54
|
+
throw new SyntaxError('unexpected incomplete base64 chunk');
|
|
55
|
+
base64 = base64.slice(0, -lastChunkBytes);
|
|
56
|
+
}
|
|
57
|
+
const binary = atob(base64);
|
|
58
|
+
const bytes = new Uint8Array(binary.length);
|
|
59
|
+
for (let i = 0; i < binary.length; i++) {
|
|
60
|
+
bytes[i] = binary.charCodeAt(i);
|
|
61
|
+
}
|
|
62
|
+
return bytes;
|
|
63
|
+
});
|
package/dist/server/db.d.ts
CHANGED
|
@@ -1,24 +1,10 @@
|
|
|
1
1
|
import { type Schema } from '@axium/server/database';
|
|
2
|
-
import type {
|
|
2
|
+
import type { Selectable } from 'kysely';
|
|
3
|
+
import type schema from '../../db.json';
|
|
3
4
|
import type { StorageItemMetadata, StorageStats } from '../common.js';
|
|
4
5
|
import '../polyfills.js';
|
|
5
6
|
declare module '@axium/server/database' {
|
|
6
|
-
interface Schema {
|
|
7
|
-
storage: {
|
|
8
|
-
createdAt: Generated<Date>;
|
|
9
|
-
hash: Uint8Array | null;
|
|
10
|
-
id: Generated<string>;
|
|
11
|
-
immutable: Generated<boolean>;
|
|
12
|
-
modifiedAt: Generated<Date>;
|
|
13
|
-
name: string;
|
|
14
|
-
parentId: string | null;
|
|
15
|
-
size: number;
|
|
16
|
-
trashedAt: Date | null;
|
|
17
|
-
type: string;
|
|
18
|
-
userId: string;
|
|
19
|
-
metadata: Generated<Record<string, unknown>>;
|
|
20
|
-
};
|
|
21
|
-
'acl.storage': DBAccessControl & DBBool<'read' | 'write' | 'manage' | 'download' | 'comment'>;
|
|
7
|
+
interface Schema extends FromSchemaFile<typeof schema> {
|
|
22
8
|
}
|
|
23
9
|
}
|
|
24
10
|
/**
|
package/lib/List.svelte
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { contextMenu } from '@axium/client/attachments';
|
|
3
|
+
import { AccessControlDialog, FormDialog, Icon } from '@axium/client/components';
|
|
3
4
|
import '@axium/client/styles/list';
|
|
4
5
|
import type { AccessControllable, UserPublic } from '@axium/core';
|
|
5
6
|
import { formatBytes } from '@axium/core/format';
|
|
6
7
|
import { forMime as iconForMime } from '@axium/core/icons';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { openers, previews } from '@axium/storage/client/3rd-party';
|
|
8
|
+
import { getDirectoryMetadata, updateItemMetadata } from '@axium/storage/client';
|
|
9
|
+
import { copyShortURL } from '@axium/storage/client/frontend';
|
|
10
10
|
import type { StorageItemMetadata } from '@axium/storage/common';
|
|
11
|
+
import Preview from './Preview.svelte';
|
|
11
12
|
|
|
12
13
|
let {
|
|
13
14
|
items = $bindable(),
|
|
@@ -58,13 +59,22 @@
|
|
|
58
59
|
} else if (appMode) location.href = '/files/' + item.id;
|
|
59
60
|
else items = await getDirectoryMetadata(item.id);
|
|
60
61
|
}}
|
|
62
|
+
{@attach contextMenu(
|
|
63
|
+
{ i: 'pencil', text: 'Rename', action: () => dialogs.rename.showModal() },
|
|
64
|
+
{ i: 'user-group', text: 'Share', action: () => dialogs['share:' + item.id].showModal() },
|
|
65
|
+
{ i: 'download', text: 'Download', action: () => dialogs.download.showModal() },
|
|
66
|
+
{ i: 'link-horizontal', text: 'Copy Link', action: () => copyShortURL(item) },
|
|
67
|
+
{ i: 'trash', text: 'Trash', action: () => dialogs.trash.showModal() }
|
|
68
|
+
)}
|
|
61
69
|
>
|
|
62
|
-
<dfn title={item.type}><Icon i={iconForMime(item.type)} /></dfn>
|
|
70
|
+
<dfn class="type" title={item.type}><Icon i={iconForMime(item.type)} /></dfn>
|
|
63
71
|
<span class="name">{item.name}</span>
|
|
64
|
-
<span>{item.modifiedAt.toLocaleString()}</span>
|
|
65
|
-
<span
|
|
72
|
+
<span class="modified mobile-subtle">{item.modifiedAt.toLocaleString()}</span>
|
|
73
|
+
<span class={['size', item.type != 'inode/directory' && 'file-size', 'mobile-subtle']}
|
|
74
|
+
>{item.type == 'inode/directory' ? '—' : formatBytes(item.size)}</span
|
|
75
|
+
>
|
|
66
76
|
<div
|
|
67
|
-
|
|
77
|
+
class="item-actions"
|
|
68
78
|
onclick={e => {
|
|
69
79
|
e.stopPropagation();
|
|
70
80
|
e.stopImmediatePropagation();
|
|
@@ -95,73 +105,12 @@
|
|
|
95
105
|
|
|
96
106
|
<dialog bind:this={dialogs.preview} class="preview">
|
|
97
107
|
{#if activeItem}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
{
|
|
103
|
-
|
|
104
|
-
<div class="openers">
|
|
105
|
-
<span>Open with <a href={first.openURL(activeItem)} target="_blank">{first.name}</a></span>
|
|
106
|
-
{#if others.length}
|
|
107
|
-
<Popover>
|
|
108
|
-
{#snippet toggle()}
|
|
109
|
-
<span class="popover-toggle"><Icon i="caret-down" /></span>
|
|
110
|
-
{/snippet}
|
|
111
|
-
{#each others as opener}
|
|
112
|
-
<a href={opener.openURL(activeItem)} target="_blank">{opener.name}</a>
|
|
113
|
-
{/each}
|
|
114
|
-
</Popover>
|
|
115
|
-
{/if}
|
|
116
|
-
</div>
|
|
117
|
-
{/if}
|
|
118
|
-
<div class="actions">
|
|
119
|
-
{@render action('rename', 'pencil', i, true)}
|
|
120
|
-
{@render action('share:' + activeItem.id, 'user-group', i, true)}
|
|
121
|
-
{@render action('download', 'download', i, true)}
|
|
122
|
-
{@render action('trash', 'trash', i, true)}
|
|
123
|
-
<span class="mobile-hide" onclick={() => dialogs.preview.close()}>
|
|
124
|
-
<Icon i="xmark" --size="20px" />
|
|
125
|
-
</span>
|
|
126
|
-
</div>
|
|
127
|
-
</div>
|
|
128
|
-
<div class="content">
|
|
129
|
-
{#if type.startsWith('image/')}
|
|
130
|
-
<img src={dataURL} alt={activeItem.name} width="100%" />
|
|
131
|
-
{:else if type.startsWith('audio/')}
|
|
132
|
-
<audio src={dataURL} controls></audio>
|
|
133
|
-
{:else if type.startsWith('video/')}
|
|
134
|
-
<video src={dataURL} controls width="100%">
|
|
135
|
-
<track kind="captions" />
|
|
136
|
-
</video>
|
|
137
|
-
{:else if type == 'application/pdf'}
|
|
138
|
-
<object data={dataURL} type="application/pdf" width="100%" height="100%">
|
|
139
|
-
<embed src={dataURL} type="application/pdf" width="100%" height="100%" />
|
|
140
|
-
<p>PDF not displayed? <a href={dataURL} download={activeItem.name}>Download</a></p>
|
|
141
|
-
</object>
|
|
142
|
-
{:else if type.startsWith('text/')}
|
|
143
|
-
{#await downloadItem(activeItem.id).then(b => b.text())}
|
|
144
|
-
<div class="full-fill no-preview">
|
|
145
|
-
<Icon i="cloud-arrow-down" --size="50px" />
|
|
146
|
-
<span>Loading</span>
|
|
147
|
-
</div>
|
|
148
|
-
{:then content}
|
|
149
|
-
<pre class="full-fill preview-text">{content}</pre>
|
|
150
|
-
{:catch}
|
|
151
|
-
<div class="full-fill no-preview">
|
|
152
|
-
<Icon i="cloud-exclamation" --size="50px" />
|
|
153
|
-
<span>Error loading preview. You might not have permission to view this file.</span>
|
|
154
|
-
</div>
|
|
155
|
-
{/await}
|
|
156
|
-
{:else if previews.has(type)}
|
|
157
|
-
{@render previews.get(type)!(activeItem)}
|
|
158
|
-
{:else}
|
|
159
|
-
<div class="full-fill no-preview">
|
|
160
|
-
<Icon i="eye-slash" --size="50px" />
|
|
161
|
-
<span>Preview not available</span>
|
|
162
|
-
</div>
|
|
163
|
-
{/if}
|
|
164
|
-
</div>
|
|
108
|
+
<Preview
|
|
109
|
+
item={activeItem}
|
|
110
|
+
previewDialog={dialogs.preview}
|
|
111
|
+
shareDialog={dialogs['share:' + activeItem.id]}
|
|
112
|
+
onDelete={() => items.splice(activeIndex, 1)}
|
|
113
|
+
/>
|
|
165
114
|
{/if}
|
|
166
115
|
</dialog>
|
|
167
116
|
|
|
@@ -206,110 +155,47 @@
|
|
|
206
155
|
</FormDialog>
|
|
207
156
|
|
|
208
157
|
<style>
|
|
158
|
+
.item-actions {
|
|
159
|
+
display: contents;
|
|
160
|
+
}
|
|
161
|
+
|
|
209
162
|
.list-item {
|
|
210
163
|
grid-template-columns: 1em 4fr 15em 5em repeat(4, 1em);
|
|
211
164
|
}
|
|
212
165
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
height: 100%;
|
|
217
|
-
background-color: #000a;
|
|
218
|
-
border: none;
|
|
219
|
-
padding: 1em;
|
|
220
|
-
word-wrap: normal;
|
|
221
|
-
anchor-scope: --preview-openers;
|
|
222
|
-
|
|
223
|
-
.preview-action:hover {
|
|
224
|
-
cursor: pointer;
|
|
166
|
+
@media (width < 700px) {
|
|
167
|
+
.item-actions {
|
|
168
|
+
display: none;
|
|
225
169
|
}
|
|
226
170
|
|
|
227
|
-
.
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
gap: 1em;
|
|
231
|
-
justify-content: space-between;
|
|
232
|
-
padding: 0;
|
|
233
|
-
position: absolute;
|
|
234
|
-
inset: 0.5em 1em 0;
|
|
235
|
-
height: fit-content;
|
|
171
|
+
.list-item {
|
|
172
|
+
grid-template-columns: 1em 2fr 1fr;
|
|
173
|
+
row-gap: 0.25em;
|
|
236
174
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
align-items: center;
|
|
175
|
+
.modified {
|
|
176
|
+
grid-row: 2;
|
|
177
|
+
grid-column: 2;
|
|
241
178
|
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
.openers {
|
|
245
|
-
padding: 1em;
|
|
246
|
-
border: 1px solid var(--border-accent);
|
|
247
|
-
border-radius: 1em;
|
|
248
|
-
height: 2em;
|
|
249
|
-
anchor-name: --preview-openers;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
.openers :global([popover]) {
|
|
253
|
-
inset: anchor(bottom) anchor(right) auto anchor(left);
|
|
254
|
-
position-anchor: --preview-openers;
|
|
255
|
-
width: anchor-size(width);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
.actions {
|
|
259
|
-
right: 0;
|
|
260
|
-
}
|
|
261
179
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
180
|
+
.size {
|
|
181
|
+
grid-row: 2;
|
|
182
|
+
grid-column: 3;
|
|
183
|
+
text-align: right;
|
|
265
184
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
inset: 0;
|
|
269
|
-
width: 100%;
|
|
270
|
-
height: 100%;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
.preview-text {
|
|
274
|
-
white-space: pre-wrap;
|
|
275
|
-
overflow-y: scroll;
|
|
276
|
-
line-height: 1.6;
|
|
277
|
-
background-color: var(--bg-menu);
|
|
278
|
-
font-family: monospace;
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
.no-preview {
|
|
283
|
-
display: flex;
|
|
284
|
-
flex-direction: column;
|
|
285
|
-
gap: 1em;
|
|
286
|
-
align-items: center;
|
|
287
|
-
justify-content: center;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
@media (width < 700px) {
|
|
291
|
-
.preview-top-bar {
|
|
292
|
-
flex-direction: column;
|
|
293
|
-
|
|
294
|
-
.actions {
|
|
295
|
-
justify-content: space-around;
|
|
296
|
-
width: 100%;
|
|
297
|
-
|
|
298
|
-
.preview-action {
|
|
299
|
-
padding: 1em;
|
|
300
|
-
flex: 1 1 0;
|
|
301
|
-
border-radius: 1em;
|
|
302
|
-
border: 1px solid var(--border-accent);
|
|
303
|
-
padding: 1em;
|
|
304
|
-
justify-content: center;
|
|
305
|
-
display: flex;
|
|
306
|
-
}
|
|
185
|
+
&:not(.file-size) {
|
|
186
|
+
display: none;
|
|
307
187
|
}
|
|
308
188
|
}
|
|
309
|
-
|
|
310
|
-
.content {
|
|
311
|
-
inset: 10em 1em 0;
|
|
312
|
-
}
|
|
313
189
|
}
|
|
314
190
|
}
|
|
191
|
+
|
|
192
|
+
dialog.preview {
|
|
193
|
+
inset: 0;
|
|
194
|
+
width: 100%;
|
|
195
|
+
height: 100%;
|
|
196
|
+
background-color: #000a;
|
|
197
|
+
border: none;
|
|
198
|
+
padding: 1em;
|
|
199
|
+
word-wrap: normal;
|
|
200
|
+
}
|
|
315
201
|
</style>
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { FormDialog, Icon, Popover } from '@axium/client/components';
|
|
3
|
+
import type { AccessControllable } from '@axium/core';
|
|
4
|
+
import { downloadItem, getDirectoryMetadata, updateItemMetadata } from '@axium/storage/client';
|
|
5
|
+
import { openers, previews } from '@axium/storage/client/3rd-party';
|
|
6
|
+
import { copyShortURL } from '@axium/storage/client/frontend';
|
|
7
|
+
import type { StorageItemMetadata } from '@axium/storage/common';
|
|
8
|
+
import '@axium/storage/polyfills';
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
item,
|
|
12
|
+
shareDialog,
|
|
13
|
+
previewDialog,
|
|
14
|
+
onDelete = () => {},
|
|
15
|
+
}: {
|
|
16
|
+
item: StorageItemMetadata & AccessControllable;
|
|
17
|
+
shareDialog?: HTMLDialogElement;
|
|
18
|
+
previewDialog?: HTMLDialogElement;
|
|
19
|
+
onDelete?(): unknown;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
const itemOpeners = openers.filter(opener => opener.types.includes(item.type));
|
|
23
|
+
|
|
24
|
+
let dialogs = $state<Record<string, HTMLDialogElement>>({});
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
{#snippet action(name: string, icon: string)}
|
|
28
|
+
<span class="icon-text preview-action" onclick={() => dialogs[name].showModal()}>
|
|
29
|
+
<Icon i={icon} />
|
|
30
|
+
</span>
|
|
31
|
+
{/snippet}
|
|
32
|
+
|
|
33
|
+
<div class="preview-top-bar">
|
|
34
|
+
<div class="title">{item.name}</div>
|
|
35
|
+
{#if itemOpeners.length}
|
|
36
|
+
{@const [first, ...others] = itemOpeners}
|
|
37
|
+
<div class="openers">
|
|
38
|
+
<span>Open with <a href={first.openURL(item)} target="_blank">{first.name}</a></span>
|
|
39
|
+
{#if others.length}
|
|
40
|
+
<Popover>
|
|
41
|
+
{#snippet toggle()}
|
|
42
|
+
<span class="popover-toggle"><Icon i="caret-down" /></span>
|
|
43
|
+
{/snippet}
|
|
44
|
+
{#each others as opener}
|
|
45
|
+
<a href={opener.openURL(item)} target="_blank">{opener.name}</a>
|
|
46
|
+
{/each}
|
|
47
|
+
</Popover>
|
|
48
|
+
{/if}
|
|
49
|
+
</div>
|
|
50
|
+
{/if}
|
|
51
|
+
<div class="actions">
|
|
52
|
+
{@render action('rename', 'pencil')}
|
|
53
|
+
{#if shareDialog}
|
|
54
|
+
<span class="icon-text preview-action" onclick={() => shareDialog.showModal()}>
|
|
55
|
+
<Icon i="user-group" />
|
|
56
|
+
</span>
|
|
57
|
+
{/if}
|
|
58
|
+
{@render action('download', 'download')}
|
|
59
|
+
<span class="icon-text preview-action" onclick={() => copyShortURL(item)}>
|
|
60
|
+
<Icon i="link-horizontal" />
|
|
61
|
+
</span>
|
|
62
|
+
{@render action('trash', 'trash')}
|
|
63
|
+
{#if previewDialog}
|
|
64
|
+
<span class="icon-text preview-action mobile-hide" onclick={() => previewDialog.close()}>
|
|
65
|
+
<Icon i="xmark" --size="20px" />
|
|
66
|
+
</span>
|
|
67
|
+
{/if}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="preview-content">
|
|
71
|
+
{#if item.type.startsWith('image/')}
|
|
72
|
+
<img src={item.dataURL} alt={item.name} width="100%" />
|
|
73
|
+
{:else if item.type.startsWith('audio/')}
|
|
74
|
+
<audio src={item.dataURL} controls></audio>
|
|
75
|
+
{:else if item.type.startsWith('video/')}
|
|
76
|
+
<video src={item.dataURL} controls width="100%">
|
|
77
|
+
<track kind="captions" />
|
|
78
|
+
</video>
|
|
79
|
+
{:else if item.type == 'application/pdf'}
|
|
80
|
+
<object data={item.dataURL} type="application/pdf" width="100%" height="100%">
|
|
81
|
+
<embed src={item.dataURL} type="application/pdf" width="100%" height="100%" />
|
|
82
|
+
<p>PDF not displayed? <a href={item.dataURL} download={item.name}>Download</a></p>
|
|
83
|
+
</object>
|
|
84
|
+
{:else if item.type.startsWith('text/')}
|
|
85
|
+
{#await downloadItem(item.id).then(b => b.text())}
|
|
86
|
+
<div class="full-fill no-preview">
|
|
87
|
+
<Icon i="cloud-arrow-down" --size="50px" />
|
|
88
|
+
<span>Loading</span>
|
|
89
|
+
</div>
|
|
90
|
+
{:then content}
|
|
91
|
+
<pre class="full-fill preview-text">{content}</pre>
|
|
92
|
+
{:catch}
|
|
93
|
+
<div class="full-fill no-preview">
|
|
94
|
+
<Icon i="cloud-exclamation" --size="50px" />
|
|
95
|
+
<span>Error loading preview. You might not have permission to view this file.</span>
|
|
96
|
+
</div>
|
|
97
|
+
{/await}
|
|
98
|
+
{:else if previews.has(item.type)}
|
|
99
|
+
{@render previews.get(item.type)!(item)}
|
|
100
|
+
{:else}
|
|
101
|
+
<div class="full-fill no-preview">
|
|
102
|
+
<Icon i="eye-slash" --size="50px" />
|
|
103
|
+
<span>Preview not available</span>
|
|
104
|
+
</div>
|
|
105
|
+
{/if}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<FormDialog
|
|
109
|
+
bind:dialog={dialogs.rename}
|
|
110
|
+
submitText="Rename"
|
|
111
|
+
submit={async (data: { name: string }) => {
|
|
112
|
+
await updateItemMetadata(item.id, data);
|
|
113
|
+
item.name = data.name;
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<div>
|
|
117
|
+
<label for="name">Name</label>
|
|
118
|
+
<input name="name" type="text" required value={item.name} />
|
|
119
|
+
</div>
|
|
120
|
+
</FormDialog>
|
|
121
|
+
<FormDialog
|
|
122
|
+
bind:dialog={dialogs.trash}
|
|
123
|
+
submitText="Trash"
|
|
124
|
+
submitDanger
|
|
125
|
+
submit={async () => {
|
|
126
|
+
if (!item) throw 'No item is selected';
|
|
127
|
+
await updateItemMetadata(item.id, { trash: true });
|
|
128
|
+
onDelete();
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
<p>Are you sure you want to trash this?</p>
|
|
132
|
+
</FormDialog>
|
|
133
|
+
<FormDialog
|
|
134
|
+
bind:dialog={dialogs.download}
|
|
135
|
+
submitText="Download"
|
|
136
|
+
submit={async () => {
|
|
137
|
+
if (item!.type == 'inode/directory') {
|
|
138
|
+
/** @todo ZIP support */
|
|
139
|
+
const children = await getDirectoryMetadata(item.id);
|
|
140
|
+
for (const child of children) open(child.dataURL, '_blank');
|
|
141
|
+
} else open(item!.dataURL, '_blank');
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<p>Are you sure you want to download this?</p>
|
|
145
|
+
</FormDialog>
|
|
146
|
+
|
|
147
|
+
<style>
|
|
148
|
+
:host {
|
|
149
|
+
anchor-scope: --preview-openers;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.preview-action {
|
|
153
|
+
--size: 18px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.preview-action:hover {
|
|
157
|
+
cursor: pointer;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.preview-top-bar {
|
|
161
|
+
display: flex;
|
|
162
|
+
align-items: center;
|
|
163
|
+
gap: 1em;
|
|
164
|
+
justify-content: space-between;
|
|
165
|
+
padding: 0;
|
|
166
|
+
position: absolute;
|
|
167
|
+
inset: 0.5em 1em 0;
|
|
168
|
+
height: fit-content;
|
|
169
|
+
|
|
170
|
+
> div {
|
|
171
|
+
display: flex;
|
|
172
|
+
gap: 1em;
|
|
173
|
+
align-items: center;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.openers {
|
|
178
|
+
padding: 1em;
|
|
179
|
+
border: 1px solid var(--border-accent);
|
|
180
|
+
border-radius: 1em;
|
|
181
|
+
height: 2em;
|
|
182
|
+
anchor-name: --preview-openers;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.openers :global([popover]) {
|
|
186
|
+
inset: anchor(bottom) anchor(right) auto anchor(left);
|
|
187
|
+
position-anchor: --preview-openers;
|
|
188
|
+
width: anchor-size(width);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.actions {
|
|
192
|
+
right: 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.preview-content {
|
|
196
|
+
position: absolute;
|
|
197
|
+
inset: 3em 10em 0;
|
|
198
|
+
|
|
199
|
+
.full-fill {
|
|
200
|
+
position: absolute;
|
|
201
|
+
inset: 0;
|
|
202
|
+
width: 100%;
|
|
203
|
+
height: 100%;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.preview-text {
|
|
207
|
+
white-space: pre-wrap;
|
|
208
|
+
overflow-y: scroll;
|
|
209
|
+
line-height: 1.6;
|
|
210
|
+
background-color: var(--bg-menu);
|
|
211
|
+
font-family: monospace;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.no-preview {
|
|
216
|
+
display: flex;
|
|
217
|
+
flex-direction: column;
|
|
218
|
+
gap: 1em;
|
|
219
|
+
align-items: center;
|
|
220
|
+
justify-content: center;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
@media (width < 700px) {
|
|
224
|
+
.preview-top-bar {
|
|
225
|
+
flex-direction: column;
|
|
226
|
+
|
|
227
|
+
.actions {
|
|
228
|
+
justify-content: space-around;
|
|
229
|
+
width: 100%;
|
|
230
|
+
|
|
231
|
+
.preview-action {
|
|
232
|
+
padding: 1em;
|
|
233
|
+
flex: 1 1 0;
|
|
234
|
+
border-radius: 1em;
|
|
235
|
+
border: 1px solid var(--border-accent);
|
|
236
|
+
padding: 1em;
|
|
237
|
+
justify-content: center;
|
|
238
|
+
display: flex;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.preview-content {
|
|
244
|
+
inset: 10em 1em 0;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
</style>
|
package/lib/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { default as
|
|
2
|
-
export { default as
|
|
3
|
-
export { default as
|
|
4
|
-
export { default as
|
|
5
|
-
export { default as
|
|
1
|
+
export { default as Add } from './Add.svelte';
|
|
2
|
+
export { default as List } from './List.svelte';
|
|
3
|
+
export { default as Preview } from './Preview.svelte';
|
|
4
|
+
export { default as Sidebar } from './Sidebar.svelte';
|
|
5
|
+
export { default as SidebarItem } from './SidebarItem.svelte';
|
|
6
|
+
export { default as Usage } from './Usage.svelte';
|
package/lib/tsconfig.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axium/storage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"author": "James Prevett <axium@jamespre.dev>",
|
|
5
5
|
"description": "User file storage for Axium",
|
|
6
6
|
"funding": {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"build": "tsc"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@axium/client": ">=0.
|
|
42
|
+
"@axium/client": ">=0.14.1",
|
|
43
43
|
"@axium/core": ">=0.19.0",
|
|
44
44
|
"@axium/server": ">=0.35.0",
|
|
45
45
|
"@sveltejs/kit": "^2.27.3",
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { decodeUUID } from 'utilium';
|
|
2
|
+
import '@axium/storage/polyfills';
|
|
3
|
+
|
|
4
|
+
export const ssr = false;
|
|
5
|
+
|
|
6
|
+
export async function load({ params }) {
|
|
7
|
+
const uuid = decodeUUID(Uint8Array.fromBase64(params.id, { alphabet: 'base64url' }));
|
|
8
|
+
location.href = '/files/' + uuid;
|
|
9
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import SidebarLayout from '@axium/client/components/SidebarLayout';
|
|
3
|
-
import {
|
|
3
|
+
import { Usage } from '@axium/storage/components';
|
|
4
4
|
|
|
5
5
|
let { children, data } = $props();
|
|
6
6
|
</script>
|
|
7
7
|
|
|
8
8
|
<SidebarLayout tabs={data.tabs}>
|
|
9
9
|
{#snippet bottom()}
|
|
10
|
-
<
|
|
10
|
+
<Usage userId={data.session?.userId} />
|
|
11
11
|
{/snippet}
|
|
12
12
|
|
|
13
13
|
{@render children()}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { Add, List } from '@axium/storage/components';
|
|
3
3
|
|
|
4
4
|
const { data } = $props();
|
|
5
5
|
let items = $state(data.items!);
|
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
<title>Files</title>
|
|
10
10
|
</svelte:head>
|
|
11
11
|
|
|
12
|
-
<
|
|
13
|
-
<
|
|
12
|
+
<List appMode bind:items user={data.session?.user} />
|
|
13
|
+
<Add onAdd={item => items.push(item)} />
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { Icon } from '@axium/client/components';
|
|
3
|
-
import {
|
|
2
|
+
import { AccessControlDialog, FormDialog, Icon } from '@axium/client/components';
|
|
3
|
+
import { Add, List, Preview } from '@axium/storage/components';
|
|
4
4
|
import type { PageProps } from './$types';
|
|
5
|
-
import { updateItemMetadata } from '@axium/storage/client';
|
|
5
|
+
import { getDirectoryMetadata, updateItemMetadata } from '@axium/storage/client';
|
|
6
|
+
import { copyShortURL } from '@axium/storage/client/frontend';
|
|
6
7
|
|
|
7
8
|
const { data }: PageProps = $props();
|
|
8
9
|
|
|
9
10
|
let items = $state(data.items!);
|
|
10
|
-
const item = $
|
|
11
|
+
const item = $derived(data.item);
|
|
12
|
+
const user = $derived(data.session?.user);
|
|
13
|
+
let shareDialog = $state<HTMLDialogElement>()!;
|
|
14
|
+
const dialogs = $state<Record<string, HTMLDialogElement>>({});
|
|
15
|
+
|
|
16
|
+
const parentHref = $derived('/files' + (item.parentId ? '/' + item.parentId : ''));
|
|
11
17
|
</script>
|
|
12
18
|
|
|
13
19
|
<svelte:head>
|
|
@@ -24,18 +30,97 @@
|
|
|
24
30
|
>
|
|
25
31
|
<Icon i="trash-can-undo" /> Restore
|
|
26
32
|
</button>
|
|
27
|
-
{:else if item.type == 'inode/directory'}
|
|
28
|
-
<button
|
|
29
|
-
class="icon-text"
|
|
30
|
-
onclick={e => {
|
|
31
|
-
e.preventDefault();
|
|
32
|
-
location.href = '/files' + (item.parentId ? '/' + item.parentId : '');
|
|
33
|
-
}}
|
|
34
|
-
>
|
|
35
|
-
<Icon i="folder-arrow-up" /> Back
|
|
36
|
-
</button>
|
|
37
|
-
<StorageList appMode bind:items user={data.session?.user} />
|
|
38
|
-
<StorageAdd parentId={item.id} onAdd={item => items.push(item)} />
|
|
39
33
|
{:else}
|
|
40
|
-
<
|
|
34
|
+
<AccessControlDialog
|
|
35
|
+
bind:dialog={shareDialog}
|
|
36
|
+
{item}
|
|
37
|
+
itemType="storage"
|
|
38
|
+
editable={(item.acl?.find(
|
|
39
|
+
a =>
|
|
40
|
+
a.userId == user?.id ||
|
|
41
|
+
(a.role && user?.roles.includes(a.role)) ||
|
|
42
|
+
(a.tag && user?.tags?.includes(a.tag)) ||
|
|
43
|
+
(!a.userId && !a.role && !a.tag)
|
|
44
|
+
)?.manage as boolean | undefined) ?? true}
|
|
45
|
+
/>
|
|
46
|
+
{#if item.type == 'inode/directory'}
|
|
47
|
+
{#snippet action(i: string, text: string, handler: (e: Event) => unknown)}
|
|
48
|
+
<button
|
|
49
|
+
class="icon-text"
|
|
50
|
+
onclick={e => {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
handler(e);
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
<Icon {i} />
|
|
56
|
+
<span class="mobile-hide">{text}</span>
|
|
57
|
+
</button>
|
|
58
|
+
{/snippet}
|
|
59
|
+
|
|
60
|
+
<div class="folder-actions">
|
|
61
|
+
{@render action('folder-arrow-up', 'Back', () => (location.href = parentHref))}
|
|
62
|
+
{@render action('pencil', 'Rename', () => dialogs.rename.showModal())}
|
|
63
|
+
{@render action('user-group', 'Share', () => shareDialog.showModal())}
|
|
64
|
+
{@render action('download', 'Download', () => dialogs.download.showModal())}
|
|
65
|
+
{@render action('link-horizontal', 'Copy Link', () => copyShortURL(item))}
|
|
66
|
+
{@render action('trash', 'Trash', () => dialogs.trash.showModal())}
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<List appMode bind:items user={data.session?.user} />
|
|
70
|
+
<Add parentId={item.id} onAdd={item => items.push(item)} />
|
|
71
|
+
|
|
72
|
+
<FormDialog
|
|
73
|
+
bind:dialog={dialogs.rename}
|
|
74
|
+
submitText="Rename"
|
|
75
|
+
submit={async (data: { name: string }) => {
|
|
76
|
+
await updateItemMetadata(item.id, data);
|
|
77
|
+
item.name = data.name;
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<div>
|
|
81
|
+
<label for="name">Name</label>
|
|
82
|
+
<input name="name" type="text" required value={item.name} />
|
|
83
|
+
</div>
|
|
84
|
+
</FormDialog>
|
|
85
|
+
<FormDialog
|
|
86
|
+
bind:dialog={dialogs.trash}
|
|
87
|
+
submitText="Trash"
|
|
88
|
+
submitDanger
|
|
89
|
+
submit={async () => {
|
|
90
|
+
await updateItemMetadata(item.id, { trash: true });
|
|
91
|
+
location.href = parentHref;
|
|
92
|
+
}}
|
|
93
|
+
>
|
|
94
|
+
<p>Are you sure you want to trash this folder?</p>
|
|
95
|
+
</FormDialog>
|
|
96
|
+
<FormDialog
|
|
97
|
+
bind:dialog={dialogs.download}
|
|
98
|
+
submitText="Download"
|
|
99
|
+
submit={async () => {
|
|
100
|
+
/** @todo ZIP support */
|
|
101
|
+
const children = await getDirectoryMetadata(item.id);
|
|
102
|
+
for (const child of children) open(child.dataURL, '_blank');
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<p>Are you sure you want to download this folder?</p>
|
|
106
|
+
</FormDialog>
|
|
107
|
+
{:else}
|
|
108
|
+
<div class="preview-container">
|
|
109
|
+
<Preview {item} {shareDialog} onDelete={() => (location.href = parentHref)} />
|
|
110
|
+
</div>
|
|
111
|
+
{/if}
|
|
41
112
|
{/if}
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
.preview-container {
|
|
116
|
+
position: relative;
|
|
117
|
+
width: 100%;
|
|
118
|
+
height: 100%;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.folder-actions {
|
|
122
|
+
display: flex;
|
|
123
|
+
gap: 1em;
|
|
124
|
+
align-items: center;
|
|
125
|
+
}
|
|
126
|
+
</style>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { List } from '@axium/storage/components';
|
|
3
3
|
|
|
4
4
|
const { data } = $props();
|
|
5
5
|
let items = $state(data.items!);
|
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
<title>Files - Shared With You</title>
|
|
10
10
|
</svelte:head>
|
|
11
11
|
|
|
12
|
-
<
|
|
12
|
+
<List appMode bind:items emptyText="No items have been shared with you." user={data.session?.user} />
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { NumberBar } from '@axium/client/components';
|
|
3
3
|
import '@axium/client/styles/list';
|
|
4
4
|
import { formatBytes } from '@axium/core/format';
|
|
5
|
-
import {
|
|
5
|
+
import { List } from '@axium/storage/components';
|
|
6
6
|
|
|
7
7
|
const { data } = $props();
|
|
8
8
|
const { limits } = data.info;
|
|
@@ -21,4 +21,4 @@
|
|
|
21
21
|
|
|
22
22
|
<p><NumberBar max={limits.user_size * 1_000_000} value={usedBytes} text={barText} /></p>
|
|
23
23
|
|
|
24
|
-
<
|
|
24
|
+
<List bind:items emptyText="You have not uploaded any files yet." user={data.session?.user} />
|
package/routes/+layout.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import '@axium/storage/common';
|