@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.
@@ -0,0 +1,2 @@
1
+ import type { StorageItemMetadata } from '@axium/storage/common';
2
+ export declare function copyShortURL(item: StorageItemMetadata): Promise<void>;
@@ -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
+ }
@@ -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: () => string;
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
- return btoa(String.fromCharCode(...this));
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>{item.type == 'inode/directory' ? '' : formatBytes(item.size)}</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
- style:display="contents"
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%;
@@ -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} --size="18px" />
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" --size="18px" />
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
@@ -4,8 +4,7 @@
4
4
  "rootDir": "..",
5
5
  "noEmit": true,
6
6
  "module": "preserve",
7
- "moduleResolution": "Bundler",
8
- "types": ["@sveltejs/kit"]
7
+ "moduleResolution": "Bundler"
9
8
  },
10
9
  "include": ["**/*.svelte", "**/*.ts"],
11
10
  "exclude": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/storage",
3
- "version": "0.17.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.13.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
- <div class="preview-container">
45
- <AccessControlDialog
46
- bind:dialog={shareDialog}
47
- {item}
48
- itemType="storage"
49
- editable={(item.acl?.find(
50
- a =>
51
- a.userId == user?.id ||
52
- (a.role && user?.roles.includes(a.role)) ||
53
- (a.tag && user?.tags?.includes(a.tag)) ||
54
- (!a.userId && !a.role && !a.tag)
55
- )?.manage as boolean | undefined) ?? true}
56
- />
57
- <Preview {item} {shareDialog} onDelete={() => (location.href = parentHref)} />
58
- </div>
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>