@revenexx/studio-shared 0.2.0 → 0.3.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.
Files changed (73) hide show
  1. package/dist/module.d.mts +6 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +32 -1
  4. package/dist/runtime/components/DataTable/ColumnsMenu.vue +3 -2
  5. package/dist/runtime/components/DataTable/DataTable.d.vue.ts +32 -4
  6. package/dist/runtime/components/DataTable/DataTable.vue +91 -34
  7. package/dist/runtime/components/DataTable/DataTable.vue.d.ts +32 -4
  8. package/dist/runtime/components/DataTable/FilterMenu.d.vue.ts +12 -3
  9. package/dist/runtime/components/DataTable/FilterMenu.vue +20 -10
  10. package/dist/runtime/components/DataTable/FilterMenu.vue.d.ts +12 -3
  11. package/dist/runtime/components/DataTable/SavedViewsMenu.d.vue.ts +6 -0
  12. package/dist/runtime/components/DataTable/SavedViewsMenu.vue +189 -0
  13. package/dist/runtime/components/DataTable/SavedViewsMenu.vue.d.ts +6 -0
  14. package/dist/runtime/components/DataTable/SortMenu.d.vue.ts +13 -0
  15. package/dist/runtime/components/DataTable/SortMenu.vue +47 -0
  16. package/dist/runtime/components/DataTable/SortMenu.vue.d.ts +13 -0
  17. package/dist/runtime/components/DataTable/ViewModeSwitch.d.vue.ts +20 -0
  18. package/dist/runtime/components/DataTable/ViewModeSwitch.vue +26 -0
  19. package/dist/runtime/components/DataTable/ViewModeSwitch.vue.d.ts +20 -0
  20. package/dist/runtime/components/DataTable/types.d.ts +34 -0
  21. package/dist/runtime/components/Media/MediaFolderTree.d.vue.ts +17 -0
  22. package/dist/runtime/components/Media/MediaFolderTree.vue +65 -0
  23. package/dist/runtime/components/Media/MediaFolderTree.vue.d.ts +17 -0
  24. package/dist/runtime/components/Media/MediaPickerDialog.d.vue.ts +18 -0
  25. package/dist/runtime/components/Media/MediaPickerDialog.vue +163 -0
  26. package/dist/runtime/components/Media/MediaPickerDialog.vue.d.ts +18 -0
  27. package/dist/runtime/components/Media/MediaUploader.d.vue.ts +29 -0
  28. package/dist/runtime/components/Media/MediaUploader.vue +161 -0
  29. package/dist/runtime/components/Media/MediaUploader.vue.d.ts +29 -0
  30. package/dist/runtime/components/Shared/BuildLogsFlyout.vue +14 -13
  31. package/dist/runtime/components/Shared/GitDeploymentSettings.vue +11 -10
  32. package/dist/runtime/components/Shared/ListSurface.d.vue.ts +38 -0
  33. package/dist/runtime/components/Shared/ListSurface.vue +27 -0
  34. package/dist/runtime/components/Shared/ListSurface.vue.d.ts +38 -0
  35. package/dist/runtime/components/Shared/RecordLayout.d.vue.ts +52 -0
  36. package/dist/runtime/components/Shared/RecordLayout.vue +63 -0
  37. package/dist/runtime/components/Shared/RecordLayout.vue.d.ts +52 -0
  38. package/dist/runtime/components/Shared/RecordRailCard.d.vue.ts +26 -0
  39. package/dist/runtime/components/Shared/RecordRailCard.vue +20 -0
  40. package/dist/runtime/components/Shared/RecordRailCard.vue.d.ts +26 -0
  41. package/dist/runtime/components/Shared/SidebarNav.d.vue.ts +5 -0
  42. package/dist/runtime/components/Shared/SidebarNav.vue +6 -26
  43. package/dist/runtime/components/Shared/SidebarNav.vue.d.ts +5 -0
  44. package/dist/runtime/components/Shared/SidebarNavLink.d.vue.ts +15 -0
  45. package/dist/runtime/components/Shared/SidebarNavLink.vue +49 -0
  46. package/dist/runtime/components/Shared/SidebarNavLink.vue.d.ts +15 -0
  47. package/dist/runtime/components/Shared/Wizard.vue +6 -3
  48. package/dist/runtime/composables/useListRail.d.ts +9 -0
  49. package/dist/runtime/composables/useListRail.js +19 -0
  50. package/dist/runtime/composables/useMediaLibrary.d.ts +41 -0
  51. package/dist/runtime/composables/useMediaLibrary.js +36 -0
  52. package/dist/runtime/composables/usePlatformAuth.d.ts +18 -0
  53. package/dist/runtime/composables/usePlatformTalkback.d.ts +29 -0
  54. package/dist/runtime/composables/usePlatformTalkback.js +54 -0
  55. package/dist/runtime/composables/useT.d.ts +11 -0
  56. package/dist/runtime/composables/useT.js +8 -0
  57. package/dist/runtime/composables/useTalkbackChannels.d.ts +78 -0
  58. package/dist/runtime/composables/useTalkbackChannels.js +52 -0
  59. package/dist/runtime/lib/formatBridge.d.ts +50 -0
  60. package/dist/runtime/lib/formatBridge.js +54 -0
  61. package/dist/runtime/lib/i18nBridge.d.ts +31 -0
  62. package/dist/runtime/lib/i18nBridge.js +17 -0
  63. package/dist/runtime/lib/isoLists.d.ts +61 -0
  64. package/dist/runtime/lib/isoLists.js +62 -0
  65. package/dist/runtime/lib/sidebarNav.d.ts +34 -0
  66. package/dist/runtime/lib/sidebarNav.js +22 -0
  67. package/dist/runtime/lib/talkback.d.ts +59 -0
  68. package/dist/runtime/lib/talkback.js +49 -0
  69. package/dist/runtime/types/nav.d.ts +15 -1
  70. package/dist/runtime/types/storage.d.ts +67 -0
  71. package/dist/runtime/types/storage.js +0 -0
  72. package/dist/types.d.mts +12 -0
  73. package/package.json +18 -2
