@axium/storage 0.16.7 → 0.17.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 CHANGED
@@ -62,6 +62,18 @@
62
62
  }
63
63
  }
64
64
  }
65
+ },
66
+ {
67
+ "delta": true,
68
+ "alter_tables": {
69
+ "storage": {
70
+ "alter_columns": {
71
+ "name": {
72
+ "ops": ["set_required"]
73
+ }
74
+ }
75
+ }
76
+ }
65
77
  }
66
78
  ],
67
79
  "wipe": ["storage", "acl.storage"],
@@ -17,12 +17,12 @@ declare global {
17
17
  * @throws {SyntaxError} If the input string contains characters outside the specified alphabet, or if the last
18
18
  * chunk is inconsistent with the `lastChunkHandling` option.
19
19
  */
20
- fromBase64: (string: string) => Uint8Array;
20
+ fromBase64: (string: string) => Uint8Array<ArrayBuffer>;
21
21
  /**
22
22
  * Creates a new `Uint8Array` from a base16-encoded string.
23
23
  * @returns A new `Uint8Array` instance.
24
24
  */
25
- fromHex: (string: string) => Uint8Array;
25
+ fromHex: (string: string) => Uint8Array<ArrayBuffer>;
26
26
  }
27
27
  interface Uint8Array {
28
28
  /**
@@ -1,24 +1,10 @@
1
1
  import { type Schema } from '@axium/server/database';
2
- import type { Generated, Selectable } from 'kysely';
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,12 @@
1
1
  <script lang="ts">
2
- import { AccessControlDialog, FormDialog, Icon, Popover } from '@axium/client/components';
2
+ import { AccessControlDialog, FormDialog, Icon } from '@axium/client/components';
3
3
  import '@axium/client/styles/list';
4
4
  import type { AccessControllable, UserPublic } from '@axium/core';
5
5
  import { formatBytes } from '@axium/core/format';
6
6
  import { forMime as iconForMime } from '@axium/core/icons';
7
- import { errorText } from '@axium/core/io';
8
- import { downloadItem, getDirectoryMetadata, updateItemMetadata } from '@axium/storage/client';
9
- import { openers, previews } from '@axium/storage/client/3rd-party';
7
+ import { getDirectoryMetadata, updateItemMetadata } from '@axium/storage/client';
10
8
  import type { StorageItemMetadata } from '@axium/storage/common';
9
+ import Preview from './Preview.svelte';
11
10
 
12
11
  let {
13
12
  items = $bindable(),
@@ -95,73 +94,12 @@
95
94
 
96
95
  <dialog bind:this={dialogs.preview} class="preview">
97
96
  {#if activeItem}
98
- {@const { type, dataURL } = activeItem}
99
- {@const itemOpeners = openers.filter(opener => opener.types.includes(type))}
100
- <div class="preview-top-bar">
101
- <div class="title">{activeItem.name}</div>
102
- {#if itemOpeners.length}
103
- {@const [first, ...others] = itemOpeners}
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>
97
+ <Preview
98
+ item={activeItem}
99
+ previewDialog={dialogs.preview}
100
+ shareDialog={dialogs['share:' + activeItem.id]}
101
+ onDelete={() => items.splice(activeIndex, 1)}
102
+ />
165
103
  {/if}
166
104
  </dialog>
167
105
 
@@ -218,98 +156,5 @@
218
156
  border: none;
219
157
  padding: 1em;
220
158
  word-wrap: normal;
221
- anchor-scope: --preview-openers;
222
-
223
- .preview-action:hover {
224
- cursor: pointer;
225
- }
226
-
227
- .preview-top-bar {
228
- display: flex;
229
- align-items: center;
230
- gap: 1em;
231
- justify-content: space-between;
232
- padding: 0;
233
- position: absolute;
234
- inset: 0.5em 1em 0;
235
- height: fit-content;
236
-
237
- > div {
238
- display: flex;
239
- gap: 1em;
240
- align-items: center;
241
- }
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
-
262
- .content {
263
- position: absolute;
264
- inset: 3em 10em 0;
265
-
266
- .full-fill {
267
- position: absolute;
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
- }
307
- }
308
- }
309
-
310
- .content {
311
- inset: 10em 1em 0;
312
- }
313
- }
314
159
  }
315
160
  </style>
@@ -0,0 +1,238 @@
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 type { StorageItemMetadata } from '@axium/storage/common';
7
+
8
+ const {
9
+ item,
10
+ shareDialog,
11
+ previewDialog,
12
+ onDelete = () => {},
13
+ }: {
14
+ item: StorageItemMetadata & AccessControllable;
15
+ shareDialog?: HTMLDialogElement;
16
+ previewDialog?: HTMLDialogElement;
17
+ onDelete?(): unknown;
18
+ } = $props();
19
+
20
+ const itemOpeners = openers.filter(opener => opener.types.includes(item.type));
21
+
22
+ let dialogs = $state<Record<string, HTMLDialogElement>>({});
23
+ </script>
24
+
25
+ {#snippet action(name: string, icon: string)}
26
+ <span class="icon-text preview-action" onclick={() => dialogs[name].showModal()}>
27
+ <Icon i={icon} --size="18px" />
28
+ </span>
29
+ {/snippet}
30
+
31
+ <div class="preview-top-bar">
32
+ <div class="title">{item.name}</div>
33
+ {#if itemOpeners.length}
34
+ {@const [first, ...others] = itemOpeners}
35
+ <div class="openers">
36
+ <span>Open with <a href={first.openURL(item)} target="_blank">{first.name}</a></span>
37
+ {#if others.length}
38
+ <Popover>
39
+ {#snippet toggle()}
40
+ <span class="popover-toggle"><Icon i="caret-down" /></span>
41
+ {/snippet}
42
+ {#each others as opener}
43
+ <a href={opener.openURL(item)} target="_blank">{opener.name}</a>
44
+ {/each}
45
+ </Popover>
46
+ {/if}
47
+ </div>
48
+ {/if}
49
+ <div class="actions">
50
+ {@render action('rename', 'pencil')}
51
+ {#if shareDialog}
52
+ <span class="icon-text preview-action" onclick={() => shareDialog.showModal()}>
53
+ <Icon i="user-group" --size="18px" />
54
+ </span>
55
+ {/if}
56
+ {@render action('download', 'download')}
57
+ {@render action('trash', 'trash')}
58
+ {#if previewDialog}
59
+ <span class="mobile-hide" onclick={() => previewDialog.close()}>
60
+ <Icon i="xmark" --size="20px" />
61
+ </span>
62
+ {/if}
63
+ </div>
64
+ </div>
65
+ <div class="preview-content">
66
+ {#if item.type.startsWith('image/')}
67
+ <img src={item.dataURL} alt={item.name} width="100%" />
68
+ {:else if item.type.startsWith('audio/')}
69
+ <audio src={item.dataURL} controls></audio>
70
+ {:else if item.type.startsWith('video/')}
71
+ <video src={item.dataURL} controls width="100%">
72
+ <track kind="captions" />
73
+ </video>
74
+ {:else if item.type == 'application/pdf'}
75
+ <object data={item.dataURL} type="application/pdf" width="100%" height="100%">
76
+ <embed src={item.dataURL} type="application/pdf" width="100%" height="100%" />
77
+ <p>PDF not displayed? <a href={item.dataURL} download={item.name}>Download</a></p>
78
+ </object>
79
+ {:else if item.type.startsWith('text/')}
80
+ {#await downloadItem(item.id).then(b => b.text())}
81
+ <div class="full-fill no-preview">
82
+ <Icon i="cloud-arrow-down" --size="50px" />
83
+ <span>Loading</span>
84
+ </div>
85
+ {:then content}
86
+ <pre class="full-fill preview-text">{content}</pre>
87
+ {:catch}
88
+ <div class="full-fill no-preview">
89
+ <Icon i="cloud-exclamation" --size="50px" />
90
+ <span>Error loading preview. You might not have permission to view this file.</span>
91
+ </div>
92
+ {/await}
93
+ {:else if previews.has(item.type)}
94
+ {@render previews.get(item.type)!(item)}
95
+ {:else}
96
+ <div class="full-fill no-preview">
97
+ <Icon i="eye-slash" --size="50px" />
98
+ <span>Preview not available</span>
99
+ </div>
100
+ {/if}
101
+ </div>
102
+
103
+ <FormDialog
104
+ bind:dialog={dialogs.rename}
105
+ submitText="Rename"
106
+ submit={async (data: { name: string }) => {
107
+ await updateItemMetadata(item.id, data);
108
+ item.name = data.name;
109
+ }}
110
+ >
111
+ <div>
112
+ <label for="name">Name</label>
113
+ <input name="name" type="text" required value={item.name} />
114
+ </div>
115
+ </FormDialog>
116
+ <FormDialog
117
+ bind:dialog={dialogs.trash}
118
+ submitText="Trash"
119
+ submitDanger
120
+ submit={async () => {
121
+ if (!item) throw 'No item is selected';
122
+ await updateItemMetadata(item.id, { trash: true });
123
+ onDelete();
124
+ }}
125
+ >
126
+ <p>Are you sure you want to trash this?</p>
127
+ </FormDialog>
128
+ <FormDialog
129
+ bind:dialog={dialogs.download}
130
+ submitText="Download"
131
+ submit={async () => {
132
+ if (item!.type == 'inode/directory') {
133
+ /** @todo ZIP support */
134
+ const children = await getDirectoryMetadata(item.id);
135
+ for (const child of children) open(child.dataURL, '_blank');
136
+ } else open(item!.dataURL, '_blank');
137
+ }}
138
+ >
139
+ <p>Are you sure you want to download this?</p>
140
+ </FormDialog>
141
+
142
+ <style>
143
+ :host {
144
+ anchor-scope: --preview-openers;
145
+ }
146
+
147
+ .preview-action:hover {
148
+ cursor: pointer;
149
+ }
150
+
151
+ .preview-top-bar {
152
+ display: flex;
153
+ align-items: center;
154
+ gap: 1em;
155
+ justify-content: space-between;
156
+ padding: 0;
157
+ position: absolute;
158
+ inset: 0.5em 1em 0;
159
+ height: fit-content;
160
+
161
+ > div {
162
+ display: flex;
163
+ gap: 1em;
164
+ align-items: center;
165
+ }
166
+ }
167
+
168
+ .openers {
169
+ padding: 1em;
170
+ border: 1px solid var(--border-accent);
171
+ border-radius: 1em;
172
+ height: 2em;
173
+ anchor-name: --preview-openers;
174
+ }
175
+
176
+ .openers :global([popover]) {
177
+ inset: anchor(bottom) anchor(right) auto anchor(left);
178
+ position-anchor: --preview-openers;
179
+ width: anchor-size(width);
180
+ }
181
+
182
+ .actions {
183
+ right: 0;
184
+ }
185
+
186
+ .preview-content {
187
+ position: absolute;
188
+ inset: 3em 10em 0;
189
+
190
+ .full-fill {
191
+ position: absolute;
192
+ inset: 0;
193
+ width: 100%;
194
+ height: 100%;
195
+ }
196
+
197
+ .preview-text {
198
+ white-space: pre-wrap;
199
+ overflow-y: scroll;
200
+ line-height: 1.6;
201
+ background-color: var(--bg-menu);
202
+ font-family: monospace;
203
+ }
204
+ }
205
+
206
+ .no-preview {
207
+ display: flex;
208
+ flex-direction: column;
209
+ gap: 1em;
210
+ align-items: center;
211
+ justify-content: center;
212
+ }
213
+
214
+ @media (width < 700px) {
215
+ .preview-top-bar {
216
+ flex-direction: column;
217
+
218
+ .actions {
219
+ justify-content: space-around;
220
+ width: 100%;
221
+
222
+ .preview-action {
223
+ padding: 1em;
224
+ flex: 1 1 0;
225
+ border-radius: 1em;
226
+ border: 1px solid var(--border-accent);
227
+ padding: 1em;
228
+ justify-content: center;
229
+ display: flex;
230
+ }
231
+ }
232
+ }
233
+
234
+ .preview-content {
235
+ inset: 10em 1em 0;
236
+ }
237
+ }
238
+ </style>
package/lib/index.ts CHANGED
@@ -1,5 +1,6 @@
1
- export { default as StorageAdd } from './Add.svelte';
2
- export { default as StorageList } from './List.svelte';
3
- export { default as StorageSidebar } from './Sidebar.svelte';
4
- export { default as StorageSidebarItem } from './SidebarItem.svelte';
5
- export { default as StorageUsage } from './Usage.svelte';
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/storage",
3
- "version": "0.16.7",
3
+ "version": "0.17.0",
4
4
  "author": "James Prevett <axium@jamespre.dev>",
