@e280/quay 0.0.0-2 → 0.0.0-20
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/README.md +98 -0
- package/package.json +17 -11
- package/s/cellar/cask.ts +1 -13
- package/s/cellar/cellar.test.ts +25 -42
- package/s/cellar/cellar.ts +16 -20
- package/s/cellar/index.ts +0 -4
- package/s/cellar/mammoth.ts +50 -0
- package/s/cellar/mammoth.worker.bundle.ts +30 -0
- package/s/demo/main.bundle.ts +2 -3
- package/s/dom/components/breadcrumb/component.ts +6 -5
- package/s/dom/components/browser/component.ts +11 -7
- package/s/dom/components/browser/style.css.ts +31 -31
- package/s/dom/components/dropzone/component.ts +6 -4
- package/s/dom/components/dropzone/style.css.ts +12 -13
- package/s/dom/components/filter/component.ts +13 -8
- package/s/dom/components/outliner/component.ts +8 -5
- package/s/dom/components/searchbar/component.ts +13 -7
- package/s/dom/components/sort/component.ts +13 -9
- package/s/dom/theme.css.ts +66 -2
- package/s/dom/views/tree/component.ts +10 -8
- package/s/dom/views/tree/style.css.ts +10 -10
- package/s/genius/parts/hy-tree.ts +10 -9
- package/s/index.html.ts +68 -73
- package/s/index.ts +6 -1
- package/s/logic/aspects/codex/codex.ts +3 -3
- package/s/logic/aspects/codex/example.ts +1 -4
- package/s/logic/aspects/codex/parts/codex-item.ts +4 -4
- package/s/logic/aspects/codex/parts/hierarchy.ts +1 -0
- package/s/logic/aspects/codex/parts/types.ts +1 -1
- package/s/logic/aspects/dropzone.ts +20 -16
- package/s/logic/aspects/tree-trail.ts +5 -4
- package/s/logic/brain.ts +5 -3
- package/s/logic/group.ts +13 -10
- package/s/logic/presets/media/group.ts +5 -4
- package/s/logic/presets/media/library.test.ts +82 -0
- package/s/logic/presets/media/library.ts +249 -0
- package/s/logic/presets/media/schema.ts +10 -1
- package/s/logic/presets/opfs/schema.ts +1 -1
- package/s/logic/presets/plain/group.ts +2 -2
- package/s/logic/presets/plain/schema.ts +1 -1
- package/s/logic/types.ts +1 -1
- package/s/tests.test.ts +2 -0
- package/x/cellar/cask.d.ts +2 -4
- package/x/cellar/cask.js +3 -12
- package/x/cellar/cask.js.map +1 -1
- package/x/cellar/cellar.d.ts +9 -7
- package/x/cellar/cellar.js +17 -22
- package/x/cellar/cellar.js.map +1 -1
- package/x/cellar/cellar.test.d.ts +2 -3
- package/x/cellar/cellar.test.js +24 -39
- package/x/cellar/cellar.test.js.map +1 -1
- package/x/cellar/index.d.ts +0 -3
- package/x/cellar/index.js +0 -3
- package/x/cellar/index.js.map +1 -1
- package/x/cellar/mammoth.d.ts +23 -0
- package/x/cellar/mammoth.js +34 -0
- package/x/cellar/mammoth.js.map +1 -0
- package/x/cellar/mammoth.worker.bundle.d.ts +1 -0
- package/x/cellar/mammoth.worker.bundle.js +20 -0
- package/x/cellar/mammoth.worker.bundle.js.map +1 -0
- package/x/cellar/mammoth.worker.bundle.min.js +8 -0
- package/x/cellar/mammoth.worker.bundle.min.js.map +7 -0
- package/x/demo/main.bundle.js +2 -2
- package/x/demo/main.bundle.js.map +1 -1
- package/x/demo/main.bundle.min.js +229 -197
- package/x/demo/main.bundle.min.js.LEGAL.txt +33 -0
- package/x/demo/main.bundle.min.js.map +7 -0
- package/x/dom/components/breadcrumb/component.d.ts +1 -1
- package/x/dom/components/breadcrumb/component.js +6 -5
- package/x/dom/components/breadcrumb/component.js.map +1 -1
- package/x/dom/components/browser/component.d.ts +1 -1
- package/x/dom/components/browser/component.js +10 -7
- package/x/dom/components/browser/component.js.map +1 -1
- package/x/dom/components/browser/style.css.d.ts +1 -1
- package/x/dom/components/browser/style.css.js +31 -30
- package/x/dom/components/browser/style.css.js.map +1 -1
- package/x/dom/components/dropzone/component.d.ts +1 -1
- package/x/dom/components/dropzone/component.js +6 -4
- package/x/dom/components/dropzone/component.js.map +1 -1
- package/x/dom/components/dropzone/style.css.d.ts +1 -1
- package/x/dom/components/dropzone/style.css.js +12 -12
- package/x/dom/components/dropzone/style.css.js.map +1 -1
- package/x/dom/components/filter/component.d.ts +1 -1
- package/x/dom/components/filter/component.js +10 -8
- package/x/dom/components/filter/component.js.map +1 -1
- package/x/dom/components/outliner/component.d.ts +1 -1
- package/x/dom/components/outliner/component.js +7 -5
- package/x/dom/components/outliner/component.js.map +1 -1
- package/x/dom/components/searchbar/component.d.ts +1 -1
- package/x/dom/components/searchbar/component.js +12 -6
- package/x/dom/components/searchbar/component.js.map +1 -1
- package/x/dom/components/sort/component.d.ts +1 -1
- package/x/dom/components/sort/component.js +11 -9
- package/x/dom/components/sort/component.js.map +1 -1
- package/x/dom/components.d.ts +7 -7
- package/x/dom/theme.css.d.ts +1 -1
- package/x/dom/theme.css.js +65 -1
- package/x/dom/theme.css.js.map +1 -1
- package/x/dom/views/tree/component.d.ts +1 -6
- package/x/dom/views/tree/component.js +10 -8
- package/x/dom/views/tree/component.js.map +1 -1
- package/x/dom/views/tree/style.css.d.ts +1 -1
- package/x/dom/views/tree/style.css.js +10 -10
- package/x/dom/views/tree/style.css.js.map +1 -1
- package/x/genius/parts/hy-tree.js +9 -8
- package/x/genius/parts/hy-tree.js.map +1 -1
- package/x/index.d.ts +6 -1
- package/x/index.html +70 -167
- package/x/index.html.d.ts +1 -2
- package/x/index.html.js +67 -73
- package/x/index.html.js.map +1 -1
- package/x/index.js +6 -1
- package/x/index.js.map +1 -1
- package/x/logic/aspects/codex/codex.js +3 -3
- package/x/logic/aspects/codex/codex.js.map +1 -1
- package/x/logic/aspects/codex/example.d.ts +0 -1
- package/x/logic/aspects/codex/example.js +1 -3
- package/x/logic/aspects/codex/example.js.map +1 -1
- package/x/logic/aspects/codex/parts/codex-item.d.ts +1 -1
- package/x/logic/aspects/codex/parts/codex-item.js +4 -4
- package/x/logic/aspects/codex/parts/codex-item.js.map +1 -1
- package/x/logic/aspects/codex/parts/hierarchy.js +1 -0
- package/x/logic/aspects/codex/parts/hierarchy.js.map +1 -1
- package/x/logic/aspects/codex/parts/types.d.ts +1 -1
- package/x/logic/aspects/dropzone.d.ts +1 -1
- package/x/logic/aspects/dropzone.js +19 -15
- package/x/logic/aspects/dropzone.js.map +1 -1
- package/x/logic/aspects/tree-trail.d.ts +1 -1
- package/x/logic/aspects/tree-trail.js +4 -4
- package/x/logic/aspects/tree-trail.js.map +1 -1
- package/x/logic/brain.d.ts +4 -2
- package/x/logic/brain.js.map +1 -1
- package/x/logic/group.d.ts +7 -7
- package/x/logic/group.js +10 -8
- package/x/logic/group.js.map +1 -1
- package/x/logic/presets/media/group.d.ts +1 -1
- package/x/logic/presets/media/group.js +3 -3
- package/x/logic/presets/media/group.js.map +1 -1
- package/x/logic/presets/media/library.d.ts +59 -0
- package/x/logic/presets/media/library.js +201 -0
- package/x/logic/presets/media/library.js.map +1 -0
- package/x/logic/presets/media/library.test.d.ts +9 -0
- package/x/logic/presets/media/library.test.js +62 -0
- package/x/logic/presets/media/library.test.js.map +1 -0
- package/x/logic/presets/media/schema.d.ts +10 -1
- package/x/logic/presets/opfs/schema.d.ts +1 -1
- package/x/logic/presets/plain/group.js +2 -2
- package/x/logic/presets/plain/group.js.map +1 -1
- package/x/logic/presets/plain/schema.d.ts +1 -1
- package/x/logic/types.d.ts +1 -1
- package/x/tests.test.js +2 -0
- package/x/tests.test.js.map +1 -1
- package/s/cellar/forklift.ts +0 -9
- package/s/cellar/memory-forklift.ts +0 -29
- package/s/cellar/opfs-forklift.ts +0 -49
- package/x/cellar/forklift.d.ts +0 -7
- package/x/cellar/forklift.js +0 -2
- package/x/cellar/forklift.js.map +0 -1
- package/x/cellar/memory-forklift.d.ts +0 -9
- package/x/cellar/memory-forklift.js +0 -21
- package/x/cellar/memory-forklift.js.map +0 -1
- package/x/cellar/opfs-forklift.d.ts +0 -11
- package/x/cellar/opfs-forklift.js +0 -44
- package/x/cellar/opfs-forklift.js.map +0 -1
- package/x/genius/folio.md +0 -29
- package/x/genius/quay.md +0 -13
- package/x/genius/turbo.md +0 -21
- package/x/importmap.json +0 -47
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
|
|
2
|
-
import "@benev/slate/x/node.js"
|
|
3
|
-
import {repeat_string} from "@benev/turtle"
|
|
4
|
-
|
|
5
2
|
import {Codex} from "./codex.js"
|
|
6
3
|
import {AsSchema, Taxon} from "./parts/types.js"
|
|
7
4
|
|
|
@@ -48,7 +45,7 @@ src
|
|
|
48
45
|
// we can crawl any folder!
|
|
49
46
|
// 👇
|
|
50
47
|
for (const [item, ancestors] of folder.crawl()) {
|
|
51
|
-
const indent =
|
|
48
|
+
const indent = " ".repeat(ancestors.length)
|
|
52
49
|
const specimen = item.specimen
|
|
53
50
|
const label = item.kind === "folder"
|
|
54
51
|
? specimen.label + "/"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import {signal} from "@
|
|
2
|
+
import {signal} from "@e280/strata"
|
|
3
3
|
import {Codex} from "../codex.js"
|
|
4
4
|
import {Id, Kind, Schema} from "./types.js"
|
|
5
5
|
|
|
@@ -52,14 +52,14 @@ export class CodexItem<Sc extends Schema = any, K extends Kind<Sc> = Kind<Sc>> {
|
|
|
52
52
|
/** add items as children */
|
|
53
53
|
attach(...items: CodexItem<Sc>[]) {
|
|
54
54
|
this.codex.hierarchy.attach(this.id, ...items.map(i => i.id))
|
|
55
|
-
this.signal
|
|
55
|
+
this.signal(this)
|
|
56
56
|
return this
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
/** detach this item from the hierarchy completely */
|
|
60
60
|
detach() {
|
|
61
61
|
this.codex.hierarchy.detach(this.id)
|
|
62
|
-
this.signal
|
|
62
|
+
this.signal(this)
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/** create a new item that will be a child */
|
|
@@ -72,7 +72,7 @@ export class CodexItem<Sc extends Schema = any, K extends Kind<Sc> = Kind<Sc>> {
|
|
|
72
72
|
/** create a new item that will be a child */
|
|
73
73
|
destroy() {
|
|
74
74
|
this.codex.hierarchy.destroy(this.id)
|
|
75
|
-
this.signal
|
|
75
|
+
this.signal(this)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/** iterate over this item and all its descendants */
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {loot} from "@e280/sly/loot"
|
|
2
|
+
import {signal} from "@e280/strata"
|
|
2
3
|
|
|
3
4
|
import {Group} from "../group.js"
|
|
4
5
|
import {CodexItem} from "./codex/parts/codex-item.js"
|
|
5
6
|
|
|
6
7
|
export class Dropzone {
|
|
7
|
-
#drag_drop = new
|
|
8
|
-
|
|
8
|
+
#drag_drop = new loot.DragAndDrops<CodexItem, CodexItem>({
|
|
9
|
+
acceptDrop: (_e, grabbed, hovering) => this.group.move(grabbed, hovering)
|
|
9
10
|
})
|
|
10
11
|
|
|
11
12
|
constructor(private group: Group) {}
|
|
@@ -14,37 +15,40 @@ export class Dropzone {
|
|
|
14
15
|
#hovering = signal<CodexItem | undefined>(undefined)
|
|
15
16
|
|
|
16
17
|
get grabbed() {
|
|
17
|
-
return this.#drag_drop.
|
|
18
|
+
return this.#drag_drop.dragging
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
get hovering() {
|
|
21
|
-
return this.#drag_drop.hovering ?? this.#hovering
|
|
22
|
+
return this.#drag_drop.hovering ?? this.#hovering()
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
dragenter = (e: DragEvent, target
|
|
25
|
+
dragenter = (e: DragEvent, target: CodexItem) => {
|
|
25
26
|
e.preventDefault()
|
|
26
|
-
this.#drag_drop.dropzone.dragenter(
|
|
27
|
-
this.#hovering
|
|
27
|
+
this.#drag_drop.dropzone(() => target).dragenter(e)
|
|
28
|
+
this.#hovering(target)
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
dragleave = (e: DragEvent) => {
|
|
31
|
-
this
|
|
32
|
-
|
|
32
|
+
const hovering = this.hovering
|
|
33
|
+
if (hovering)
|
|
34
|
+
this.#drag_drop.dropzone(() => hovering).dragleave(e)
|
|
35
|
+
this.#hovering(undefined)
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
dragstart = (e: DragEvent, n: CodexItem) => {
|
|
36
39
|
e.stopPropagation()
|
|
37
|
-
this.#drag_drop.dragzone
|
|
40
|
+
this.#drag_drop.dragzone(() => n).dragstart(e)
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
dragover = (e: DragEvent, n: CodexItem) => {
|
|
41
44
|
e.preventDefault()
|
|
42
|
-
this.#drag_drop.dropzone
|
|
45
|
+
this.#drag_drop.dropzone(() => n).dragover(e)
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
dragend = (e: DragEvent) => {
|
|
46
|
-
this.#drag_drop
|
|
47
|
-
this.#
|
|
49
|
+
this.#drag_drop.$draggy(undefined)
|
|
50
|
+
this.#drag_drop.$droppy(undefined)
|
|
51
|
+
this.#hovering(undefined)
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
drop = (e: DragEvent, target: CodexItem) => {
|
|
@@ -60,9 +64,9 @@ export class Dropzone {
|
|
|
60
64
|
const parent = this.grabbed?.parent?.id === this.hovering?.id
|
|
61
65
|
|
|
62
66
|
if(!same && !child && !parent)
|
|
63
|
-
this.#drag_drop.dropzone
|
|
67
|
+
this.#drag_drop.dropzone(() => target).drop(e)
|
|
64
68
|
|
|
65
|
-
this.#hovering
|
|
69
|
+
this.#hovering(undefined)
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
change = (e: DragEvent, targetFolder: CodexItem) => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
|
|
2
|
+
import {signal} from "@e280/strata"
|
|
2
3
|
import {Schema} from "./codex/parts/types.js"
|
|
3
4
|
import {CodexItem} from "./codex/parts/codex-item.js"
|
|
4
5
|
|
|
@@ -6,7 +7,7 @@ export class TreeTrail<Sc extends Schema> {
|
|
|
6
7
|
readonly signal = signal<CodexItem<Sc>[]>([])
|
|
7
8
|
|
|
8
9
|
constructor(root: CodexItem<Sc>) {
|
|
9
|
-
this.signal
|
|
10
|
+
this.signal([root])
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
setTrail(e: Event, item: CodexItem<Sc>) {
|
|
@@ -23,11 +24,11 @@ export class TreeTrail<Sc extends Schema> {
|
|
|
23
24
|
cur = cur.parent
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
this.signal
|
|
27
|
+
this.signal(trail)
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
get currentFolder() {
|
|
30
|
-
return this.signal.
|
|
31
|
+
return this.signal().at(-1)?.children ?? []
|
|
31
32
|
}
|
|
32
33
|
}
|
|
33
34
|
|
package/s/logic/brain.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import {MapG} from "@e280/stz"
|
|
3
3
|
import {Group} from "./group.js"
|
|
4
4
|
|
|
5
|
+
type RegisteredGroup<G> = G extends Group<infer _Schema> ? G : never
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Quay's global state
|
|
7
9
|
* - manages groups
|
|
@@ -10,13 +12,13 @@ import {Group} from "./group.js"
|
|
|
10
12
|
export class Brain {
|
|
11
13
|
#groups = new MapG<string, Group>()
|
|
12
14
|
|
|
13
|
-
setGroup<G
|
|
15
|
+
setGroup<G>(name: string, group: RegisteredGroup<G>) {
|
|
14
16
|
this.#groups.set(name, group)
|
|
15
17
|
return group
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
getGroup<G
|
|
19
|
-
return this.#groups.require(name) as G
|
|
20
|
+
getGroup<G = Group>(name: string) {
|
|
21
|
+
return this.#groups.require(name) as RegisteredGroup<G>
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
|
package/s/logic/group.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {sub} from "@e280/stz"
|
|
2
|
-
import {signal} from "@
|
|
2
|
+
import {signal} from "@e280/strata"
|
|
3
3
|
|
|
4
4
|
import {Permission} from "./permissions.js"
|
|
5
5
|
import {Dropzone} from "./aspects/dropzone.js"
|
|
@@ -18,7 +18,7 @@ export class Group<Sc extends Schema = any> {
|
|
|
18
18
|
on = {
|
|
19
19
|
newFolder: sub<[{parent: CodexItem<Sc>}]>(),
|
|
20
20
|
move: sub<[{item: CodexItem<Sc>, target: CodexItem<Sc>}]>(),
|
|
21
|
-
delete: sub<[{
|
|
21
|
+
delete: sub<[{items: CodexItem<Sc>[]}]>(),
|
|
22
22
|
rename: sub<[{item: CodexItem<Sc>, newName: string}]>(),
|
|
23
23
|
upload: sub<[{files: File[], target: CodexItem<Sc>}]>(),
|
|
24
24
|
search: sub<[{terms: string[]}]>(),
|
|
@@ -26,13 +26,13 @@ export class Group<Sc extends Schema = any> {
|
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
constructor(public config: GroupConfig<Sc>) {
|
|
29
|
-
this.selectedFilter
|
|
30
|
-
this.selectedSort
|
|
29
|
+
this.selectedFilter(config.defaultFilter)
|
|
30
|
+
this.selectedSort(config.defaultSort)
|
|
31
31
|
this.trail = new TreeTrail(config.root)
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
get permissions() {
|
|
35
|
-
return this.config.permissions as (item: CodexItem) => Permission
|
|
35
|
+
return this.config.permissions as (item: CodexItem<Sc>) => Permission
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
getFilterFn(filterKey: string): SearchFn<Sc> {
|
|
@@ -49,13 +49,13 @@ export class Group<Sc extends Schema = any> {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
sort(items: CodexItem<Sc>[]): CodexItem<Sc>[] {
|
|
52
|
-
const sortFn = this.getSortFn(this.selectedSort
|
|
52
|
+
const sortFn = this.getSortFn(this.selectedSort())
|
|
53
53
|
return [...items].sort(sortFn)
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
matches(item: CodexItem<Sc>) {
|
|
57
|
-
const filterFn = this.getFilterFn(this.selectedFilter
|
|
58
|
-
const searchFn = this.getSearchFn(this.searchText
|
|
57
|
+
const filterFn = this.getFilterFn(this.selectedFilter())
|
|
58
|
+
const searchFn = this.getSearchFn(this.searchText())
|
|
59
59
|
return filterFn(item) && searchFn(item)
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -73,9 +73,11 @@ export class Group<Sc extends Schema = any> {
|
|
|
73
73
|
if(!this.permissions(item).delete)
|
|
74
74
|
throw new Error("delete permission not granted")
|
|
75
75
|
|
|
76
|
+
const items = [...item.crawl()].map(([item]) => item)
|
|
77
|
+
|
|
76
78
|
item.destroy()
|
|
77
79
|
|
|
78
|
-
this.on.delete.pub({
|
|
80
|
+
return this.on.delete.pub({items})
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
rename(item: CodexItem<Sc>, newName: string) {
|
|
@@ -89,7 +91,8 @@ export class Group<Sc extends Schema = any> {
|
|
|
89
91
|
if(!this.permissions(folder).upload)
|
|
90
92
|
throw new Error("upload permission not granted")
|
|
91
93
|
|
|
92
|
-
this.on.upload.pub({files, target: folder})
|
|
94
|
+
return this.on.upload.pub({files, target: folder})
|
|
95
|
+
.then(() => this.on.refresh.pub({}))
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
addFolder(parent: CodexItem<Sc>) {
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
|
|
2
|
+
import {html} from "lit"
|
|
2
3
|
import {MapG} from "@e280/stz"
|
|
3
|
-
import {Content
|
|
4
|
+
import {Content} from "@e280/sly"
|
|
4
5
|
|
|
5
|
-
import Quay from "../../../index.js"
|
|
6
6
|
import {Group} from "../../group.js"
|
|
7
|
+
import {Permissions} from "../../permissions.js"
|
|
7
8
|
import {Codex} from "../../aspects/codex/codex.js"
|
|
8
9
|
import {MediaFormat, MediaSchema} from "./schema.js"
|
|
9
10
|
import {GroupConfig, SearchFn, SortFn} from "../../types.js"
|
|
10
11
|
import {CodexItem} from "../../aspects/codex/parts/codex-item.js"
|
|
11
12
|
|
|
12
|
-
export class MediaGroup extends Group<MediaSchema>
|
|
13
|
+
export class MediaGroup extends Group<MediaSchema> {
|
|
13
14
|
static config = (): GroupConfig<MediaSchema> => {
|
|
14
15
|
const formatIcons = new MapG<MediaFormat, Content>()
|
|
15
16
|
.set("audio", html`<sl-icon name=music-note-beamed></sl-icon>`)
|
|
@@ -83,7 +84,7 @@ export class MediaGroup extends Group<MediaSchema> implements Group {
|
|
|
83
84
|
// // TODO icons
|
|
84
85
|
// : html`<sl-icon name=${item.taxon.icon}></sl-icon>`
|
|
85
86
|
},
|
|
86
|
-
permissions: item =>
|
|
87
|
+
permissions: item => Permissions.all,
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
|
|
2
|
+
import {Txt} from "@e280/stz"
|
|
3
|
+
import {expect, Science, test} from "@e280/science"
|
|
4
|
+
|
|
5
|
+
import {MediaLibrary} from "./library.js"
|
|
6
|
+
import {Permissions} from "../../permissions.js"
|
|
7
|
+
|
|
8
|
+
delete (globalThis as any).localStorage
|
|
9
|
+
|
|
10
|
+
export default Science.suite({
|
|
11
|
+
"uploads files into cellar and index": test(async() => {
|
|
12
|
+
const group = new MediaLibrary()
|
|
13
|
+
await group.upload([file("hello.txt", "hello")], group.config.root)
|
|
14
|
+
|
|
15
|
+
const record = await recordByLabel(group, "hello.txt")
|
|
16
|
+
const item = group.findByHash(record.hash)!
|
|
17
|
+
|
|
18
|
+
expect(item.specimen.label).is("hello.txt")
|
|
19
|
+
expect(await group.cellar.has(record.hash)).is(true)
|
|
20
|
+
}),
|
|
21
|
+
|
|
22
|
+
"upload respects upload permission": test(async() => {
|
|
23
|
+
const group = new MediaLibrary()
|
|
24
|
+
const file = new File([Txt.toBytes("hello")], "hello.txt", {type: "text/plain"})
|
|
25
|
+
group.config.permissions = () => Permissions.readOnly
|
|
26
|
+
|
|
27
|
+
expect(() => group.upload([file], group.config.root)).throws()
|
|
28
|
+
}),
|
|
29
|
+
|
|
30
|
+
"lists media records": test(async() => {
|
|
31
|
+
const store = new MediaLibrary()
|
|
32
|
+
await store.upload([file("image.png", "image", "image/png")], store.config.root)
|
|
33
|
+
|
|
34
|
+
expect(await recordByLabel(store, "image.png")).ok()
|
|
35
|
+
}),
|
|
36
|
+
|
|
37
|
+
"includes existing media in a scope": test(async() => {
|
|
38
|
+
const library = new MediaLibrary()
|
|
39
|
+
await library.upload([file("include.txt", "include")], library.config.root)
|
|
40
|
+
const record = await recordByLabel(library, "include.txt")
|
|
41
|
+
const project = await library.scope("include-project")
|
|
42
|
+
|
|
43
|
+
await project.include(record.hash)
|
|
44
|
+
|
|
45
|
+
expect(project.findByHash(record.hash)).ok()
|
|
46
|
+
expect(await recordByLabel(project, "include.txt")).ok()
|
|
47
|
+
expect(await project.cellar.has(record.hash)).is(true)
|
|
48
|
+
await expect(() => project.include("missing")).throwsAsync()
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
"root deletion removes media records and resources": test(async() => {
|
|
52
|
+
const store = new MediaLibrary()
|
|
53
|
+
await store.upload([file("delete.png", "delete-image", "image/png")], store.config.root)
|
|
54
|
+
|
|
55
|
+
const record = await recordByLabel(store, "delete.png")
|
|
56
|
+
const item = store.findByHash(record.hash)!
|
|
57
|
+
|
|
58
|
+
await store.delete(item)
|
|
59
|
+
|
|
60
|
+
expect(store.findByHash(record.hash)).is(undefined)
|
|
61
|
+
expect(await store.cellar.has(record.hash)).is(false)
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
function file(name: string, text: string, type = "text/plain") {
|
|
67
|
+
return new File([Txt.toBytes(text)], name, {type})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function records(store: MediaLibrary) {
|
|
71
|
+
const records = []
|
|
72
|
+
for await (const record of store.records())
|
|
73
|
+
records.push(record)
|
|
74
|
+
return records
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function recordByLabel(store: MediaLibrary, label: string) {
|
|
78
|
+
const record = (await records(store)).find(r => r.label === label)
|
|
79
|
+
if (!record)
|
|
80
|
+
throw new Error(`expected record "${label}"`)
|
|
81
|
+
return record
|
|
82
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
|
|
2
|
+
import {collect, sub} from "@e280/stz"
|
|
3
|
+
import {Kv, StorageMagazine} from "@e280/kv"
|
|
4
|
+
|
|
5
|
+
import {MediaGroup} from "./group.js"
|
|
6
|
+
import {Cellar} from "../../../cellar/cellar.js"
|
|
7
|
+
import {MediaFormat, MediaProgress, MediaSchema} from "./schema.js"
|
|
8
|
+
import {CodexItem} from "../../aspects/codex/parts/codex-item.js"
|
|
9
|
+
|
|
10
|
+
export type MediaRecord = {
|
|
11
|
+
hash: string
|
|
12
|
+
label: string
|
|
13
|
+
format: MediaFormat
|
|
14
|
+
mime: string
|
|
15
|
+
size: number
|
|
16
|
+
createdAt: number
|
|
17
|
+
updatedAt: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class MediaLibrary extends MediaGroup {
|
|
21
|
+
static async open(scope = "default") {
|
|
22
|
+
const group = new this()
|
|
23
|
+
group.cellar = await Cellar.opfs("media")
|
|
24
|
+
group.#index = mediaIndex(scope)
|
|
25
|
+
group.#lineage = [group.#index]
|
|
26
|
+
await group.#load()
|
|
27
|
+
return group
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#index
|
|
31
|
+
#lineage
|
|
32
|
+
#root = this
|
|
33
|
+
#objectUrls = new Map<string, string>()
|
|
34
|
+
|
|
35
|
+
progress = sub<[MediaProgress]>()
|
|
36
|
+
cellar = new Cellar()
|
|
37
|
+
|
|
38
|
+
constructor() {
|
|
39
|
+
super()
|
|
40
|
+
this.#index = mediaIndex("default")
|
|
41
|
+
this.#lineage = [this.#index]
|
|
42
|
+
this.on.upload.sub(({files, target}) => {
|
|
43
|
+
return this.#upload(files, target)
|
|
44
|
+
})
|
|
45
|
+
this.on.delete.sub(({items}) => {
|
|
46
|
+
const hashes = this.#hashes(items)
|
|
47
|
+
return this.#delete(hashes)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get #isRoot() {
|
|
52
|
+
return this === this.#root
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async scope(name: string) {
|
|
56
|
+
const child = new MediaLibrary()
|
|
57
|
+
child.cellar = this.cellar
|
|
58
|
+
child.#index = this.#index.scope<MediaRecord>(name)
|
|
59
|
+
child.#lineage = [...this.#lineage, child.#index]
|
|
60
|
+
child.#root = this.#root
|
|
61
|
+
await child.#load()
|
|
62
|
+
return child
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async *records() {
|
|
66
|
+
for await (const [, record] of this.#index.entries())
|
|
67
|
+
yield record
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async include(hash: string) {
|
|
71
|
+
const record = await this.#root.#index.need(hash)
|
|
72
|
+
await this.#index.commit(this.#lineage.map(index => index.op.set(hash, record)))
|
|
73
|
+
if (record.format === "image" && !this.#objectUrls.has(hash))
|
|
74
|
+
await this.#loadPreview(record)
|
|
75
|
+
this.#attachRecord(record)
|
|
76
|
+
this.on.refresh.pub({})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async #load() {
|
|
80
|
+
for await (const record of this.records()) {
|
|
81
|
+
if (record.format === "image" && !this.#objectUrls.has(record.hash))
|
|
82
|
+
await this.#loadPreview(record)
|
|
83
|
+
this.#attachRecord(record, this.config.root)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async #upload(files: File[], parent: CodexItem<MediaSchema>) {
|
|
88
|
+
await Promise.all(files.map(file => this.#storeFile(file, parent)))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async #storeFile(file: File, parent: CodexItem<MediaSchema>) {
|
|
92
|
+
const item = this.#attachPending(file, parent)
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const hash = await this.cellar.write(trackProgress(file.stream(), loaded =>
|
|
96
|
+
this.progress.pub({item, loaded, total: file.size})
|
|
97
|
+
))
|
|
98
|
+
const record = await this.#saveRecord(file, hash)
|
|
99
|
+
item.destroy()
|
|
100
|
+
this.#attachRecord(record, parent)
|
|
101
|
+
return record
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
item.destroy()
|
|
105
|
+
this.on.refresh.pub({})
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
#attachPending(file: File, parent: CodexItem<MediaSchema>) {
|
|
111
|
+
const item = this.config.codex.create("file", {
|
|
112
|
+
label: file.name,
|
|
113
|
+
format: mediaFormat(file.type),
|
|
114
|
+
mime: file.type,
|
|
115
|
+
size: file.size,
|
|
116
|
+
previewUrl: null,
|
|
117
|
+
})
|
|
118
|
+
parent.attach(item)
|
|
119
|
+
this.on.refresh.pub({})
|
|
120
|
+
return item
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async #saveRecord(file: File, hash: string) {
|
|
124
|
+
const existing = await this.#index.get(hash) ?? await this.#root.#index.get(hash)
|
|
125
|
+
const now = Date.now()
|
|
126
|
+
const record: MediaRecord = {
|
|
127
|
+
hash,
|
|
128
|
+
label: existing?.label ?? file.name,
|
|
129
|
+
format: mediaFormat(file.type),
|
|
130
|
+
mime: file.type,
|
|
131
|
+
size: file.size,
|
|
132
|
+
createdAt: existing?.createdAt ?? now,
|
|
133
|
+
updatedAt: now,
|
|
134
|
+
}
|
|
135
|
+
await this.#index.commit(this.#lineage.map(index => index.op.set(hash, record)))
|
|
136
|
+
if (record.format === "image")
|
|
137
|
+
this.#setPreview(hash, URL.createObjectURL(file))
|
|
138
|
+
return record
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#hashes(items: CodexItem<MediaSchema>[]) {
|
|
142
|
+
return items
|
|
143
|
+
.map(item => item.isKind("file") ? item.specimen.hash : undefined)
|
|
144
|
+
.filter((hash): hash is string => !!hash)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async #delete(hashes: string[]) {
|
|
148
|
+
const index = this.#index.crush()
|
|
149
|
+
const keys = (await collect(index.entries()))
|
|
150
|
+
.filter(([, record]) => hashes.includes(record.hash))
|
|
151
|
+
.map(([key]) => key)
|
|
152
|
+
await index.commit(keys.map(key => index.op.delete(key)))
|
|
153
|
+
|
|
154
|
+
for (const hash of hashes) {
|
|
155
|
+
this.#revokePreview(hash)
|
|
156
|
+
if (this.#isRoot)
|
|
157
|
+
await this.cellar.delete(hash)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
findByHash(hash: string) {
|
|
162
|
+
for (const [item] of this.config.root.crawl()) {
|
|
163
|
+
if (item.isKind("file") && item.specimen.hash === hash)
|
|
164
|
+
return item
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
dispose() {
|
|
169
|
+
for (const url of this.#objectUrls.values())
|
|
170
|
+
URL.revokeObjectURL(url)
|
|
171
|
+
this.#objectUrls.clear()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#attachRecord(record: MediaRecord, parent = this.config.root) {
|
|
175
|
+
const existing = this.findByHash(record.hash)
|
|
176
|
+
if (existing)
|
|
177
|
+
return existing
|
|
178
|
+
|
|
179
|
+
const item = this.config.codex.create("file", {
|
|
180
|
+
hash: record.hash,
|
|
181
|
+
label: record.label,
|
|
182
|
+
format: record.format,
|
|
183
|
+
mime: record.mime,
|
|
184
|
+
size: record.size,
|
|
185
|
+
previewUrl: this.#objectUrls.get(record.hash) ?? null,
|
|
186
|
+
})
|
|
187
|
+
parent.attach(item)
|
|
188
|
+
return item
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async #loadPreview(record: MediaRecord) {
|
|
192
|
+
const cask = await this.cellar.load(record.hash)
|
|
193
|
+
const blob = new Blob([cask.file], {type: record.mime || "image/*"})
|
|
194
|
+
return this.#setPreview(record.hash, URL.createObjectURL(blob))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#setPreview(hash: string, url: string) {
|
|
198
|
+
this.#revokePreview(hash)
|
|
199
|
+
this.#objectUrls.set(hash, url)
|
|
200
|
+
return url
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
#revokePreview(hash: string) {
|
|
204
|
+
const url = this.#objectUrls.get(hash)
|
|
205
|
+
if (url) {
|
|
206
|
+
URL.revokeObjectURL(url)
|
|
207
|
+
this.#objectUrls.delete(hash)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const mediaFormats = new Set(["video", "image", "audio"])
|
|
213
|
+
const memoryIndexes = new Map<string, Kv<MediaRecord>>()
|
|
214
|
+
|
|
215
|
+
function mediaFormat(mime: string): MediaFormat {
|
|
216
|
+
const [type] = mime.split("/")
|
|
217
|
+
return mediaFormats.has(type) ? type as MediaFormat : "other"
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function mediaIndex(scope: string) {
|
|
221
|
+
const storage = globalThis.localStorage
|
|
222
|
+
if (storage)
|
|
223
|
+
return new Kv<MediaRecord>(new StorageMagazine(storage))
|
|
224
|
+
.scope("quay")
|
|
225
|
+
.scope("media")
|
|
226
|
+
.scope<MediaRecord>(scope)
|
|
227
|
+
|
|
228
|
+
const existing = memoryIndexes.get(scope)
|
|
229
|
+
if (existing)
|
|
230
|
+
return existing
|
|
231
|
+
|
|
232
|
+
const index = new Kv<MediaRecord>()
|
|
233
|
+
memoryIndexes.set(scope, index)
|
|
234
|
+
return index
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function trackProgress(
|
|
238
|
+
readable: ReadableStream<Uint8Array>,
|
|
239
|
+
onProgress: (loaded: number) => void,
|
|
240
|
+
) {
|
|
241
|
+
let loaded = 0
|
|
242
|
+
return readable.pipeThrough(new TransformStream({
|
|
243
|
+
transform(chunk, controller) {
|
|
244
|
+
loaded += chunk.byteLength
|
|
245
|
+
onProgress(loaded)
|
|
246
|
+
controller.enqueue(chunk)
|
|
247
|
+
},
|
|
248
|
+
}))
|
|
249
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
|
|
2
|
-
import {Content} from "@
|
|
2
|
+
import {Content} from "@e280/sly"
|
|
3
3
|
import {AsSchema} from "../../aspects/codex/parts/types.js"
|
|
4
|
+
import {CodexItem} from "../../aspects/codex/parts/codex-item.js"
|
|
4
5
|
|
|
5
6
|
export type MediaFormat = "video" | "image" | "audio" | "other"
|
|
6
7
|
|
|
@@ -16,7 +17,15 @@ export type MediaSchema = AsSchema<{
|
|
|
16
17
|
label: string
|
|
17
18
|
format: MediaFormat
|
|
18
19
|
previewUrl: string | null
|
|
20
|
+
hash?: string
|
|
21
|
+
mime?: string
|
|
22
|
+
size?: number
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
}>
|
|
22
26
|
|
|
27
|
+
export type MediaProgress = {
|
|
28
|
+
item: CodexItem<MediaSchema>
|
|
29
|
+
loaded: number
|
|
30
|
+
total: number
|
|
31
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
|
|
2
|
-
import Quay from "../../../index.js"
|
|
3
2
|
import {Group} from "../../group.js"
|
|
4
3
|
import {PlainSchema} from "./schema.js"
|
|
4
|
+
import {Permissions} from "../../permissions.js"
|
|
5
5
|
import {GroupConfig, SortFn} from "../../types.js"
|
|
6
6
|
import {Codex} from "../../aspects/codex/codex.js"
|
|
7
7
|
|
|
@@ -33,7 +33,7 @@ export class PlainGroup extends Group<PlainSchema> {
|
|
|
33
33
|
renderLabel: item => item.id,
|
|
34
34
|
renderIcon: item => item.taxon.icon,
|
|
35
35
|
renderPreview: () => null,
|
|
36
|
-
permissions: item =>
|
|
36
|
+
permissions: item => Permissions.all,
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|