@@ -0,0 +1,189 @@
1
+ <script setup>
2
+ import { AltArrowDown, Bookmark, TrashBinMinimalistic } from "#solar-icons";
3
+ import {
4
+ Button,
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogFooter,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ DropdownMenu,
12
+ DropdownMenuContent,
13
+ DropdownMenuItem,
14
+ DropdownMenuSeparator,
15
+ DropdownMenuTrigger,
16
+ Input
17
+ } from "@revenexx/studio";
18
+ const props = defineProps({
19
+ config: { type: Object, required: true }
20
+ });
21
+ const t = useT();
22
+ const views = ref([]);
23
+ function read() {
24
+ try {
25
+ if (typeof window === "undefined") return [];
26
+ const raw = window.localStorage.getItem(props.config.storageKey);
27
+ if (!raw) return [];
28
+ const parsed = JSON.parse(raw);
29
+ if (!Array.isArray(parsed)) return [];
30
+ return parsed.filter((v) => !!v && typeof v === "object" && typeof v.name === "string");
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+ function persist() {
36
+ try {
37
+ if (typeof window === "undefined") return;
38
+ window.localStorage.setItem(props.config.storageKey, JSON.stringify(views.value));
39
+ } catch {
40
+ }
41
+ }
42
+ onMounted(() => {
43
+ views.value = read();
44
+ });
45
+ watch(() => props.config.storageKey, () => {
46
+ views.value = read();
47
+ });
48
+ const allLabel = computed(() => props.config.allLabel ?? t("shared.datatable.savedviews.all.default"));
49
+ function same(a, b) {
50
+ return JSON.stringify(a) === JSON.stringify(b);
51
+ }
52
+ const isDefault = computed(() => same(props.config.snapshot(), props.config.defaultSnapshot()));
53
+ function isActive(v) {
54
+ return same(v.snapshot, props.config.snapshot());
55
+ }
56
+ const activeLabel = computed(() => {
57
+ const hit = views.value.find(isActive);
58
+ if (hit) return hit.name;
59
+ return isDefault.value ? allLabel.value : t("shared.datatable.savedviews.custom");
60
+ });
61
+ function applyDefault() {
62
+ props.config.apply(props.config.defaultSnapshot());
63
+ }
64
+ function applyView(v) {
65
+ props.config.apply(v.snapshot);
66
+ }
67
+ const saveOpen = ref(false);
68
+ const saveName = ref("");
69
+ function openSave() {
70
+ saveName.value = "";
71
+ saveOpen.value = true;
72
+ }
73
+ function save() {
74
+ const name = saveName.value.trim();
75
+ if (!name) return;
76
+ const entry = { name, snapshot: props.config.snapshot() };
77
+ const idx = views.value.findIndex((v) => v.name === name);
78
+ if (idx >= 0) views.value.splice(idx, 1, entry);
79
+ else views.value.push(entry);
80
+ persist();
81
+ saveOpen.value = false;
82
+ }
83
+ const deleteTarget = ref(null);
84
+ function remove() {
85
+ if (!deleteTarget.value) return;
86
+ views.value = views.value.filter((v) => v.name !== deleteTarget.value.name);
87
+ persist();
88
+ deleteTarget.value = null;
89
+ }
90
+ </script>
91
+
92
+ <template>
93
+ <DropdownMenu>
94
+ <DropdownMenuTrigger as-child>
95
+ <Button
96
+ variant="outline"
97
+ size="sm"
98
+ class="h-7 max-w-48 gap-1.5 text-sm"
99
+ :class="isDefault ? '' : 'border-primary/30 text-primary'"
100
+ :aria-label="t('shared.datatable.savedviews.aria')"
101
+ >
102
+ <Bookmark :size="13" />
103
+ <span class="truncate">{{ activeLabel }}</span>
104
+ <AltArrowDown :size="12" class="opacity-60" />
105
+ </Button>
106
+ </DropdownMenuTrigger>
107
+ <DropdownMenuContent align="end" class="w-56">
108
+ <DropdownMenuItem class="gap-2" @select="applyDefault">
109
+ <span class="flex-1 truncate">{{ allLabel }}</span>
110
+ <span v-if="isDefault" class="text-xs text-primary">{{ t("shared.datatable.savedviews.active.badge") }}</span>
111
+ </DropdownMenuItem>
112
+ <template v-if="views.length">
113
+ <DropdownMenuSeparator />
114
+ <DropdownMenuItem
115
+ v-for="v in views"
116
+ :key="v.name"
117
+ class="gap-2"
118
+ @select="applyView(v)"
119
+ >
120
+ <span class="flex-1 truncate">{{ v.name }}</span>
121
+ <span
122
+ role="button"
123
+ class="text-muted-foreground transition-colors hover:text-rose-600"
124
+ :aria-label="t('shared.datatable.savedviews.delete.aria', { name: v.name })"
125
+ @click.stop="deleteTarget = v"
126
+ @pointerdown.stop.prevent
127
+ >
128
+ <TrashBinMinimalistic :size="13" />
129
+ </span>
130
+ </DropdownMenuItem>
131
+ </template>
132
+ <DropdownMenuSeparator />
133
+ <DropdownMenuItem @select="openSave">
134
+ {{ t("shared.datatable.savedviews.action.savecurrent") }}
135
+ </DropdownMenuItem>
136
+ </DropdownMenuContent>
137
+ </DropdownMenu>
138
+
139
+ <!-- Save the current filters + sort under a name -->
140
+ <Dialog :open="saveOpen" @update:open="(v2) => {
141
+ if (!v2) saveOpen = false;
142
+ }">
143
+ <DialogContent>
144
+ <DialogHeader>
145
+ <DialogTitle>{{ t("shared.datatable.savedviews.savedialog.title") }}</DialogTitle>
146
+ <DialogDescription>{{ t("shared.datatable.savedviews.savedialog.description") }}</DialogDescription>
147
+ </DialogHeader>
148
+ <div class="space-y-1">
149
+ <label for="saved-view-name" class="text-sm font-medium">{{ t("shared.datatable.savedviews.savedialog.field.name.label") }}</label>
150
+ <Input
151
+ id="saved-view-name"
152
+ v-model="saveName"
153
+ :placeholder="t('shared.datatable.savedviews.savedialog.field.name.placeholder')"
154
+ @keydown.enter="save"
155
+ />
156
+ </div>
157
+ <DialogFooter>
158
+ <Button variant="outline" @click="saveOpen = false">
159
+ {{ t("shared.datatable.savedviews.savedialog.action.cancel") }}
160
+ </Button>
161
+ <Button :disabled="!saveName.trim()" @click="save">
162
+ {{ t("shared.datatable.savedviews.savedialog.action.save") }}
163
+ </Button>
164
+ </DialogFooter>
165
+ </DialogContent>
166
+ </Dialog>
167
+
168
+ <!-- Delete a saved view -->
169
+ <Dialog :open="deleteTarget !== null" @update:open="(v2) => {
170
+ if (!v2) deleteTarget = null;
171
+ }">
172
+ <DialogContent>
173
+ <DialogHeader>
174
+ <DialogTitle>{{ t("shared.datatable.savedviews.deletedialog.title") }}</DialogTitle>
175
+ <DialogDescription>
176
+ {{ t("shared.datatable.savedviews.deletedialog.description", { name: deleteTarget?.name ?? "" }) }}
177
+ </DialogDescription>
178
+ </DialogHeader>
179
+ <DialogFooter>
180
+ <Button variant="outline" @click="deleteTarget = null">
181
+ {{ t("shared.datatable.savedviews.deletedialog.action.cancel") }}
182
+ </Button>
183
+ <Button variant="outline" class="border-rose-500/30 text-rose-700 hover:bg-rose-500/10" @click="remove">
184
+ {{ t("shared.datatable.savedviews.deletedialog.action.delete") }}
185
+ </Button>
186
+ </DialogFooter>
187
+ </DialogContent>
188
+ </Dialog>
189
+ </template>
@@ -0,0 +1,6 @@
1
+ import type { DataTableSavedViews } from './types.js';
2
+ type __VLS_Props = {
3
+ config: DataTableSavedViews;
4
+ };
5
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
6
+ export default _default;
@@ -0,0 +1,13 @@
1
+ import type { DataTableColumn, SortState } from './types.js';
2
+ type __VLS_Props = {
3
+ columns: DataTableColumn[];
4
+ };
5
+ type __VLS_PublicProps = __VLS_Props & {
6
+ modelValue: SortState;
7
+ };
8
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
+ "update:modelValue": (value: SortState) => any;
10
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
11
+ "onUpdate:modelValue"?: ((value: SortState) => any) | undefined;
12
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
13
+ export default _default;
@@ -0,0 +1,47 @@
1
+ <script setup>
2
+ import { AltArrowDown, SortVertical } from "#solar-icons";
3
+ import {
4
+ Button,
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger
9
+ } from "@revenexx/studio";
10
+ const props = defineProps({
11
+ columns: { type: Array, required: true }
12
+ });
13
+ const sort = defineModel({ type: Object, ...{ required: true } });
14
+ const t = useT();
15
+ const label = computed(() => {
16
+ const col = props.columns.find((c) => c.key === sort.value.column);
17
+ if (!col || !sort.value.direction) return t("shared.datatable.sortmenu.label.default");
18
+ return `${col.label} ${sort.value.direction === "asc" ? "\u2191" : "\u2193"}`;
19
+ });
20
+ function pick(key, direction) {
21
+ sort.value = { column: key, direction };
22
+ }
23
+ </script>
24
+
25
+ <template>
26
+ <DropdownMenu>
27
+ <DropdownMenuTrigger as-child>
28
+ <Button variant="outline" size="sm" class="h-7 gap-1.5 text-sm" :aria-label="t('shared.datatable.sortmenu.aria')">
29
+ <SortVertical :size="14" />
30
+ {{ label }}
31
+ <AltArrowDown :size="12" class="opacity-60" />
32
+ </Button>
33
+ </DropdownMenuTrigger>
34
+ <DropdownMenuContent align="start" class="w-52">
35
+ <template v-for="col in columns" :key="col.key">
36
+ <DropdownMenuItem class="gap-2" @select="pick(col.key, 'asc')">
37
+ <span class="flex-1 truncate">{{ col.label }} ↑</span>
38
+ <span v-if="sort.column === col.key && sort.direction === 'asc'" class="text-xs text-primary">{{ t("shared.datatable.sortmenu.active.badge") }}</span>
39
+ </DropdownMenuItem>
40
+ <DropdownMenuItem class="gap-2" @select="pick(col.key, 'desc')">
41
+ <span class="flex-1 truncate">{{ col.label }} ↓</span>
42
+ <span v-if="sort.column === col.key && sort.direction === 'desc'" class="text-xs text-primary">{{ t("shared.datatable.sortmenu.active.badge") }}</span>
43
+ </DropdownMenuItem>
44
+ </template>
45
+ </DropdownMenuContent>
46
+ </DropdownMenu>
47
+ </template>
@@ -0,0 +1,13 @@
1
+ import type { DataTableColumn, SortState } from './types.js';
2
+ type __VLS_Props = {
3
+ columns: DataTableColumn[];
4
+ };
5
+ type __VLS_PublicProps = __VLS_Props & {
6
+ modelValue: SortState;
7
+ };
8
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
+ "update:modelValue": (value: SortState) => any;
10
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
11
+ "onUpdate:modelValue"?: ((value: SortState) => any) | undefined;
12
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
13
+ export default _default;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * List | Gallery (| …) switch for the DataTable toolbar.
3
+ *
4
+ * A gallery is a view mode of a table, not a different kind of page: the
5
+ * toolbar, the footer and the data pipeline stay put, only the body swaps
6
+ * for the `#view` slot.
7
+ */
8
+ import type { DataTableViewMode } from './types.js';
9
+ type __VLS_Props = {
10
+ modes: DataTableViewMode[];
11
+ };
12
+ type __VLS_PublicProps = __VLS_Props & {
13
+ modelValue: string;
14
+ };
15
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
16
+ "update:modelValue": (value: string) => any;
17
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
18
+ "onUpdate:modelValue"?: ((value: string) => any) | undefined;
19
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
20
+ export default _default;
@@ -0,0 +1,26 @@
1
+ <script setup>
2
+ defineProps({
3
+ modes: { type: Array, required: true }
4
+ });
5
+ const mode = defineModel({ type: String, ...{ required: true } });
6
+ </script>
7
+
8
+ <template>
9
+ <div class="inline-flex shrink-0 overflow-hidden rounded-md border border-black/[0.06]">
10
+ <button
11
+ v-for="(m, i) in modes"
12
+ :key="m.value"
13
+ type="button"
14
+ class="inline-flex items-center gap-1.5 px-2.5 py-1 text-sm transition-colors"
15
+ :class="[
16
+ i > 0 ? 'border-l border-black/[0.06]' : '',
17
+ mode === m.value ? 'bg-primary/10 font-medium text-[#0B0522]' : 'text-muted-foreground hover:bg-muted/60'
18
+ ]"
19
+ :aria-pressed="mode === m.value"
20
+ @click="mode = m.value"
21
+ >
22
+ <component :is="m.icon" v-if="m.icon" :size="13" />
23
+ {{ m.label }}
24
+ </button>
25
+ </div>
26
+ </template>
@@ -0,0 +1,20 @@
1
+ /**
2
+ * List | Gallery (| …) switch for the DataTable toolbar.
3
+ *
4
+ * A gallery is a view mode of a table, not a different kind of page: the
5
+ * toolbar, the footer and the data pipeline stay put, only the body swaps
6
+ * for the `#view` slot.
7
+ */
8
+ import type { DataTableViewMode } from './types.js';
9
+ type __VLS_Props = {
10
+ modes: DataTableViewMode[];
11
+ };
12
+ type __VLS_PublicProps = __VLS_Props & {
13
+ modelValue: string;
14
+ };
15
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
16
+ "update:modelValue": (value: string) => any;
17
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
18
+ "onUpdate:modelValue"?: ((value: string) => any) | undefined;
19
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
20
+ export default _default;
@@ -56,6 +56,40 @@ export interface DataTableFilter<T = unknown> {
56
56
  /** Label for the reset/"all" entry (default `All`). */
57
57
  allLabel?: string;
58
58
  }
