@axium/storage 0.17.0 → 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/dist/client/frontend.d.ts +2 -0
- package/dist/client/frontend.js +6 -0
- package/dist/polyfills.d.ts +10 -3
- package/dist/polyfills.js +36 -2
- package/lib/List.svelte +45 -4
- package/lib/Preview.svelte +12 -3
- package/lib/tsconfig.json +1 -2
- package/package.json +2 -2
- package/routes/f/[id]/+page.ts +9 -0
- package/routes/files/[id]/+page.svelte +89 -30
|
@@ -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,7 +21,7 @@ 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<ArrayBuffer>;
|
|
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.
|
|
@@ -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/lib/List.svelte
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { contextMenu } from '@axium/client/attachments';
|
|
2
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
8
|
import { getDirectoryMetadata, updateItemMetadata } from '@axium/storage/client';
|
|
9
|
+
import { copyShortURL } from '@axium/storage/client/frontend';
|
|
8
10
|
import type { StorageItemMetadata } from '@axium/storage/common';
|
|
9
11
|
import Preview from './Preview.svelte';
|
|
10
12
|
|
|
@@ -57,13 +59,22 @@
|
|
|
57
59
|
} else if (appMode) location.href = '/files/' + item.id;
|
|
58
60
|
else items = await getDirectoryMetadata(item.id);
|
|
59
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
|
+
)}
|
|
60
69
|
>
|
|
61
|
-
<dfn title={item.type}><Icon i={iconForMime(item.type)} /></dfn>
|
|
70
|
+
<dfn class="type" title={item.type}><Icon i={iconForMime(item.type)} /></dfn>
|
|
62
71
|
<span class="name">{item.name}</span>
|
|
63
|
-
<span>{item.modifiedAt.toLocaleString()}</span>
|
|
64
|
-
<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
|
+
>
|
|
65
76
|
<div
|
|
66
|
-
|
|
77
|
+
class="item-actions"
|
|
67
78
|
onclick={e => {
|
|
68
79
|
e.stopPropagation();
|
|
69
80
|
e.stopImmediatePropagation();
|
|
@@ -144,10 +155,40 @@
|
|
|
144
155
|
</FormDialog>
|
|
145
156
|
|
|
146
157
|
<style>
|
|
158
|
+
.item-actions {
|
|
159
|
+
display: contents;
|
|
160
|
+
}
|
|
161
|
+
|
|
147
162
|
.list-item {
|
|
148
163
|
grid-template-columns: 1em 4fr 15em 5em repeat(4, 1em);
|
|
149
164
|
}
|
|
150
165
|
|
|
166
|
+
@media (width < 700px) {
|
|
167
|
+
.item-actions {
|
|
168
|
+
display: none;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.list-item {
|
|
172
|
+
grid-template-columns: 1em 2fr 1fr;
|
|
173
|
+
row-gap: 0.25em;
|
|
174
|
+
|
|
175
|
+
.modified {
|
|
176
|
+
grid-row: 2;
|
|
177
|
+
grid-column: 2;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.size {
|
|
181
|
+
grid-row: 2;
|
|
182
|
+
grid-column: 3;
|
|
183
|
+
text-align: right;
|
|
184
|
+
|
|
185
|
+
&:not(.file-size) {
|
|
186
|
+
display: none;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
151
192
|
dialog.preview {
|
|
152
193
|
inset: 0;
|
|
153
194
|
width: 100%;
|
package/lib/Preview.svelte
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import type { AccessControllable } from '@axium/core';
|
|
4
4
|
import { downloadItem, getDirectoryMetadata, updateItemMetadata } from '@axium/storage/client';
|
|
5
5
|
import { openers, previews } from '@axium/storage/client/3rd-party';
|
|
6
|
+
import { copyShortURL } from '@axium/storage/client/frontend';
|
|
6
7
|
import type { StorageItemMetadata } from '@axium/storage/common';
|
|
8
|
+
import '@axium/storage/polyfills';
|
|
7
9
|
|
|
8
10
|
const {
|
|
9
11
|
item,
|
|
@@ -24,7 +26,7 @@
|
|
|
24
26
|
|
|
25
27
|
{#snippet action(name: string, icon: string)}
|
|
26
28
|
<span class="icon-text preview-action" onclick={() => dialogs[name].showModal()}>
|
|
27
|
-
<Icon i={icon}
|
|
29
|
+
<Icon i={icon} />
|
|
28
30
|
</span>
|
|
29
31
|
{/snippet}
|
|
30
32
|
|
|
@@ -50,13 +52,16 @@
|
|
|
50
52
|
{@render action('rename', 'pencil')}
|
|
51
53
|
{#if shareDialog}
|
|
52
54
|
<span class="icon-text preview-action" onclick={() => shareDialog.showModal()}>
|
|
53
|
-
<Icon i="user-group"
|
|
55
|
+
<Icon i="user-group" />
|
|
54
56
|
</span>
|
|
55
57
|
{/if}
|
|
56
58
|
{@render action('download', 'download')}
|
|
59
|
+
<span class="icon-text preview-action" onclick={() => copyShortURL(item)}>
|
|
60
|
+
<Icon i="link-horizontal" />
|
|
61
|
+
</span>
|
|
57
62
|
{@render action('trash', 'trash')}
|
|
58
63
|
{#if previewDialog}
|
|
59
|
-
<span class="mobile-hide" onclick={() => previewDialog.close()}>
|
|
64
|
+
<span class="icon-text preview-action mobile-hide" onclick={() => previewDialog.close()}>
|
|
60
65
|
<Icon i="xmark" --size="20px" />
|
|
61
66
|
</span>
|
|
62
67
|
{/if}
|
|
@@ -144,6 +149,10 @@
|
|
|
144
149
|
anchor-scope: --preview-openers;
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
.preview-action {
|
|
153
|
+
--size: 18px;
|
|
154
|
+
}
|
|
155
|
+
|
|
147
156
|
.preview-action:hover {
|
|
148
157
|
cursor: pointer;
|
|
149
158
|
}
|
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,15 +1,17 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { AccessControlDialog, Icon } from '@axium/client/components';
|
|
2
|
+
import { AccessControlDialog, FormDialog, Icon } from '@axium/client/components';
|
|
3
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
11
|
const item = $derived(data.item);
|
|
11
12
|
const user = $derived(data.session?.user);
|
|
12
|
-
let shareDialog = $state<HTMLDialogElement>()
|
|
13
|
+
let shareDialog = $state<HTMLDialogElement>()!;
|
|
14
|
+
const dialogs = $state<Record<string, HTMLDialogElement>>({});
|
|
13
15
|
|
|
14
16
|
const parentHref = $derived('/files' + (item.parentId ? '/' + item.parentId : ''));
|
|
15
17
|
</script>
|
|
@@ -28,34 +30,85 @@
|
|
|
28
30
|
>
|
|
29
31
|
<Icon i="trash-can-undo" /> Restore
|
|
30
32
|
</button>
|
|
31
|
-
{:else if item.type == 'inode/directory'}
|
|
32
|
-
<button
|
|
33
|
-
class="icon-text"
|
|
34
|
-
onclick={e => {
|
|
35
|
-
e.preventDefault();
|
|
36
|
-
location.href = parentHref;
|
|
37
|
-
}}
|
|
38
|
-
>
|
|
39
|
-
<Icon i="folder-arrow-up" /> Back
|
|
40
|
-
</button>
|
|
41
|
-
<List appMode bind:items user={data.session?.user} />
|
|
42
|
-
<Add parentId={item.id} onAdd={item => items.push(item)} />
|
|
43
33
|
{:else}
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
a
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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}
|
|
59
112
|
{/if}
|
|
60
113
|
|
|
61
114
|
<style>
|
|
@@ -64,4 +117,10 @@
|
|
|
64
117
|
width: 100%;
|
|
65
118
|
height: 100%;
|
|
66
119
|
}
|
|
120
|
+
|
|
121
|
+
.folder-actions {
|
|
122
|
+
display: flex;
|
|
123
|
+
gap: 1em;
|
|
124
|
+
align-items: center;
|
|
125
|
+
}
|
|
67
126
|
</style>
|