@axium/storage 0.6.0 → 0.6.2

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/lib/Add.svelte ADDED
@@ -0,0 +1,93 @@
1
+ <script lang="ts">
2
+ import { forMime } from '@axium/core/icons';
3
+ import { FormDialog, Icon, Popover, Upload } from '@axium/server/components';
4
+ import { uploadItem } from '@axium/storage/client';
5
+ import type { StorageItemMetadata } from '@axium/storage/common';
6
+
7
+ const { parentId, onadd }: { parentId?: string; onadd?(item: StorageItemMetadata): void } = $props();
8
+
9
+ let uploadDialog = $state<HTMLDialogElement>()!;
10
+ let input = $state<HTMLInputElement>();
11
+
12
+ let createDialog = $state<HTMLDialogElement>()!;
13
+ let createType = $state<string>();
14
+ let createIncludesContent = $state(false);
15
+ </script>
16
+
17
+ {#snippet _item(type: string, text: string, includeContent: boolean = false)}
18
+ <span
19
+ class="icon-text add-menu-item"
20
+ onclick={() => {
21
+ createType = type;
22
+ createIncludesContent = includeContent;
23
+ createDialog.showModal();
24
+ }}
25
+ >
26
+ <Icon i={forMime(type)} />
27
+ {text}
28
+ </span>
29
+ {/snippet}
30
+
31
+ <Popover>
32
+ {#snippet toggle()}
33
+ <button class="icon-text"><Icon i="plus" />Add</button>
34
+ {/snippet}
35
+
36
+ <div class="add-menu">
37
+ <span class="icon-text add-menu-item" onclick={() => uploadDialog.showModal()}><Icon i="upload" />Upload</span>
38
+ {@render _item('inode/directory', 'New Folder')}
39
+ {@render _item('text/plain', 'Plain Text')}
40
+ {@render _item('text/x-uri', 'URL', true)}
41
+ </div>
42
+ </Popover>
43
+
44
+ <FormDialog
45
+ bind:dialog={uploadDialog}
46
+ submitText="Upload"
47
+ submit={async () => {
48
+ for (const file of input?.files!) {
49
+ const item = await uploadItem(file, { parentId });
50
+ onadd?.(item);
51
+ }
52
+ }}
53
+ >
54
+ <Upload bind:input multiple />
55
+ </FormDialog>
56
+
57
+ <FormDialog
58
+ bind:dialog={createDialog}
59
+ submitText="Create"
60
+ submit={async (data: { name: string; content?: string }) => {
61
+ const file = new File(createIncludesContent ? [data.content!] : [], data.name, { type: createType });
62
+ const item = await uploadItem(file, { parentId });
63
+ onadd?.(item);
64
+ }}
65
+ >
66
+ <div>
67
+ <label for="name">Name</label>
68
+ <input name="name" type="text" required />
69
+ </div>
70
+ {#if createIncludesContent}
71
+ <div>
72
+ <label for="content">Content</label>
73
+ <input name="content" type="text" size="40" required />
74
+ </div>
75
+ {/if}
76
+ </FormDialog>
77
+
78
+ <style>
79
+ .add-menu {
80
+ display: flex;
81
+ flex-direction: column;
82
+ }
83
+
84
+ .add-menu-item {
85
+ border-radius: 0.5em;
86
+ padding: 0.25em 0.25em;
87
+ }
88
+
89
+ .add-menu-item:hover {
90
+ cursor: pointer;
91
+ background-color: #4455;
92
+ }
93
+ </style>
package/lib/List.svelte CHANGED
@@ -6,7 +6,11 @@
6
6
  import type { StorageItemMetadata } from '@axium/storage/common';
7
7
  import '../styles/list.css';
8
8
 
9
- let { items = $bindable([]), appMode }: { appMode?: boolean; items: StorageItemMetadata[] } = $props();
9
+ let {
10
+ items = $bindable(),
11
+ appMode,
12
+ emptyText = 'Folder is empty.',
13
+ }: { appMode?: boolean; items: StorageItemMetadata[]; emptyText?: string } = $props();
10
14
 
11
15
  let activeIndex = $state<number>(-1);
12
16
  let activeItem = $derived(activeIndex == -1 ? null : items[activeIndex]);
@@ -40,7 +44,7 @@
40
44
  <dfn title={item.type}><Icon i={iconForMime(item.type)} /></dfn>
41
45
  <span class="name">{item.name}</span>
42
46
  <span>{item.modifiedAt.toLocaleString()}</span>
43
- <span>{formatBytes(item.size)}</span>
47
+ <span>{item.type == 'inode/directory' ? '—' : formatBytes(item.size)}</span>
44
48
  {@render action('rename', 'pencil', i)}
45
49
  {@render action('download', 'download', i)}
46
50
  {@render action('trash', 'trash', i)}
@@ -61,7 +65,7 @@
61
65
  {@render _item(item, i)}
62
66
  {/if}
63
67
  {:else}
64
- <p class="list-empty">Folder is empty.</p>
68
+ <p class="list-empty">{emptyText}</p>
65
69
  {/each}
66
70
  </div>
67
71
 
@@ -69,8 +73,9 @@
69
73
  bind:dialog={dialogs.rename}
70
74
  submitText="Rename"
71
75
  submit={async (data: { name: string }) => {
72
- await updateItemMetadata(activeItem!.id, data);
73
- activeItem!.name = data.name;
76
+ if (!activeItem) throw 'No item is selected';
77
+ await updateItemMetadata(activeItem.id, data);
78
+ activeItem.name = data.name;
74
79
  }}
75
80
  >
76
81
  <div>
@@ -83,8 +88,9 @@
83
88
  submitText="Trash"
84
89
  submitDanger
85
90
  submit={async () => {
86
- await updateItemMetadata(activeItem!.id, { trash: true });
87
- if (activeIndex != -1) items.splice(activeIndex, 1);
91
+ if (!activeItem) throw 'No item is selected';
92
+ await updateItemMetadata(activeItem.id, { trash: true });
93
+ items.splice(activeIndex, 1);
88
94
  }}
89
95
  >
90
96
  <p>Are you sure you want to trash {@render _itemName()}?</p>
@@ -107,10 +113,6 @@
107
113
 
108
114
  <style>
109
115
  .list-item {
110
- display: grid;
111
116
  grid-template-columns: 1em 4fr 15em 5em repeat(3, 1em);
112
- align-items: center;
113
- gap: 0.5em;
114
- padding: 0.5em 0;
115
117
  }
116
118
  </style>
package/lib/Usage.svelte CHANGED
@@ -8,14 +8,16 @@
8
8
  </script>
9
9
 
10
10
  {#await info || getUserStorageInfo(userId) then info}
11
- <a href="/files/usage">
12
- <NumberBar
13
- max={info.limits.user_size * 1_000_000}
14
- value={info.usage.bytes}
15
- text="Using {formatBytes(info.usage.bytes)} of {formatBytes(info.limits.user_size * 1_000_000)}"
16
- --fill="#345"
17
- />
18
- </a>
11
+ <p>
12
+ <a href="/files/usage">
13
+ <NumberBar
14
+ max={info.limits.user_size * 1_000_000}
15
+ value={info.usage.bytes}
16
+ text="Using {formatBytes(info.usage.bytes)} of {formatBytes(info.limits.user_size * 1_000_000)}"
17
+ --fill="#345"
18
+ />
19
+ </a>
20
+ </p>
19
21
  {:catch error}
20
22
  <p>Couldn't load your uploads.</p>
21
23
  <p>{error.message}</p>
package/lib/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { default as StorageAdd } from './Add.svelte';
1
2
  export { default as StorageList } from './List.svelte';
2
3
  export { default as StorageSidebar } from './Sidebar.svelte';
3
4
  export { default as StorageSidebarItem } from './SidebarItem.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@axium/storage",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "author": "James Prevett <axium@jamespre.dev> (https://jamespre.dev)",
5
5
  "description": "User file storage for Axium",
6
6
  "funding": {
@@ -40,7 +40,7 @@
40
40
  "peerDependencies": {
41
41
  "@axium/client": ">=0.1.0",
42
42
  "@axium/core": ">=0.5.0",
43
- "@axium/server": ">=0.19.2",
43
+ "@axium/server": ">=0.20.2",
44
44
  "@sveltejs/kit": "^2.27.3",
45
45
  "utilium": "^2.3.8"
46
46
  },
@@ -2,15 +2,14 @@
2
2
  import { Icon } from '@axium/server/components';
3
3
  import { StorageUsage } from '@axium/storage/components';
4
4
  import { capitalize } from 'utilium';
5
- import type { LayoutProps } from './$types';
6
5
 
7
- let { children, data }: LayoutProps = $props();
6
+ let { children, data } = $props();
8
7
  </script>
9
8
 
10
9
  <div class="app">
11
10
  <div class="sidebar">
12
11
  {#each data.tabs as { href, name, icon: i, active }}
13
- <a {href} class={['item', active && 'active']}><Icon {i} /> {capitalize(name)}</a>
12
+ <a {href} class={['item', 'icon-text', active && 'active']}><Icon {i} /> {capitalize(name)}</a>
14
13
  {/each}
15
14
 
16
15
  <div class="usage">
@@ -42,9 +41,6 @@
42
41
  .item {
43
42
  padding: 0.3em 0.5em;
44
43
  border-radius: 0.25em 1em 1em 0.25em;
45
- display: inline-flex;
46
- align-items: center;
47
- gap: 1em;
48
44
  }
49
45
 
50
46
  .item:hover {
@@ -15,8 +15,5 @@ export async function load({ url, route }: LayoutLoadEvent) {
15
15
  { name: 'shared', href: '/files/shared', icon: 'user-group', active: route.id.endsWith('/files/shared') },
16
16
  ] satisfies { name: string; href: LayoutRouteId; icon: string; active: boolean }[];
17
17
 
18
- return {
19
- session: await getCurrentSession(),
20
- tabs,
21
- };
18
+ return { session, tabs };
22
19
  }
@@ -1,12 +1,13 @@
1
1
  <script lang="ts">
2
- import { StorageList } from '@axium/storage/components';
3
- import type { PageProps } from './$types';
2
+ import { StorageAdd, StorageList } from '@axium/storage/components';
4
3
 
5
- const { data }: PageProps = $props();
4
+ const { data } = $props();
5
+ let items = $state(data.items!);
6
6
  </script>
7
7
 
8
8
  <svelte:head>
9
9
  <title>Files</title>
10
10
  </svelte:head>
11
11
 
12
- <StorageList appMode bind:items={data.items} />
12
+ <StorageList appMode bind:items />
13
+ <StorageAdd onadd={item => items.push(item)} />
@@ -1,11 +1,12 @@
1
1
  <script lang="ts">
2
2
  import { Icon } from '@axium/server/components';
3
- import { StorageList } from '@axium/storage/components';
3
+ import { StorageAdd, StorageList } 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
+ let items = $state(data.items!);
9
10
  const item = $state(data.item);
10
11
  </script>
11
12
 
@@ -24,7 +25,8 @@
24
25
  <Icon i="trash-can-undo" /> Restore
25
26
  </button>
26
27
  {:else if item.type == 'inode/directory'}
27
- <StorageList appMode bind:items={data.items!} />
28
+ <StorageList appMode bind:items />
29
+ <StorageAdd parentId={item.id} onadd={item => items.push(item)} />
28
30
  {:else}
29
31
  <p>No preview available.</p>
30
32
  {/if}
@@ -2,16 +2,26 @@
2
2
  import { formatBytes } from '@axium/core/format';
3
3
  import { forMime as iconForMime } from '@axium/core/icons';
4
4
  import { FormDialog, Icon } from '@axium/server/components';
5
- import { deleteItem } from '@axium/storage/client';
5
+ import { deleteItem, updateItemMetadata } from '@axium/storage/client';
6
6
  import '@axium/storage/styles/list';
7
7
  import type { PageProps } from './$types';
8
8
 
9
9
  const { data }: PageProps = $props();
10
10
  let items = $state(data.items);
11
- let dialog = $state<HTMLDialogElement>();
11
+ let restoreDialog = $state<HTMLDialogElement>()!;
12
+ let deleteDialog = $state<HTMLDialogElement>()!;
12
13
 
13
14
  let activeIndex = $state<number>(-1);
14
- const activeItem = $derived(items[activeIndex]);
15
+ const activeItem = $derived(activeIndex == -1 ? null : items[activeIndex]);
16
+
17
+ function action(index: number, dialog: () => HTMLDialogElement) {
18
+ return (e: Event) => {
19
+ e.stopPropagation();
20
+ e.preventDefault();
21
+ activeIndex = index;
22
+ dialog().showModal();
23
+ };
24
+ }
15
25
  </script>
16
26
 
17
27
  <svelte:head>
@@ -31,16 +41,11 @@
31
41
  <span class="name">{item.name}</span>
32
42
  <span>{item.modifiedAt.toLocaleString()}</span>
33
43
  <span>{formatBytes(item.size)}</span>
34
- <span
35
- class="action"
36
- onclick={(e: Event) => {
37
- e.stopPropagation();
38
- e.preventDefault();
39
- activeIndex = i;
40
- dialog?.showModal();
41
- }}
42
- >
43
- <Icon i="trash" --size="14px" --fill="#c44" />
44
+ <span class="action" onclick={action(i, () => restoreDialog)}>
45
+ <Icon i="rotate-left" --size="14px" />
46
+ </span>
47
+ <span class="action" onclick={action(i, () => deleteDialog)}>
48
+ <Icon i="trash-can-xmark" --size="14px" --fill="#c44" />
44
49
  </span>
45
50
  </div>
46
51
  {:else}
@@ -48,25 +53,40 @@
48
53
  {/each}
49
54
  </div>
50
55
 
56
+ {#snippet _name()}
57
+ {#if activeItem?.name}<strong>{activeItem.name.length > 23 ? activeItem.name.slice(0, 20) + '...' : activeItem.name}</strong>
58
+ {:else}this
59
+ {/if}
60
+ {/snippet}
61
+
62
+ <FormDialog
63
+ bind:dialog={restoreDialog}
64
+ submitText="Restore"
65
+ submit={async () => {
66
+ if (!activeItem) throw 'No item is selected';
67
+ await updateItemMetadata(activeItem.id, { trash: false });
68
+ items.splice(activeIndex, 1);
69
+ }}
70
+ >
71
+ <p>Restore {@render _name()}?</p>
72
+ </FormDialog>
51
73
  <FormDialog
52
- bind:dialog
74
+ bind:dialog={deleteDialog}
53
75
  submitText="Delete"
54
76
  submitDanger
55
77
  submit={async () => {
78
+ if (!activeItem) throw 'No item is selected';
56
79
  await deleteItem(activeItem.id);
57
- if (activeIndex != -1) items.splice(activeIndex, 1);
80
+ items.splice(activeIndex, 1);
58
81
  }}
59
82
  >
60
83
  <p>
61
- Are you sure you want to permanently delete
62
- {#if activeItem?.name}<strong>{activeItem.name.length > 23 ? activeItem.name.slice(0, 20) + '...' : activeItem.name}</strong>
63
- {:else}this
64
- {/if}?
84
+ Are you sure you want to permanently delete {@render _name()}?
65
85
  </p>
66
86
  </FormDialog>
67
87
 
68
88
  <style>
69
89
  .list-item {
70
- grid-template-columns: 1em 4fr 15em 5em 1em !important;
90
+ grid-template-columns: 1em 4fr 15em 5em 1em 1em;
71
91
  }
72
92
  </style>
@@ -4,14 +4,13 @@
4
4
  import { FormDialog, Icon, NumberBar } from '@axium/server/components';
5
5
  import { deleteItem, updateItemMetadata } from '@axium/storage/client';
6
6
  import type { StorageItemUpdate } from '@axium/storage/common';
7
+ import { StorageList } from '@axium/storage/components';
8
+ import '@axium/storage/styles/list';
7
9
 
8
10
  const { data } = $props();
9
- const {
10
- info: { limits },
11
- session,
12
- } = data;
11
+ const { limits } = data.info;
13
12
 
14
- const items = $state(data.info.items.filter(i => i.type != 'inode/directory').sort((a, b) => Math.sign(b.size - a.size)));
13
+ let items = $state(data.info.items.filter(i => i.type != 'inode/directory').sort((a, b) => Math.sign(b.size - a.size)));
15
14
  const usage = $state(data.info.usage);
16
15
 
17
16
  let dialogs = $state<Record<string, HTMLDialogElement>>({});
@@ -23,71 +22,13 @@
23
22
  </svelte:head>
24
23
 
25
24
  {#snippet action(name: string, i: string = 'pen')}
26
- <button style:display="contents" onclick={() => dialogs[name].showModal()}>
25
+ <span class="action" onclick={() => dialogs[name].showModal()}>
27
26
  <Icon {i} --size="16px" />
28
- </button>
27
+ </span>
29
28
  {/snippet}
30
29
 
31
- <div class="flex-content">
32
- <div class="list main">
33
- <h2>Storage Usage</h2>
30
+ <h2>Storage Usage</h2>
34
31
 
35
- <p><NumberBar max={limits.user_size * 1_000_000} value={usage?.bytes} text={barText} --fill="#345" /></p>
32
+ <p><NumberBar max={limits.user_size * 1_000_000} value={usage?.bytes} text={barText} --fill="#345" /></p>
36
33
 
37
- {#each items as item}
38
- <div class="item">
39
- <Icon i={forMime(item.type)} />
40
- <p>{item.name}</p>
41
- <p>{item.type}</p>
42
- <p>Owned by {item.userId === session?.userId ? 'You' : item.userId}</p>
43
- <p>{formatBytes(item.size)}</p>
44
- <p>Uploaded {item.modifiedAt.toLocaleString()}</p>
45
- <span>{@render action('rename#' + item.id)}</span>
46
- <span>{@render action('delete#' + item.id, 'trash')}</span>
47
- </div>
48
- <FormDialog
49
- bind:dialog={dialogs['rename#' + item.id]}
50
- submit={(data: StorageItemUpdate) => updateItemMetadata(item.id, data).then(n => (item.name = n.name))}
51
- submitText="Update"
52
- >
53
- <div>
54
- <label for="name">Name</label>
55
- <input name="name" type="text" value={item.name || ''} required />
56
- </div>
57
- </FormDialog>
58
- <FormDialog
59
- bind:dialog={dialogs['delete#' + item.id]}
60
- submit={async (data: StorageItemUpdate) => {
61
- await deleteItem(item.id);
62
- dialogs['delete#' + item.id].close();
63
- items.splice(items.indexOf(item), 1);
64
- }}
65
- submitText="Delete"
66
- submitDanger
67
- >
68
- <p>
69
- Are you sure you want to delete this file?<br />
70
- This action can't be undone.
71
- </p>
72
- </FormDialog>
73
- {/each}
74
- </div>
75
- </div>
76
-
77
- <style>
78
- .list {
79
- width: 80%;
80
- padding-top: 4em;
81
- }
82
-
83
- .item {
84
- display: grid;
85
- align-items: center;
86
- width: 100%;
87
- gap: 1em;
88
- text-wrap: nowrap;
89
- border-top: 1px solid #8888;
90
- padding-bottom: 1em;
91
- grid-template-columns: 2em 1.5fr 1fr 1fr 5em 1fr 2em 2em;
92
- }
93
- </style>
34
+ <StorageList bind:items emptyText="You have not uploaded any files yet." />
package/styles/list.css CHANGED
@@ -4,9 +4,9 @@
4
4
  padding: 0.5em;
5
5
  }
6
6
 
7
- .list-item.list-header {
7
+ .list-header {
8
8
  font-weight: bold;
9
- border-bottom: 1px solid #bbc;
9
+ border-bottom: 1.5px solid #bbc;
10
10
  }
11
11
 
12
12
  .list-item-container {
@@ -18,17 +18,20 @@
18
18
  display: grid;
19
19
  grid-template-columns: 1em 4fr 15em 5em repeat(3, 1em);
20
20
  align-items: center;
21
- gap: 0.5em;
22
- padding: 0.5em 0;
21
+ gap: 1em;
22
+ padding: 0.5em;
23
+ overflow: hidden;
24
+ text-wrap: nowrap;
23
25
  }
24
26
 
25
- .list-item:not(:last-child) {
26
- border-bottom: 1px solid #bbc;
27
+ .list-item:not(.list-header, :first-child) {
28
+ border-top: 1px solid #bbc;
27
29
  }
28
30
 
29
31
  .list-item:not(.list-header):hover {
30
32
  background-color: #7777;
31
33
  }
34
+
32
35
  p.list-empty {
33
36
  text-align: center;
34
37
  color: #888;
@@ -40,10 +43,7 @@ p.list-empty {
40
43
  visibility: hidden;
41
44
  }
42
45
 
43
- .item:hover .action {
46
+ .list-item:hover .action {
44
47
  visibility: visible;
45
- }
46
-
47
- .action:hover {
48
48
  cursor: pointer;
49
49
  }