59
+ /** A named snapshot of a list surface's filter + sort state. */
60
+ export interface DataTableSavedView<S = unknown> {
61
+ name: string;
62
+ snapshot: S;
63
+ }
64
+ /**
65
+ * Saved-view wiring for the DataTable toolbar. The table owns the control,
66
+ * the storage and the save/delete dialogs; the surface only says how to read
67
+ * and write its own filter state.
68
+ */
69
+ export interface DataTableSavedViews<S = unknown> {
70
+ /** `localStorage` key — one bucket per list surface. */
71
+ storageKey: string;
72
+ /** Label of the reset entry (default `All`). */
73
+ allLabel?: string;
74
+ /** The surface's current filter + sort state. */
75
+ snapshot: () => S;
76
+ /** The unfiltered baseline the reset entry restores. */
77
+ defaultSnapshot: () => S;
78
+ /** Put a snapshot back onto the surface. */
79
+ apply: (snapshot: S) => void;
80
+ }
81
+ /** One entry of the toolbar's view-mode switch (List | Gallery | …). */
82
+ export interface DataTableViewMode {
83
+ /** `list` renders the table; any other value renders the `#view` slot. */
84
+ value: string;
85
+ label: string;
86
+ icon?: Component;
87
+ }
88
+ /** Collapsible left-rail affordance in the toolbar. */
89
+ export interface DataTableRail {
90
+ /** Button label (default `Filters`). */
91
+ label?: string;
92
+ }
59
93
  export type SortDirection = 'asc' | 'desc' | null;