5
5
  "description": "User file storage for Axium",
6
6
  "funding": {
@@ -1,13 +1,13 @@
1
1
  <script lang="ts">
2
2
  import SidebarLayout from '@axium/client/components/SidebarLayout';
3
- import { StorageUsage } from '@axium/storage/components';
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
- <StorageUsage userId={data.session?.userId} />
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 { StorageAdd, StorageList } from '@axium/storage/components';
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
- <StorageList appMode bind:items user={data.session?.user} />
13
- <StorageAdd onAdd={item => items.push(item)} />
12
+ <List appMode bind:items user={data.session?.user} />
13
+ <Add onAdd={item => items.push(item)} />
@@ -1,13 +1,17 @@
1
1
  <script lang="ts">
2
- import { Icon } from '@axium/client/components';
3
- import { StorageAdd, StorageList } from '@axium/storage/components';
2
+ import { AccessControlDialog, Icon } from '@axium/client/components';
3
+ import { Add, List, Preview } from '@axium/storage/components';
4
4
  import type { PageProps } from './$types';
5
5
  import { updateItemMetadata } from '@axium/storage/client';
6
6
 
7
7
  const { data }: PageProps = $props();
8
8
 
9
9
  let items = $state(data.items!);
10
- const item = $state(data.item);
10
+ const item = $derived(data.item);
11
+ const user = $derived(data.session?.user);
12
+ let shareDialog = $state<HTMLDialogElement>();
13
+
14
+ const parentHref = $derived('/files' + (item.parentId ? '/' + item.parentId : ''));
11
15
  </script>
12
16
 
13
17
  <svelte:head>
@@ -29,13 +33,35 @@
29
33
  class="icon-text"
30
34
  onclick={e => {
31
35
  e.preventDefault();
32
- location.href = '/files' + (item.parentId ? '/' + item.parentId : '');
36
+ location.href = parentHref;
33
37
  }}
34
38
  >
35
39
  <Icon i="folder-arrow-up" /> Back
36
40
  </button>
37
- <StorageList appMode bind:items user={data.session?.user} />
38
- <StorageAdd parentId={item.id} onAdd={item => items.push(item)} />
41
+ <List appMode bind:items user={data.session?.user} />
42
+ <Add parentId={item.id} onAdd={item => items.push(item)} />
39
43
  {:else}
40
- <p>No preview available.</p>
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>
41
59
  {/if}
60
+
61
+ <style>
62
+ .preview-container {
63
+ position: relative;
64
+ width: 100%;
65
+ height: 100%;
66
+ }
67
+ </style>
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { StorageList } from '@axium/storage/components';
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
- <StorageList appMode bind:items emptyText="No items have been shared with you." user={data.session?.user} />
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 { StorageList } from '@axium/storage/components';
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
- <StorageList bind:items emptyText="You have not uploaded any files yet." user={data.session?.user} />
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';