60
94
  export interface SortState {
61
95
  column: string | null;
@@ -0,0 +1,17 @@
1
+ import type { StorageFolderNode } from '../../types/storage.js';
2
+ /**
3
+ * Recursive folder tree for the media browser sidebar. Emits `select` with a
4
+ * folder id (or null for the synthetic root). Self-renders for children.
5
+ */
6
+ type __VLS_Props = {
7
+ nodes: StorageFolderNode[];
8
+ currentId: string | null;
9
+ /** Internal: nesting depth for indentation. */
10
+ depth?: number;
11
+ };
12
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
13
+ select: (id: string | null) => any;
14
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
15
+ onSelect?: ((id: string | null) => any) | undefined;
16
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
17
+ export default _default;
@@ -0,0 +1,65 @@
1
+ <script setup>
2
+ import { Folder as FolderIcon, FolderOpen, AltArrowRight } from "#solar-icons";
3
+ const props = defineProps({
4
+ nodes: { type: Array, required: true },
5
+ currentId: { type: [String, null], required: true },
6
+ depth: { type: Number, required: false }
7
+ });
8
+ const emit = defineEmits(["select"]);
9
+ const expanded = ref(/* @__PURE__ */ new Set());
10
+ function toggle(id) {
11
+ const next = new Set(expanded.value);
12
+ if (next.has(id)) next.delete(id);
13
+ else next.add(id);
14
+ expanded.value = next;
15
+ }
16
+ const depth = computed(() => props.depth ?? 0);
17
+ const t = useT();
18
+ </script>
19
+
20
+ <template>
21
+ <div class="flex flex-col">
22
+ <div
23
+ v-for="node in nodes"
24
+ :key="node.id"
25
+ >
26
+ <div
27
+ class="group flex items-center gap-1 rounded-md pr-2 py-1 text-sm transition-colors cursor-pointer"
28
+ :class="currentId === node.id ? 'bg-primary/10 text-[#0B0522] font-medium' : 'text-foreground/80 hover:bg-primary/5'"
29
+ :style="{ paddingLeft: `${depth * 12 + 4}px` }"
30
+ @click="emit('select', node.id)"
31
+ >
32
+ <button
33
+ type="button"
34
+ class="shrink-0 rounded p-0.5 text-foreground/40 hover:text-foreground/80"
35
+ :class="{ invisible: node.children.length === 0 }"
36
+ @click.stop="toggle(node.id)"
37
+ >
38
+ <AltArrowRight
39
+ :size="12"
40
+ class="transition-transform"
41
+ :class="{ 'rotate-90': expanded.has(node.id) }"
42
+ />
43
+ </button>
44
+ <component
45
+ :is="currentId === node.id ? FolderOpen : FolderIcon"
46
+ :size="14"
47
+ class="shrink-0 text-foreground/50"
48
+ />
49
+ <span class="flex-1 truncate">{{ node.name }}</span>
50
+ <span
51
+ v-if="node.is_system"
52
+ class="rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground"
53
+ >{{ t("shared.media.foldertree.badge.system") }}</span>
54
+ </div>
55
+
56
+ <MediaFolderTree
57
+ v-if="node.children.length && expanded.has(node.id)"
58
+ :nodes="node.children"
59
+ :current-id="currentId"
60
+ :depth="depth + 1"
61
+ @select="(id) => emit('select', id)"
62
+ />
63
+ </div>
64
+ </div>
65
+ </template>
@@ -0,0 +1,17 @@
1
+ import type { StorageFolderNode } from '../../types/storage.js';
2
+ /**
3
+ * Recursive folder tree for the media browser sidebar. Emits `select` with a
4
+ * folder id (or null for the synthetic root). Self-renders for children.
5
+ */
6
+ type __VLS_Props = {
7
+ nodes: StorageFolderNode[];
8
+ currentId: string | null;
9
+ /** Internal: nesting depth for indentation. */
10
+ depth?: number;
11
+ };
12
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
13
+ select: (id: string | null) => any;
14
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
15
+ onSelect?: ((id: string | null) => any) | undefined;
16
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
17
+ export default _default;
@@ -0,0 +1,18 @@
1
+ import type { StorageAsset } from '../../types/storage.js';
2
+ type __VLS_Props = {
3
+ open: boolean;
4
+ multiple?: boolean;
5
+ /** Restrict the grid to these asset kinds (e.g. ['image']). */
6
+ kinds?: string[];
7
+ };
8
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
+ select: (assets: StorageAsset[]) => any;
10
+ "update:open": (value: boolean) => any;
11
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
12
+ onSelect?: ((assets: StorageAsset[]) => any) | undefined;
13
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
14
+ }>, {
15
+ multiple: boolean;
16
+ kinds: string[];
17
+ }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
18
+ export default _default;
@@ -0,0 +1,163 @@
1
+ <script setup>
2
+ import { Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@revenexx/studio";
3
+ import { CloudUpload, FileText } from "#solar-icons";
4
+ import MediaFolderTree from "./MediaFolderTree.vue";
5
+ import MediaUploader from "./MediaUploader.vue";
6
+ const props = defineProps({
7
+ open: { type: Boolean, required: true },
8
+ multiple: { type: Boolean, required: false, default: false },
9
+ kinds: { type: Array, required: false, default: void 0 }
10
+ });
11
+ const emit = defineEmits(["update:open", "select"]);
12
+ const {
13
+ assets,
14
+ folderTree,
15
+ currentFolderId,
16
+ currentView,
17
+ currentFolder,
18
+ loadingAssets,
19
+ hasMore,
20
+ init,
21
+ openFolder,
22
+ showAllMedia,
23
+ loadMore,
24
+ thumbnailUrl,
25
+ isProcessing
26
+ } = useMediaLibrary();
27
+ const t = useT();
28
+ const selected = ref([]);
29
+ const uploadOpen = ref(false);
30
+ const failedThumbs = ref(/* @__PURE__ */ new Set());
31
+ const kindIcon = {};
32
+ const visibleAssets = computed(
33
+ () => props.kinds?.length ? assets.value.filter((a) => props.kinds.includes(a.kind)) : assets.value
34
+ );
35
+ function isSelected(asset) {
36
+ return selected.value.some((a) => a.id === asset.id);
37
+ }
38
+ function toggle(asset) {
39
+ if (isProcessing(asset)) return;
40
+ if (props.multiple) {
41
+ if (isSelected(asset)) selected.value = selected.value.filter((a) => a.id !== asset.id);
42
+ else selected.value = [...selected.value, asset];
43
+ } else {
44
+ selected.value = isSelected(asset) ? [] : [asset];
45
+ }
46
+ }
47
+ function markFailed(url) {
48
+ if (url) failedThumbs.value = new Set(failedThumbs.value).add(url);
49
+ }
50
+ function confirm() {
51
+ if (!selected.value.length) return;
52
+ emit("select", selected.value);
53
+ emit("update:open", false);
54
+ }
55
+ function onUploaded() {
56
+ uploadOpen.value = false;
57
+ }
58
+ watch(() => props.open, (open) => {
59
+ if (open) {
60
+ selected.value = [];
61
+ void init();
62
+ }
63
+ });
64
+ </script>
65
+
66
+ <template>
67
+ <Dialog :open="open" @update:open="(v) => emit('update:open', v)">
68
+ <DialogContent class="max-w-4xl">
69
+ <DialogHeader>
70
+ <DialogTitle>{{ multiple ? t("shared.media.picker.title.multiple") : t("shared.media.picker.title.single") }}</DialogTitle>
71
+ </DialogHeader>
72
+
73
+ <div class="flex gap-4 h-[26rem]">
74
+ <!-- Folder sidebar -->
75
+ <div class="w-56 shrink-0 overflow-y-auto border-r border-black/[0.06] pr-2">
76
+ <button
77
+ type="button"
78
+ class="w-full text-left rounded-md px-2 py-1 text-sm transition-colors"
79
+ :class="currentView === 'all' ? 'bg-primary/10 text-[#0B0522] font-medium' : 'text-foreground/80 hover:bg-primary/5'"
80
+ @click="showAllMedia"
81
+ >
82
+ {{ t("shared.media.picker.action.allmedia") }}
83
+ </button>
84
+ <MediaFolderTree
85
+ :nodes="folderTree"
86
+ :current-id="currentView === 'folder' ? currentFolderId : null"
87
+ @select="(id) => id ? openFolder(id) : showAllMedia()"
88
+ />
89
+ </div>
90
+
91
+ <!-- Asset grid -->
92
+ <div class="flex-1 overflow-y-auto">
93
+ <div class="flex items-center justify-between mb-2">
94
+ <p class="text-sm text-muted-foreground">
95
+ {{ t("shared.media.picker.count", visibleAssets.length) }}
96
+ </p>
97
+ <Button variant="outline" size="sm" class="h-7 text-sm" @click="uploadOpen = true">
98
+ <CloudUpload :size="14" /> {{ t("shared.media.picker.action.upload") }}
99
+ </Button>
100
+ </div>
101
+
102
+ <div v-if="loadingAssets && !visibleAssets.length" class="py-12 text-center text-sm text-muted-foreground">
103
+ {{ t("shared.media.picker.loading") }}
104
+ </div>
105
+ <div v-else-if="!visibleAssets.length" class="py-12 text-center text-sm text-muted-foreground">
106
+ {{ t("shared.media.picker.empty") }}
107
+ </div>
108
+ <div v-else class="grid grid-cols-3 gap-3 sm:grid-cols-4">
109
+ <button
110
+ v-for="asset in visibleAssets"
111
+ :key="asset.id"
112
+ type="button"
113
+ class="group relative aspect-square rounded-md border overflow-hidden transition-all"
114
+ :class="isSelected(asset) ? 'border-primary ring-2 ring-primary' : 'border-black/[0.06] hover:border-primary/40'"
115
+ @click="toggle(asset)"
116
+ >
117
+ <img
118
+ v-if="thumbnailUrl(asset) && !failedThumbs.has(thumbnailUrl(asset))"
119
+ :src="thumbnailUrl(asset)"
120
+ :alt="asset.alt_text ?? asset.display_name ?? asset.original_name"
121
+ class="h-full w-full object-cover"
122
+ :class="{ 'opacity-50': isProcessing(asset) }"
123
+ @error="markFailed(thumbnailUrl(asset))"
124
+ >
125
+ <span v-else class="flex h-full w-full items-center justify-center">
126
+ <component :is="kindIcon[asset.kind] ?? FileText" :size="28" class="text-foreground/30" />
127
+ </span>
128
+ <span class="absolute inset-x-0 bottom-0 truncate bg-black/50 px-1.5 py-0.5 text-[11px] text-white">
129
+ {{ asset.display_name ?? asset.original_name }}
130
+ </span>
131
+ </button>
132
+ </div>
133
+
134
+ <div v-if="hasMore" class="mt-3 flex justify-center">
135
+ <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="loadingAssets" @click="loadMore">
136
+ {{ t("shared.media.picker.action.loadmore") }}
137
+ </Button>
138
+ </div>
139
+ </div>
140
+ </div>
141
+
142
+ <DialogFooter>
143
+ <span class="mr-auto text-sm text-muted-foreground">
144
+ {{ t("shared.media.picker.selectedcount", { count: selected.length }) }}
145
+ </span>
146
+ <Button variant="outline" size="sm" @click="emit('update:open', false)">
147
+ {{ t("shared.media.picker.action.cancel") }}
148
+ </Button>
149
+ <Button size="sm" :disabled="!selected.length" @click="confirm">
150
+ {{ t("shared.media.picker.action.confirm") }}
151
+ </Button>
152
+ </DialogFooter>
153
+ </DialogContent>
154
+ </Dialog>
155
+
156
+ <MediaUploader
157
+ :open="uploadOpen"
158
+ :folder-id="currentFolderId"
159
+ :folder-name="currentFolder?.name ?? null"
160
+ @update:open="(v) => uploadOpen = v"
161
+ @done="onUploaded"
162
+ />
163
+ </template>