From 30fecb25d833fef3eac79a6010c8fbcd011b38e9 Mon Sep 17 00:00:00 2001 From: Darragh Van Tichelen Date: Sun, 29 Dec 2024 19:36:22 +0100 Subject: [PATCH] feat(Assets): New asset search UI --- CHANGELOG.md | 10 + client/src/apiTypes.ts | 6 +- client/src/assetManager/AssetContext.vue | 108 ---- client/src/assetManager/context.ts | 11 - client/src/{assetManager => assets}/emits.ts | 12 +- client/src/{assetManager => assets}/events.ts | 31 +- client/src/{assetManager => assets}/index.ts | 60 +- client/src/{assetManager => assets}/models.ts | 0 client/src/assets/search.ts | 44 ++ client/src/{assetManager => assets}/socket.ts | 0 client/src/{assetManager => assets}/state.ts | 4 + client/src/assets/ui/AssetContext.vue | 100 ++++ client/src/assets/ui/AssetListCore.vue | 413 ++++++++++++++ .../ui}/AssetShare.vue | 11 +- client/src/assets/ui/AssetUploadProgress.vue | 101 ++++ client/src/assets/ui/access.ts | 25 + client/src/assets/ui/context.ts | 34 ++ client/src/assets/ui/drag.ts | 179 ++++++ client/src/{assetManager => assets}/utils.ts | 0 client/src/auth/logout.ts | 4 + .../components/contextMenu/ContextMenu.vue | 98 ++-- .../contextMenu/ContextMenuSection.vue | 10 +- .../src/core/components/contextMenu/types.ts | 2 +- .../core/components/modals/AssetPicker.vue | 12 +- client/src/core/models/types.ts | 15 - client/src/core/plugins/modals/assetPicker.ts | 2 +- client/src/core/systems/models.ts | 2 +- client/src/core/utils.ts | 13 - client/src/dashboard/Assets.vue | 535 +----------------- client/src/dashboard/games/CreateGame.vue | 4 +- client/src/dashboard/games/GameList.vue | 4 +- client/src/fa.ts | 4 + client/src/game/api/events.ts | 7 +- client/src/game/assets/utils.ts | 41 -- client/src/game/dropAsset.ts | 22 +- client/src/game/input/keyboard/down.ts | 6 + client/src/game/layers/variants/map.ts | 2 +- client/src/game/shapes/variants/asset.ts | 2 +- client/src/game/systems/assets/emits.ts | 5 + client/src/game/systems/assets/events.ts | 12 + client/src/game/systems/assets/index.ts | 28 + client/src/game/systems/assets/state.ts | 20 + client/src/game/systems/assets/ui.ts | 31 + client/src/game/systems/game/index.ts | 5 - client/src/game/systems/game/state.ts | 5 - client/src/game/ui/ModalStack.vue | 2 + client/src/game/ui/assets/AssetList.vue | 416 ++++++++++++++ client/src/game/ui/assets/AssetManager.vue | 80 +++ .../game/ui/contextmenu/DefaultContext.vue | 21 +- .../src/game/ui/contextmenu/ShapeContext.vue | 112 ++-- client/src/game/ui/menu/AssetNode.vue | 141 ----- client/src/game/ui/menu/AssetParentNode.vue | 19 - client/src/game/ui/menu/Characters.vue | 2 +- client/src/game/ui/menu/MenuBar.vue | 33 +- .../ui/settings/floor/PatternSettings.vue | 18 +- .../game/ui/settings/shape/ExtraSettings.vue | 34 +- .../ui/settings/shape/PropertySettings.vue | 20 +- .../ui/settings/shape/VariantSwitcher.vue | 15 +- client/src/locales/en.json | 7 +- server/generate_types.sh | 4 +- server/src/api/models/asset/__init__.py | 4 +- server/src/api/socket/asset.py | 31 + server/src/api/socket/asset_manager/core.py | 52 +- server/src/api/socket/location.py | 11 +- server/src/db/all.py | 3 + server/src/db/models/asset.py | 2 +- server/src/db/models/asset_shortcut.py | 24 + server/src/save.py | 8 +- server/src/transform/to_api/asset.py | 2 +- 69 files changed, 1946 insertions(+), 1155 deletions(-) delete mode 100644 client/src/assetManager/AssetContext.vue delete mode 100644 client/src/assetManager/context.ts rename client/src/{assetManager => assets}/emits.ts (64%) rename client/src/{assetManager => assets}/events.ts (63%) rename client/src/{assetManager => assets}/index.ts (76%) rename client/src/{assetManager => assets}/models.ts (100%) create mode 100644 client/src/assets/search.ts rename client/src/{assetManager => assets}/socket.ts (100%) rename client/src/{assetManager => assets}/state.ts (96%) create mode 100644 client/src/assets/ui/AssetContext.vue create mode 100644 client/src/assets/ui/AssetListCore.vue rename client/src/{assetManager => assets/ui}/AssetShare.vue (97%) create mode 100644 client/src/assets/ui/AssetUploadProgress.vue create mode 100644 client/src/assets/ui/access.ts create mode 100644 client/src/assets/ui/context.ts create mode 100644 client/src/assets/ui/drag.ts rename client/src/{assetManager => assets}/utils.ts (100%) delete mode 100644 client/src/game/assets/utils.ts create mode 100644 client/src/game/systems/assets/emits.ts create mode 100644 client/src/game/systems/assets/events.ts create mode 100644 client/src/game/systems/assets/index.ts create mode 100644 client/src/game/systems/assets/state.ts create mode 100644 client/src/game/systems/assets/ui.ts create mode 100644 client/src/game/ui/assets/AssetList.vue create mode 100644 client/src/game/ui/assets/AssetManager.vue delete mode 100644 client/src/game/ui/menu/AssetNode.vue delete mode 100644 client/src/game/ui/menu/AssetParentNode.vue create mode 100644 server/src/db/models/asset_shortcut.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ef4c0ae4..b042ceca0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ tech changes will usually be stripped from release notes for the public - Notes can now be popped out to a separate window - NoteManager: - Added a button to clear the current search +- In-Game Assets UI: + - Option to search through assets + - Option to add folder shortcuts per campaign + - These allow quicker navigation to frequently used folders + - A "All assets" shortcut is always available - [server] Assets: - limits: - Added limits to the total size of assets a user can upload and the size of a single asset @@ -44,6 +49,11 @@ tech changes will usually be stripped from release notes for the public - The images shown in the asset manager will now use the thumbnail of the asset if available - This should reduce load times and improve general performance - This also applies to the preview when hovering over assets in the in-game assets sidebar + - Remove initiated from the context menu now removes the entire selection + - Context menu retains selection unless an item not in the current selection is clicked +- In-game assets: + - Sidebar is removed and replaced with a new Assets dialog similar to notes + - The new UI has almost full compatibility with the assets in the dashboard - Notes: - Add filtering option 'All' to note manager to show both global and local notes - Note popouts for clients without edit access now show 'view source' instead of 'edit' diff --git a/client/src/apiTypes.ts b/client/src/apiTypes.ts index 85817323d..d02a68677 100644 --- a/client/src/apiTypes.ts +++ b/client/src/apiTypes.ts @@ -1,4 +1,4 @@ -import type { AssetId } from "./assetManager/models"; +import type { AssetId } from "./assets/models"; import type { GlobalId } from "./core/id"; import type { LayerName } from "./game/models/floor"; import type { Role } from "./game/models/role"; @@ -23,8 +23,8 @@ export interface ApiAsset { id: AssetId; name: string; owner: string; - fileHash?: string; - children?: ApiAsset[]; + fileHash: string | null; + children: ApiAsset[] | null; shares: ApiAssetShare[]; } export interface ApiAssetShare { diff --git a/client/src/assetManager/AssetContext.vue b/client/src/assetManager/AssetContext.vue deleted file mode 100644 index c026869d3..000000000 --- a/client/src/assetManager/AssetContext.vue +++ /dev/null @@ -1,108 +0,0 @@ - - - diff --git a/client/src/assetManager/context.ts b/client/src/assetManager/context.ts deleted file mode 100644 index abfe94ced..000000000 --- a/client/src/assetManager/context.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ref } from "vue"; - -export const showAssetContextMenu = ref(false); -export const assetContextLeft = ref(0); -export const assetContextTop = ref(0); - -export function openAssetContextMenu(event: MouseEvent): void { - showAssetContextMenu.value = true; - assetContextLeft.value = event.clientX; - assetContextTop.value = event.clientY; -} diff --git a/client/src/assetManager/emits.ts b/client/src/assets/emits.ts similarity index 64% rename from client/src/assetManager/emits.ts rename to client/src/assets/emits.ts index 80b8d1751..a9357a15e 100644 --- a/client/src/assetManager/emits.ts +++ b/client/src/assets/emits.ts @@ -1,6 +1,7 @@ import type { ApiAssetCreateFolder, ApiAssetCreateShare, + ApiAssetFolder, ApiAssetInodeMove, ApiAssetRemoveShare, ApiAssetRename, @@ -15,8 +16,14 @@ function wrapSocket(event: string): (data: T) => void { }; } -export const sendFolderGet = wrapSocket("Folder.Get"); -export const sendFolderGetByPath = wrapSocket("Folder.GetByPath"); +function wrapSocketWithAck(event: string): (data: T) => Promise { + return async (data: T): Promise => { + return (await socket.emitWithAck(event, data)) as Y; + }; +} + +export const getFolder = wrapSocketWithAck("Folder.Get"); +export const getFolderByPath = wrapSocketWithAck("Folder.GetByPath"); export const sendInodeMove = wrapSocket("Inode.Move"); export const sendAssetRename = wrapSocket("Asset.Rename"); export const sendAssetRemove = wrapSocket("Asset.Remove"); @@ -24,3 +31,4 @@ export const sendCreateFolder = wrapSocket("Folder.Create" export const sendRemoveShare = wrapSocket("Asset.Share.Remove"); export const sendEditShareRight = wrapSocket("Asset.Share.Edit"); export const sendCreateShare = wrapSocket("Asset.Share.Create"); +export const getFolderPath = wrapSocketWithAck("Asset.FolderPath"); diff --git a/client/src/assetManager/events.ts b/client/src/assets/events.ts similarity index 63% rename from client/src/assetManager/events.ts rename to client/src/assets/events.ts index 70f0f42e5..2971ff085 100644 --- a/client/src/assetManager/events.ts +++ b/client/src/assets/events.ts @@ -1,29 +1,22 @@ import { useToast } from "vue-toastification"; -import type { ApiAssetAdd, ApiAssetCreateShare, ApiAssetFolder, ApiAssetRemoveShare } from "../apiTypes"; -import { baseAdjust } from "../core/http"; -import { router } from "../router"; +import type { ApiAssetAdd, ApiAssetCreateShare, ApiAssetRemoveShare } from "../apiTypes"; import { coreStore } from "../store/core"; -import { sendFolderGet } from "./emits"; import type { AssetId } from "./models"; import { socket } from "./socket"; import { assetState } from "./state"; import { assetSystem } from "."; -let disConnected = false; - const toast = useToast(); socket.on("connect", () => { console.log("[Assets] connected"); - if (disConnected) sendFolderGet(assetState.currentFolder.value); }); socket.on("disconnect", () => { console.log("[Assets] disconnected"); - disConnected = true; }); socket.on("redirect", (destination: string) => { @@ -39,20 +32,6 @@ socket.on("Folder.Root.Set", (root: AssetId) => { assetSystem.setRoot(root); }); -socket.on("Folder.Set", async (data: ApiAssetFolder) => { - assetSystem.clear(); - assetSystem.setFolderData(data.folder.id, data.folder); - assetState.mutableReactive.sharedParent = data.sharedParent; - assetState.mutableReactive.sharedRight = data.sharedRight; - if (!assetState.readonly.modalActive) { - if (data.path) assetSystem.setPath(data.path); - const path = `/assets${assetState.currentFilePath.value}/`; - if (path !== router.currentRoute.value.path) { - await router.push({ path }); - } - } -}); - socket.on("Asset.Add", (data: ApiAssetAdd) => { assetSystem.addAsset(data.asset, data.parent); }); @@ -62,13 +41,9 @@ socket.on("Asset.Upload.Finish", (data: ApiAssetAdd) => { assetSystem.resolveUpload(data.asset.name); }); -socket.on("Asset.Export.Finish", (uuid: string) => { - window.open(baseAdjust(`/static/temp/${uuid}.paa`)); -}); - -socket.on("Asset.Import.Finish", (name: string) => { +socket.on("Asset.Import.Finish", async (name: string) => { assetSystem.resolveUpload(name); - sendFolderGet(assetState.currentFolder.value); + await assetSystem.loadFolder(assetState.currentFolder.value); }); socket.on("Asset.Share.Created", (data: ApiAssetCreateShare) => { diff --git a/client/src/assetManager/index.ts b/client/src/assets/index.ts similarity index 76% rename from client/src/assetManager/index.ts rename to client/src/assets/index.ts index 01d5f31e6..daa237d4e 100644 --- a/client/src/assetManager/index.ts +++ b/client/src/assets/index.ts @@ -1,10 +1,12 @@ import { useToast } from "vue-toastification"; import type { ApiAsset, ApiAssetUpload } from "../apiTypes"; +import { registerSystem, type System } from "../core/systems"; +import type { SystemClearReason } from "../core/systems/models"; import { callbackProvider, uuidv4 } from "../core/utils"; import { router } from "../router"; -import { sendAssetRemove, sendAssetRename, sendFolderGet, sendInodeMove } from "./emits"; +import { sendAssetRemove, sendAssetRename, getFolder, sendInodeMove, getFolderPath, getFolderByPath } from "./emits"; import type { AssetId } from "./models"; import { socket } from "./socket"; import { assetState } from "./state"; @@ -15,12 +17,21 @@ const toast = useToast(); const { raw, mutableReactive: $ } = assetState; -class AssetSystem { +class AssetSystem implements System { rootCallback = callbackProvider(); - clear(): void { + clearLocal(): void { $.folders = []; $.files = []; + $.loadingFolder = false; + } + + clear(reason: SystemClearReason): void { + if (reason === "logging-out") { + this.clearLocal(); + $.idMap.clear(); + $.folderPath = []; + } } clearFolderPath(): void { @@ -55,7 +66,8 @@ class AssetSystem { sendInodeMove({ inode, target: targetFolder }); } - changeDirectory(targetFolder: AssetId | "POP"): void { + async changeDirectory(targetFolder: AssetId | "POP"): Promise { + $.loadingFolder = true; if (targetFolder === "POP") { $.folderPath.pop(); } else if (targetFolder === raw.root) { @@ -64,10 +76,28 @@ class AssetSystem { while (assetState.currentFolder.value !== targetFolder) $.folderPath.pop(); } else { const asset = raw.idMap.get(targetFolder); - if (asset !== undefined) $.folderPath.push({ id: targetFolder, name: asset.name }); + if (asset !== undefined) { + if (raw.root && ($.idMap.get(raw.root)?.children?.some((c) => c.id === targetFolder) ?? false)) { + $.folderPath = [{ id: targetFolder, name: asset.name }]; + } else { + const path = await getFolderPath(targetFolder); + $.folderPath = path.slice(1); + } + } } this.clearSelected(); - sendFolderGet(assetState.currentFolder.value); + await this.loadFolder(assetState.currentFolder.value); + } + + async loadFolder(folder: AssetId | string | undefined): Promise { + if (folder === undefined) return; + + const data = typeof folder === "string" ? await getFolderByPath(folder) : await getFolder(folder); + this.clearLocal(); + this.setFolderData(data.folder.id, data.folder); + if (data.path) assetSystem.setPath(data.path); + assetState.mutableReactive.sharedParent = data.sharedParent; + assetState.mutableReactive.sharedRight = data.sharedRight; } setFolderData(folder: AssetId, data: ApiAsset): void { @@ -78,6 +108,7 @@ class AssetSystem { this.addAsset(child); } } + $.loadingFolder = false; } // SELECTED @@ -161,19 +192,29 @@ class AssetSystem { await assetSystem.rootCallback.wait(); } - const limit = await socket.emitWithAck("Asset.Upload.Limit") as { single: number; total: number; used: number }; + const limit = (await socket.emitWithAck("Asset.Upload.Limit")) as { + single: number; + total: number; + used: number; + }; // First check limits let totalSize = 0; for (const file of fls) { totalSize += file.size; if (limit.single > 0 && file.size > limit.single) { - toast.error(`File ${file.name} is too large. Max size is ${limit.single} bytes. Contact the server admin if you need to upload larger files.`, { timeout: 0 }); + toast.error( + `File ${file.name} is too large. Max size is ${limit.single} bytes. Contact the server admin if you need to upload larger files.`, + { timeout: 0 }, + ); return []; } if (limit.total > 0 && totalSize > limit.total - limit.used) { const remaining = Math.max(0, limit.total - limit.used); - toast.error(`Total size of files is too large. You have ${remaining} bytes remaining and attempted to upload ${totalSize} bytes. Contact the server admin if you need to upload larger files.`, { timeout: 0 }); + toast.error( + `Total size of files is too large. You have ${remaining} bytes remaining and attempted to upload ${totalSize} bytes. Contact the server admin if you need to upload larger files.`, + { timeout: 0 }, + ); return []; } } @@ -240,3 +281,4 @@ class AssetSystem { export const assetSystem = new AssetSystem(); // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access (window as any).assetSystem = assetSystem; +registerSystem("asset", assetSystem, false, assetState); diff --git a/client/src/assetManager/models.ts b/client/src/assets/models.ts similarity index 100% rename from client/src/assetManager/models.ts rename to client/src/assets/models.ts diff --git a/client/src/assets/search.ts b/client/src/assets/search.ts new file mode 100644 index 000000000..854911ced --- /dev/null +++ b/client/src/assets/search.ts @@ -0,0 +1,44 @@ +import { ref, watch, type Ref } from "vue"; + +import type { ApiAsset } from "../apiTypes"; + +import { socket } from "./socket"; +import { assetState } from "./state"; + +interface AssetSearch { + clear: () => void; + filter: Ref; + results: Ref; + loading: Ref; +} + +export function useAssetSearch(searchBar: Ref): AssetSearch { + const filter = ref(""); + const results = ref([]); + const loading = ref(false); + watch(assetState.currentFolder, () => { + filter.value = ""; + }); + + function clear(): void { + filter.value = ""; + searchBar.value?.focus(); + } + + watch(filter, async (filter) => { + if (filter.length < 3) { + results.value = []; + return; + } + + loading.value = true; + const data = (await socket.emitWithAck("Asset.Search", filter)) as ApiAsset[]; + for (const asset of data) { + assetState.mutableReactive.idMap.set(asset.id, asset); + } + results.value = data; + loading.value = false; + }); + + return { clear, filter, results, loading }; +} diff --git a/client/src/assetManager/socket.ts b/client/src/assets/socket.ts similarity index 100% rename from client/src/assetManager/socket.ts rename to client/src/assets/socket.ts diff --git a/client/src/assetManager/state.ts b/client/src/assets/state.ts similarity index 96% rename from client/src/assetManager/state.ts rename to client/src/assets/state.ts index dce8a4baa..2ce908951 100644 --- a/client/src/assetManager/state.ts +++ b/client/src/assets/state.ts @@ -14,6 +14,8 @@ interface ReactiveAssetState { // We track names here, as the full breadcrumb Asset info might not be known in idMap folderPath: { id: AssetId; name: string }[]; + loadingFolder: boolean; + sharedParent: ApiAsset | null; sharedRight: "edit" | "view" | null; @@ -35,6 +37,8 @@ const state = buildState( selected: [], folderPath: [], + loadingFolder: false, + sharedParent: null, sharedRight: null, diff --git a/client/src/assets/ui/AssetContext.vue b/client/src/assets/ui/AssetContext.vue new file mode 100644 index 000000000..71510edee --- /dev/null +++ b/client/src/assets/ui/AssetContext.vue @@ -0,0 +1,100 @@ + + + diff --git a/client/src/assets/ui/AssetListCore.vue b/client/src/assets/ui/AssetListCore.vue new file mode 100644 index 000000000..c274394fd --- /dev/null +++ b/client/src/assets/ui/AssetListCore.vue @@ -0,0 +1,413 @@ + + + + + diff --git a/client/src/assetManager/AssetShare.vue b/client/src/assets/ui/AssetShare.vue similarity index 97% rename from client/src/assetManager/AssetShare.vue rename to client/src/assets/ui/AssetShare.vue index b879448fa..6b3809820 100644 --- a/client/src/assetManager/AssetShare.vue +++ b/client/src/assets/ui/AssetShare.vue @@ -2,12 +2,11 @@ import { computed, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; -import Modal from "../core/components/modals/Modal.vue"; - -import { sendCreateShare, sendEditShareRight, sendRemoveShare } from "./emits"; -import type { AssetId } from "./models"; -import { socket } from "./socket"; -import { assetState } from "./state"; +import Modal from "../../core/components/modals/Modal.vue"; +import { sendCreateShare, sendEditShareRight, sendRemoveShare } from "../emits"; +import type { AssetId } from "../models"; +import { socket } from "../socket"; +import { assetState } from "../state"; const props = defineProps<{ visible: boolean; asset: AssetId | undefined }>(); const emit = defineEmits<(e: "close") => void>(); diff --git a/client/src/assets/ui/AssetUploadProgress.vue b/client/src/assets/ui/AssetUploadProgress.vue new file mode 100644 index 000000000..a8f7e9ec0 --- /dev/null +++ b/client/src/assets/ui/AssetUploadProgress.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/client/src/assets/ui/access.ts b/client/src/assets/ui/access.ts new file mode 100644 index 000000000..efff23f8e --- /dev/null +++ b/client/src/assets/ui/access.ts @@ -0,0 +1,25 @@ +import type { DeepReadonly } from "vue"; + +import type { ApiAsset } from "../../apiTypes"; +import { coreStore } from "../../store/core"; +import type { AssetId } from "../models"; +import { assetState } from "../state"; + +export function canEdit(data: AssetId | DeepReadonly | undefined, includeRootShare = true): boolean { + if (data === undefined) return false; // We accept undefined to alleviate awkward type checks in callers + let asset: DeepReadonly | undefined; + if (data instanceof Object && "id" in data) asset = data; + else asset = assetState.raw.idMap.get(data); + + if (asset === undefined) return false; + + if (assetState.raw.sharedRight === "view") return false; + + if (includeRootShare) { + const username = coreStore.state.username; + if (asset === undefined) return false; + if (asset.owner !== username && !asset.shares.some((s) => s.user === username && s.right === "edit")) + return false; + } + return true; +} diff --git a/client/src/assets/ui/context.ts b/client/src/assets/ui/context.ts new file mode 100644 index 000000000..a9ccbbead --- /dev/null +++ b/client/src/assets/ui/context.ts @@ -0,0 +1,34 @@ +import type { Ref } from "vue"; +import { ref } from "vue"; + +const showAssetContextMenu = ref(false); +const assetContextLeft = ref(0); +const assetContextTop = ref(0); + +function openAssetContextMenu(event: MouseEvent): void { + showAssetContextMenu.value = true; + assetContextLeft.value = event.clientX; + assetContextTop.value = event.clientY; +} + +export interface AssetContextMenu { + state: { + readonly visible: Ref; + readonly left: Ref; + readonly top: Ref; + }; + open: (event: MouseEvent) => void; + close: () => void; +} + +export function useAssetContextMenu(): AssetContextMenu { + return { + state: { + visible: showAssetContextMenu, + left: assetContextLeft, + top: assetContextTop, + }, + open: openAssetContextMenu, + close: () => (showAssetContextMenu.value = false), + }; +} diff --git a/client/src/assets/ui/drag.ts b/client/src/assets/ui/drag.ts new file mode 100644 index 000000000..a7b0dd187 --- /dev/null +++ b/client/src/assets/ui/drag.ts @@ -0,0 +1,179 @@ +import type { Ref } from "vue"; +import { onMounted, ref } from "vue"; +import { useToast } from "vue-toastification"; + +import { assetSystem } from ".."; +import { map } from "../../core/iter"; +import type { AssetId } from "../models"; +import { assetState } from "../state"; + +import { canEdit } from "./access"; + +const toast = useToast(); + +let emit: (event: "onDragEnd" | "onDragLeave" | "onDragStart", value: DragEvent) => void = () => {}; +let draggingSelection = false; +const dropZoneVisible = ref(0); + +function showDropZone(): void { + dropZoneVisible.value++; +} + +function hideDropZone(): void { + dropZoneVisible.value--; +} + +function fsToFile(fl: FileSystemFileEntry): Promise { + return new Promise((resolve) => fl.file(resolve)); +} + +async function parseDirectoryUpload( + fileSystemEntries: Iterable, + target: AssetId, + newDirectories: string[] = [], +): Promise { + const files: FileSystemFileEntry[] = []; + for (const entry of fileSystemEntries) { + if (entry === null) continue; + if (entry.isDirectory) { + const fwk = entry as FileSystemDirectoryEntry; + const reader = fwk.createReader(); + reader.readEntries( + (entries) => void parseDirectoryUpload(entries, target, [...newDirectories, entry.name]), + ); + } else if (entry.isFile) { + files.push(entry as FileSystemFileEntry); + } + } + if (files.length > 0) { + const fileList = await Promise.all(files.map((f) => fsToFile(f))); + await assetSystem.upload(fileList as unknown as FileList, { target: () => target, newDirectories }); + } +} + +async function onDrop(event: DragEvent): Promise { + emit("onDragEnd", event); + hideDropZone(); + const currentFolder = assetState.currentFolder.value; + if (!canEdit(currentFolder)) { + toast.error("You do not have permission to do this."); + return; + } + + if (currentFolder && event.dataTransfer && event.dataTransfer.items.length > 0) { + await parseDirectoryUpload( + map(event.dataTransfer.items, (i) => i.webkitGetAsEntry()), + currentFolder, + ); + } +} + +function startDrag(event: DragEvent, file: AssetId, assetHash: string | null): void { + if (event.dataTransfer === null) return; + if (!canEdit(file, false)) { + return; + } + + // Find the image to use as the drag image + const image = (event.target as HTMLElement).closest(".inode")?.querySelector("img"); + if (image) { + event.dataTransfer.setDragImage(image, 0, 0); + } + + // Add file info in case we drop it on the canvas + event.dataTransfer.setData("text/plain", JSON.stringify({ assetHash, assetId: file })); + event.dataTransfer.dropEffect = "move"; + + if (!assetState.raw.selected.includes(file)) assetSystem.addSelectedInode(file); + draggingSelection = true; + + emit("onDragStart", event); +} + +function moveDrag(event: DragEvent): void { + const fromElement = (event.target as HTMLElement).closest(".inode"); + + if (fromElement) { + fromElement.classList.add("inode-hovered"); + } +} + +function leaveDrag(event: DragEvent): void { + const fromElement = (event.target as HTMLElement).closest(".inode"); + const toElement = event.relatedTarget as HTMLElement; + + if (fromElement && fromElement !== toElement.closest(".inode")) { + fromElement.classList.remove("inode-hovered"); + } +} + +function onDragEnd(event: DragEvent): void { + emit("onDragEnd", event); + hideDropZone(); + + const fromElement = (event.target as HTMLElement).closest(".inode"); + if (fromElement) { + fromElement.classList.remove("inode-hovered"); + } +} + +async function stopDrag(event: DragEvent, target: AssetId): Promise { + emit("onDragEnd", event); + (event.target as HTMLElement).classList.remove("inode-hovered"); + + if (!canEdit(target)) { + if (!assetState.raw.selected.includes(target)) toast.error("You do not have permission to do this."); + } else { + if (draggingSelection) { + if (assetState.raw.selected.includes(target)) return; + if ( + target === assetState.parentFolder.value || + target === assetState.raw.root || + assetState.raw.folders.includes(target) + ) { + for (const inode of assetState.raw.selected) { + assetSystem.moveInode(inode, target); + } + } + assetSystem.clearSelected(); + } else if (event.dataTransfer && event.dataTransfer.items.length > 0) { + await parseDirectoryUpload( + map(event.dataTransfer.items, (i) => i.webkitGetAsEntry()), + target, + ); + } + } + draggingSelection = false; + dropZoneVisible.value = 0; +} + +interface DragComposable { + dropZoneVisible: Ref; + startDrag: (event: DragEvent, file: AssetId, fileHash: string | null) => void; + moveDrag: (event: DragEvent) => void; + leaveDrag: (event: DragEvent) => void; + onDragEnd: (event: DragEvent) => void; + onDrop: (event: DragEvent) => Promise; + stopDrag: (event: DragEvent, target: AssetId) => Promise; +} + +export function useDrag( + _emit: (event: "onDragEnd" | "onDragLeave" | "onDragStart", value: DragEvent) => void, +): DragComposable { + emit = _emit; + onMounted(() => { + const body = document.getElementsByTagName("body")[0]; + body?.addEventListener("dragenter", showDropZone); + body?.addEventListener("dragleave", hideDropZone); + }); + + return { + dropZoneVisible, + startDrag, + moveDrag, + leaveDrag, + onDragEnd, + onDrop, + stopDrag, + }; +} diff --git a/client/src/assetManager/utils.ts b/client/src/assets/utils.ts similarity index 100% rename from client/src/assetManager/utils.ts rename to client/src/assets/utils.ts diff --git a/client/src/auth/logout.ts b/client/src/auth/logout.ts index 24495a842..d79aeb946 100644 --- a/client/src/auth/logout.ts +++ b/client/src/auth/logout.ts @@ -1,6 +1,8 @@ import { defineComponent } from "vue"; +import { socket } from "../assets/socket"; import { http } from "../core/http"; +import { clearSystems } from "../core/systems"; import { coreStore } from "../store/core"; export const Logout = defineComponent({ @@ -10,6 +12,8 @@ export const Logout = defineComponent({ await http.postJson("/api/logout"); coreStore.setAuthenticated(false); coreStore.setUsername(""); + clearSystems("logging-out"); + socket.disconnect(); next({ path: "/auth/login" }); }, }); diff --git a/client/src/core/components/contextMenu/ContextMenu.vue b/client/src/core/components/contextMenu/ContextMenu.vue index b13598616..797167b91 100644 --- a/client/src/core/components/contextMenu/ContextMenu.vue +++ b/client/src/core/components/contextMenu/ContextMenu.vue @@ -53,73 +53,75 @@ const visibleSections = computed(() => props.sections.filter((section) => isVisi @contextmenu.stop.prevent >
    - +
diff --git a/client/src/dashboard/games/CreateGame.vue b/client/src/dashboard/games/CreateGame.vue index 802b515b7..b279346f4 100644 --- a/client/src/dashboard/games/CreateGame.vue +++ b/client/src/dashboard/games/CreateGame.vue @@ -3,7 +3,7 @@ import { reactive, ref } from "vue"; import { useRouter } from "vue-router"; import { useToast } from "vue-toastification"; -import { getImageSrcFromHash } from "../../assetManager/utils"; +import { getImageSrcFromHash } from "../../assets/utils"; import { baseAdjust, http } from "../../core/http"; import { useModal } from "../../core/plugins/modals/plugin"; import { coreStore } from "../../store/core"; @@ -38,7 +38,7 @@ async function create(): Promise { async function setLogo(): Promise { const data = await modals.assetPicker(); - if (data === undefined || data.fileHash === undefined) return; + if (data === undefined || data.fileHash === null) return; logo.path = data.fileHash; logo.id = data.id; } diff --git a/client/src/dashboard/games/GameList.vue b/client/src/dashboard/games/GameList.vue index 88e3406e6..cb927e614 100644 --- a/client/src/dashboard/games/GameList.vue +++ b/client/src/dashboard/games/GameList.vue @@ -3,7 +3,7 @@ import { onMounted, reactive, ref, watch } from "vue"; import { useI18n } from "vue-i18n"; import { useRoute, useRouter } from "vue-router"; -import { getImageSrcFromHash } from "../../assetManager/utils"; +import { getImageSrcFromHash } from "../../assets/utils"; import { baseAdjust, getStaticImg, http } from "../../core/http"; import { useModal } from "../../core/plugins/modals/plugin"; import { getErrorReason } from "../../core/utils"; @@ -105,7 +105,7 @@ async function setLogo(): Promise { logo: data.id, }); if (success.ok) { - state.focussed.logo = data.fileHash; + state.focussed.logo = data.fileHash ?? undefined; } } diff --git a/client/src/fa.ts b/client/src/fa.ts index 1b115219b..86ca6c171 100644 --- a/client/src/fa.ts +++ b/client/src/fa.ts @@ -42,6 +42,7 @@ import { faEye, faFilter, faFolder, + faFolderOpen, faFont, faHandPaper, faLanguage, @@ -50,6 +51,7 @@ import { faLocationDot, faLock, faMagnifyingGlass, + faMapLocationDot, faMinus, faMinusSquare, faNoteSticky, @@ -117,6 +119,7 @@ export function loadFontAwesome(): void { faEye, faFilter, faFolder, + faFolderOpen, faFont, faGithub, faHandPaper, @@ -126,6 +129,7 @@ export function loadFontAwesome(): void { faLocationDot, faLock, faMagnifyingGlass, + faMapLocationDot, faMinus, faMinusSquare, faNoteSticky, diff --git a/client/src/game/api/events.ts b/client/src/game/api/events.ts index 646a83dbd..f8490a5ad 100644 --- a/client/src/game/api/events.ts +++ b/client/src/game/api/events.ts @@ -1,4 +1,5 @@ import "../systems/access/events"; +import "../systems/assets/events"; import "../systems/auras/events"; import "../systems/characters/events"; import "../systems/chat/events"; @@ -32,13 +33,11 @@ import type { ApiFloor, ApiLocationCore, PlayerPosition } from "../../apiTypes"; import { toGP } from "../../core/geometry"; import type { GlobalId } from "../../core/id"; import { SyncMode } from "../../core/models/types"; -import type { AssetList } from "../../core/models/types"; import { debugLayers } from "../../localStorageHelpers"; import { modEvents } from "../../mods/events"; import { router } from "../../router"; import { coreStore } from "../../store/core"; import { locationStore } from "../../store/location"; -import { convertAssetListToMap } from "../assets/utils"; import { clearGame } from "../clear"; import { addServerFloor } from "../floor/server"; import { getShapeFromGlobal } from "../id"; @@ -118,10 +117,6 @@ socket.on("Position.Set", (data: PlayerPosition) => { setCenterPosition(toGP(data.x, data.y)); }); -socket.on("Asset.List.Set", (assets: AssetList) => { - gameSystem.setAssets(convertAssetListToMap(assets)); -}); - socket.on("Temp.Clear", (shapeIds: GlobalId[]) => { const shapes = shapeIds.map((s) => getShapeFromGlobal(s)!).filter((s) => s !== undefined); deleteShapes(shapes, SyncMode.NO_SYNC); diff --git a/client/src/game/assets/utils.ts b/client/src/game/assets/utils.ts deleted file mode 100644 index 3b6eddcdc..000000000 --- a/client/src/game/assets/utils.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { AssetFile, AssetList, AssetListMap, ReadonlyAssetListMap } from "../../core/models/types"; -import { alphSort } from "../../core/utils"; - -export function convertAssetListToMap(assets: AssetList): AssetListMap { - const m = new Map(); - for (const key of Object.keys(assets)) { - if (key === "__files") { - m.set( - key, - (assets[key] as AssetFile[]).sort((a, b) => alphSort(a.name, b.name)), - ); - } else { - const n = convertAssetListToMap(assets[key] as AssetList); - m.set(key, n); - } - } - return new Map([...m].sort((a, b) => alphSort(a[0], b[0]))); -} - -export function filterAssetMap(assets: ReadonlyAssetListMap, filter = ""): AssetListMap { - const m = new Map(); - for (const [key, value] of assets.entries()) { - if (key === "__files") { - m.set( - key, - (value as AssetFile[]) - .filter((a) => a.name.toLocaleLowerCase().includes(filter)) - .sort((a, b) => alphSort(a.name, b.name)), - ); - } else { - const n = filterAssetMap(value as ReadonlyAssetListMap, filter); - if ( - n.size > 1 || - (n.size === 1 && (n.get("__files") as AssetFile[]).length > 0) || - key.toLocaleLowerCase().includes(filter) - ) - m.set(key, n); - } - } - return new Map([...m].sort((a, b) => alphSort(a[0], b[0]))); -} diff --git a/client/src/game/dropAsset.ts b/client/src/game/dropAsset.ts index 460a94efa..481918d2d 100644 --- a/client/src/game/dropAsset.ts +++ b/client/src/game/dropAsset.ts @@ -1,6 +1,6 @@ -import { assetSystem } from "../assetManager"; -import { assetState } from "../assetManager/state"; -import { getImageSrcFromHash } from "../assetManager/utils"; +import { assetSystem } from "../assets"; +import { assetState } from "../assets/state"; +import { getImageSrcFromHash } from "../assets/utils"; import { l2gx, l2gy, l2gz } from "../core/conversions"; import { type GlobalPoint, toGP, Vector } from "../core/geometry"; import { DEFAULT_GRID_SIZE, snapPointToGrid } from "../core/grid"; @@ -33,18 +33,16 @@ export async function handleDropEvent(event: DragEvent): Promise { const location = toGP(l2gx(event.clientX), l2gy(event.clientY)); // temp hack to prevent redirection - assetState.mutable.modalActive = true; + // assetState.mutable.modalActive = true; + + const transferInfo = event.dataTransfer.getData("text/plain"); // External files are dropped - if (event.dataTransfer.files.length > 0) { + if (!transferInfo && event.dataTransfer.files.length > 0) { for (const asset of await assetSystem.upload(event.dataTransfer.files, { target: () => assetState.raw.root })) { - if (asset.fileHash !== undefined) - await dropHelper({ assetHash: asset.fileHash, assetId: asset.id }, location); + if (asset.fileHash !== null) await dropHelper({ assetHash: asset.fileHash, assetId: asset.id }, location); } - } else { - const transferInfo = event.dataTransfer.getData("text/plain"); - if (transferInfo === "") return; - + } else if (transferInfo) { const assetInfo = JSON.parse(transferInfo) as { assetHash: string; assetId: number; @@ -53,7 +51,7 @@ export async function handleDropEvent(event: DragEvent): Promise { await dropHelper(assetInfo, location); } - assetState.mutable.modalActive = false; + // assetState.mutable.modalActive = false; } async function dropHelper( diff --git a/client/src/game/input/keyboard/down.ts b/client/src/game/input/keyboard/down.ts index 16179016c..fd5d31773 100644 --- a/client/src/game/input/keyboard/down.ts +++ b/client/src/game/input/keyboard/down.ts @@ -11,6 +11,7 @@ import { redoOperation, undoOperation } from "../../operations/undo"; import { setCenterPosition } from "../../position"; import { copyShapes, pasteShapes } from "../../shapes/utils"; import { accessSystem } from "../../systems/access"; +import { toggleAssetManager } from "../../systems/assets/ui"; import { floorSystem } from "../../systems/floors"; import { floorState } from "../../systems/floors/state"; import { gameState } from "../../systems/game/state"; @@ -28,6 +29,8 @@ export async function onKeyDown(event: KeyboardEvent): Promise { if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { // Ctrl-a with a HTMLInputElement or a HTMLTextAreaElement selected - select all the text if (event.key === "a" && ctrlOrCmdPressed(event)) event.target.select(); + } else if (event.target instanceof HTMLElement && event.target.contentEditable === "true") { + // no-op - we're editing a contentEditable element } else { const navKeys = [ "ArrowLeft", @@ -201,6 +204,9 @@ export async function onKeyDown(event: KeyboardEvent): Promise { } else if (event.key === "n") { event.preventDefault(); toggleNoteManager(); + } else if (event.key === "a") { + event.preventDefault(); + toggleAssetManager(); } } } diff --git a/client/src/game/layers/variants/map.ts b/client/src/game/layers/variants/map.ts index aaad145d9..9d8699d50 100644 --- a/client/src/game/layers/variants/map.ts +++ b/client/src/game/layers/variants/map.ts @@ -1,4 +1,4 @@ -import { getImageSrcFromHash } from "../../../assetManager/utils"; +import { getImageSrcFromHash } from "../../../assets/utils"; import { FloorType } from "../../models/floor"; import { floorSystem } from "../../systems/floors"; import { positionState } from "../../systems/position/state"; diff --git a/client/src/game/shapes/variants/asset.ts b/client/src/game/shapes/variants/asset.ts index 6424c99f7..b27d821f8 100644 --- a/client/src/game/shapes/variants/asset.ts +++ b/client/src/game/shapes/variants/asset.ts @@ -1,5 +1,5 @@ import type { ApiAssetRectShape } from "../../../apiTypes"; -import { getImageSrcFromHash } from "../../../assetManager/utils"; +import { getImageSrcFromHash } from "../../../assets/utils"; import { g2l, g2lz } from "../../../core/conversions"; import { toGP } from "../../../core/geometry"; import type { GlobalPoint } from "../../../core/geometry"; diff --git a/client/src/game/systems/assets/emits.ts b/client/src/game/systems/assets/emits.ts new file mode 100644 index 000000000..5d9ac551f --- /dev/null +++ b/client/src/game/systems/assets/emits.ts @@ -0,0 +1,5 @@ +import type { AssetId } from "../../../assets/models"; +import { wrapSocket } from "../../api/helpers"; + +export const sendAssetShortcutAdd = wrapSocket("Asset.Shortcut.Add"); +export const sendAssetShortcutRemove = wrapSocket("Asset.Shortcut.Remove"); diff --git a/client/src/game/systems/assets/events.ts b/client/src/game/systems/assets/events.ts new file mode 100644 index 000000000..0601adc5d --- /dev/null +++ b/client/src/game/systems/assets/events.ts @@ -0,0 +1,12 @@ +import type { ApiAsset } from "../../../apiTypes"; +import { assetState } from "../../../assets/state"; +import { socket } from "../../api/socket"; + +import { assetGameState } from "./state"; + +socket.on("Asset.Shortcuts.Set", (data: ApiAsset[]) => { + assetGameState.mutableReactive.shortcuts = data.map((a) => a.id); + for (const asset of data) { + assetState.mutableReactive.idMap.set(asset.id, asset); + } +}); diff --git a/client/src/game/systems/assets/index.ts b/client/src/game/systems/assets/index.ts new file mode 100644 index 000000000..9a13f1be0 --- /dev/null +++ b/client/src/game/systems/assets/index.ts @@ -0,0 +1,28 @@ +import type { AssetId } from "../../../assets/models"; +import { registerSystem } from "../../../core/systems"; +import type { System } from "../../../core/systems"; + +import { sendAssetShortcutAdd, sendAssetShortcutRemove } from "./emits"; +import { assetGameState } from "./state"; + +const { mutableReactive: $ } = assetGameState; + +class AssetGameSystem implements System { + clear(): void { + $.managerOpen = false; + $.shortcuts = []; + } + + addShortcut(id: AssetId): void { + $.shortcuts.push(id); + sendAssetShortcutAdd(id); + } + + removeShortcut(id: AssetId): void { + $.shortcuts = $.shortcuts.filter((i) => i !== id); + sendAssetShortcutRemove(id); + } +} + +export const assetGameSystem = new AssetGameSystem(); +registerSystem("assetGame", assetGameSystem, false, assetGameState); diff --git a/client/src/game/systems/assets/state.ts b/client/src/game/systems/assets/state.ts new file mode 100644 index 000000000..8a3bf4d19 --- /dev/null +++ b/client/src/game/systems/assets/state.ts @@ -0,0 +1,20 @@ +import type { AssetId } from "../../../assets/models"; +import { buildState } from "../../../core/systems/state"; + +interface ReactiveAssetState { + managerOpen: boolean; + shortcuts: AssetId[]; + + picker: ((value: AssetId | null) => void) | null; +} + +const state = buildState({ + managerOpen: false, + shortcuts: [], + + picker: null, +}); + +export const assetGameState = { + ...state, +}; diff --git a/client/src/game/systems/assets/ui.ts b/client/src/game/systems/assets/ui.ts new file mode 100644 index 000000000..f78899620 --- /dev/null +++ b/client/src/game/systems/assets/ui.ts @@ -0,0 +1,31 @@ +import type { AssetId } from "../../../assets/models"; + +import { assetGameState } from "./state"; + +function openAssetManager(): void { + assetGameState.mutableReactive.managerOpen = true; +} + +export function closeAssetManager(): void { + if (assetGameState.raw.managerOpen) { + assetGameState.mutableReactive.managerOpen = false; + if (assetGameState.raw.picker !== null) { + assetGameState.raw.picker(null); + } + } +} + +export function toggleAssetManager(): void { + if (assetGameState.raw.managerOpen) { + closeAssetManager(); + } else { + openAssetManager(); + } +} + +export async function pickAsset(): Promise { + openAssetManager(); + return new Promise((resolve) => { + assetGameState.mutableReactive.picker = resolve; + }); +} diff --git a/client/src/game/systems/game/index.ts b/client/src/game/systems/game/index.ts index 2edde1402..a32a72b79 100644 --- a/client/src/game/systems/game/index.ts +++ b/client/src/game/systems/game/index.ts @@ -1,4 +1,3 @@ -import type { AssetListMap } from "../../../core/models/types"; import { registerSystem } from "../../../core/systems"; import type { System } from "../../../core/systems"; import { sendRoomLock } from "../../api/emits/room"; @@ -54,10 +53,6 @@ class GameSystem implements System { $.isLocked = isLocked; if (sync) sendRoomLock(isLocked); } - - setAssets(assets: AssetListMap): void { - $.assets = assets; - } } export const gameSystem = new GameSystem(); diff --git a/client/src/game/systems/game/state.ts b/client/src/game/systems/game/state.ts index 5fb9a63ac..30c88abcb 100644 --- a/client/src/game/systems/game/state.ts +++ b/client/src/game/systems/game/state.ts @@ -1,6 +1,5 @@ import { computed } from "vue"; -import type { AssetListMap } from "../../../core/models/types"; import { buildState } from "../../../core/systems/state"; interface GameState { @@ -15,8 +14,6 @@ interface GameState { invitationCode: string; publicName: string; isLocked: boolean; - - assets: AssetListMap; } const state = buildState({ @@ -31,8 +28,6 @@ const state = buildState({ invitationCode: "", publicName: window.location.host, isLocked: false, - - assets: new Map(), }); export const gameState = { diff --git a/client/src/game/ui/ModalStack.vue b/client/src/game/ui/ModalStack.vue index ebde2f86d..c14dc4ae6 100644 --- a/client/src/game/ui/ModalStack.vue +++ b/client/src/game/ui/ModalStack.vue @@ -19,6 +19,7 @@ import { modalSystem } from "../systems/modals"; import { modalState } from "../systems/modals/state"; import type { IndexedModal, ModalIndex } from "../systems/modals/types"; +import AssetManager from "./assets/AssetManager.vue"; import DiceResults from "./dice/DiceResults.vue"; import Initiative from "./initiative/Initiative.vue"; import NoteManager from "./notes/NoteManager.vue"; @@ -38,6 +39,7 @@ const fixedModals = [ { component: LocationSettings, condition: gameState.isDmOrFake }, ShapeSettings, NoteManager, + AssetManager, DiceResults, ]; modalSystem.setFixedModals(fixedModals); diff --git a/client/src/game/ui/assets/AssetList.vue b/client/src/game/ui/assets/AssetList.vue new file mode 100644 index 000000000..5783415aa --- /dev/null +++ b/client/src/game/ui/assets/AssetList.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/client/src/game/ui/assets/AssetManager.vue b/client/src/game/ui/assets/AssetManager.vue new file mode 100644 index 000000000..b25cc5219 --- /dev/null +++ b/client/src/game/ui/assets/AssetManager.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/client/src/game/ui/contextmenu/DefaultContext.vue b/client/src/game/ui/contextmenu/DefaultContext.vue index 3dcd1b834..e68dbe1fc 100644 --- a/client/src/game/ui/contextmenu/DefaultContext.vue +++ b/client/src/game/ui/contextmenu/DefaultContext.vue @@ -33,19 +33,19 @@ function close(): void { showDefaultContextMenu.value = false; } -function bringPlayers(): void { - if (!gameState.raw.isDm) return; +function bringPlayers(): boolean { + if (!gameState.raw.isDm) return false; sendBringPlayers({ floor: floorState.currentFloor.value!.name, x: l2gx(defaultContextLeft.value), y: l2gy(defaultContextTop.value), }); - close(); + return true; } -async function createSpawnLocation(): Promise { - if (!gameState.raw.isDm) return; +async function createSpawnLocation(): Promise { + if (!gameState.raw.isDm) return false; const spawnLocations = locationSettingsState.raw.spawnLocations.value; const spawnName = await modals.prompt( @@ -61,7 +61,7 @@ async function createSpawnLocation(): Promise { return { valid: true }; }, ); - if (spawnName === undefined || spawnName === "") return; + if (spawnName === undefined || spawnName === "") return false; const uuid = uuidv4(); const src = "/static/img/spawn.png"; @@ -87,16 +87,17 @@ async function createSpawnLocation(): Promise { locationSettingsState.raw.activeLocation, true, ); + return true; } -function showInitiativeDialog(): void { +function showInitiativeDialog(): boolean { initiativeStore.show(true, true); - close(); + return true; } -function showTokenDialog(): void { +function showTokenDialog(): boolean { openCreateTokenDialog({ x: defaultContextLeft.value, y: defaultContextTop.value }); - close(); + return true; } const sections = computed(() => { diff --git a/client/src/game/ui/contextmenu/ShapeContext.vue b/client/src/game/ui/contextmenu/ShapeContext.vue index c5806bb07..f9f7612c0 100644 --- a/client/src/game/ui/contextmenu/ShapeContext.vue +++ b/client/src/game/ui/contextmenu/ShapeContext.vue @@ -65,16 +65,16 @@ function close(): void { showShapeContextMenu.value = false; } -function openEditDialog(): void { - if (selectedState.raw.selected.size !== 1) return; +function openEditDialog(): boolean { + if (selectedState.raw.selected.size !== 1) return false; activeShapeStore.setShowEditDialog(true); - close(); + return true; } -function openNotes(): void { - if (selectedState.raw.selected.size !== 1) return; +function openNotes(): boolean { + if (selectedState.raw.selected.size !== 1) return false; openNoteManager(NoteManagerMode.List, [...selectedState.raw.selected][0]); - close(); + return true; } // MARKERS @@ -85,23 +85,23 @@ const isMarker = computed(() => { return markerState.reactive.markers.has([...sel][0]!); }); -function deleteMarker(): void { +function deleteMarker(): boolean { const sel = selectedState.raw.selected; - if (sel.size !== 1) return; + if (sel.size !== 1) return false; markerSystem.removeMarker([...sel][0]!, true); - close(); + return true; } -function setMarker(): void { +function setMarker(): boolean { const sel = selectedState.raw.selected; - if (sel.size !== 1) return; + if (sel.size !== 1) return false; markerSystem.newMarker([...sel][0]!, true); - close(); + return true; } // INITIATIVE -async function addToInitiative(): Promise { +async function addToInitiative(): Promise { let groupInitiatives = false; const selection = selectedSystem.get({ includeComposites: false }); // First check if there are shapes with the same groupId @@ -115,7 +115,7 @@ async function addToInitiative(): Promise { "Some of the selected shapes belong to the same group. Do you wish to add 1 entry for these?", { no: "no, create a separate entry for each", focus: "confirm" }, ); - if (answer === undefined) return; + if (answer === undefined) return false; groupInitiatives = answer; break; } else { @@ -132,7 +132,7 @@ async function addToInitiative(): Promise { } } initiativeStore.show(true, true); - close(); + return true; } function getInitiativeWord(): string { @@ -158,35 +158,35 @@ const layers = computed(() => { return []; }); -function setLayer(newLayer: LayerName): void { +function setLayer(newLayer: LayerName): boolean { const oldSelection = [...selectedSystem.get({ includeComposites: true })]; selectedSystem.clear(); moveLayer(oldSelection, floorSystem.getLayer(floorState.currentFloor.value!, newLayer)!, true); - close(); + return true; } -function moveToBack(): void { +function moveToBack(): boolean { const layer = floorState.currentLayer.value!; for (const shape of selectedSystem.get({ includeComposites: false })) { layer.moveShapeOrder(shape, 0, SyncMode.FULL_SYNC); } - close(); + return true; } -function moveToFront(): void { +function moveToFront(): boolean { const layer = floorState.currentLayer.value!; for (const shape of selectedSystem.get({ includeComposites: false })) { layer.moveShapeOrder(shape, layer.size({ includeComposites: true, onlyInView: false }) - 1, SyncMode.FULL_SYNC); } - close(); + return true; } // FLOORS -function setFloor(floor: Floor): void { +function setFloor(floor: Floor): boolean { moveFloor([...selectedSystem.get({ includeComposites: true })], floor, true); - close(); + return true; } // LOCATIONS @@ -198,10 +198,10 @@ const locations = computed(() => { return []; }); -async function setLocation(newLocation: number): Promise { +async function setLocation(newLocation: number): Promise { const shapes = selectedSystem.get({ includeComposites: true }).filter((s) => !getProperties(s.id)!.isLocked); if (shapes.length === 0) { - return; + return false; } const spawnInfo = await requestSpawnInfo(newLocation); @@ -214,8 +214,7 @@ async function setLocation(newLocation: number): Promise { t("game.ui.selection.ShapeContext.no_spawn_set_text"), { showNo: false, yes: "Ok" }, ); - close(); - return; + return true; case 1: spawnLocation = spawnInfo[0]!; break; @@ -224,9 +223,9 @@ async function setLocation(newLocation: number): Promise { "Choose the desired spawn location", spawnInfo.map((s) => s.name), ); - if (choices === undefined) return; + if (choices === undefined) return false; const choiceShape = spawnInfo.find((s) => s.name === choices[0]); - if (choiceShape === undefined) return; + if (choiceShape === undefined) return false; spawnLocation = choiceShape; break; } @@ -251,16 +250,16 @@ async function setLocation(newLocation: number): Promise { playerSystem.updatePlayersLocation([...users], newLocation, true, { ...targetPosition }); } - close(); + return true; } // SELECTION const hasSingleSelection = computed(() => selectedState.reactive.selected.size === 1); -function deleteSelection(): void { +function deleteSelection(): boolean { deleteShapes(selectedSystem.get({ includeComposites: true }), SyncMode.FULL_SYNC); - close(); + return true; } // TEMPLATES @@ -271,9 +270,9 @@ const canBeSaved = computed(() => ), ); -async function saveTemplate(): Promise { +async function saveTemplate(): Promise { const shape = selectedSystem.get({ includeComposites: false })[0]; - if (shape === undefined) return; + if (shape === undefined) return false; let assetOptions: AssetOptions = { version: "0", @@ -285,7 +284,7 @@ async function saveTemplate(): Promise { if (response.success && response.options) assetOptions = response.options; } else { console.warn("Templates are currently only supported for shapes with existing asset relations."); - return; + return false; } const choices = Object.keys(assetOptions.templates); try { @@ -293,7 +292,7 @@ async function saveTemplate(): Promise { defaultButton: t("game.ui.templates.overwrite"), customButton: t("game.ui.templates.create_new"), }); - if (selection === undefined || selection.length === 0) return; + if (selection === undefined || selection.length === 0) return false; const notes = noteState.raw.shapeNotes.get1(shape.id); if (notes !== undefined) { shape.options.templateNoteIds = notes.map((n) => n); @@ -305,6 +304,7 @@ async function saveTemplate(): Promise { } catch { // no-op ; action cancelled } + return true; } // CHARACTER @@ -319,17 +319,17 @@ const canHaveCharacter = computed(() => { return true; }); -function createCharacter(): void { - close(); +function createCharacter(): boolean { const selectedId = [...selectedState.raw.selected].at(0); - if (selectedId === undefined) return; + if (selectedId === undefined) return false; const shape = getShape(selectedId); - if (shape === undefined || shape.character !== undefined) return; + if (shape === undefined || shape.character !== undefined) return false; const data: CharacterCreate = { shape: getGlobalId(selectedId)!, name: getProperties(selectedId)!.name, }; sendCreateCharacter(data); + return true; } // GROUPS @@ -353,21 +353,21 @@ const hasUngrouped = computed(() => [...selectedState.reactive.selected].some((s) => groupSystem.getGroupId(s) === undefined), ); -function createGroup(): void { +function createGroup(): boolean { groupSystem.createNewGroupForShapes([...selectedState.raw.selected]); - close(); + return true; } -async function splitGroup(): Promise { +async function splitGroup(): Promise { const keepBadges = await modals.confirm("Splitting group", "Do you wish to keep the original badges?", { no: "No, reset them", }); - if (keepBadges === undefined) return; + if (keepBadges === undefined) return false; groupSystem.createNewGroupForShapes([...selectedState.raw.selected], keepBadges); - close(); + return true; } -async function mergeGroups(): Promise { +async function mergeGroups(): Promise { const keepBadges = await modals.confirm( "Merging group", "Do you wish to keep the original badges? This can lead to duplicate badges!", @@ -375,7 +375,7 @@ async function mergeGroups(): Promise { no: "No, reset them", }, ); - if (keepBadges === undefined) return; + if (keepBadges === undefined) return false; let targetGroup: string | undefined; const membersToMove: { uuid: LocalId; badge?: number }[] = []; for (const shape of selectedSystem.get({ includeComposites: false })) { @@ -392,10 +392,10 @@ async function mergeGroups(): Promise { } } groupSystem.addGroupMembers(targetGroup!, membersToMove, true); - close(); + return true; } -function removeEntireGroup(): void { +function removeEntireGroup(): boolean { const shape = selectedSystem.get({ includeComposites: false })[0]; if (shape !== undefined) { const groupId = groupSystem.getGroupId(shape.id); @@ -403,10 +403,10 @@ function removeEntireGroup(): void { groupSystem.removeGroup(groupId, true); } } - close(); + return true; } -function enlargeGroup(): void { +function enlargeGroup(): boolean { const selection = selectedSystem .get({ includeComposites: false }) .map((s) => ({ id: s.id, groupId: groupSystem.getGroupId(s.id) })); @@ -418,20 +418,20 @@ function enlargeGroup(): void { true, ); } - close(); + return true; } -function _collapseSelection(): void { +function _collapseSelection(): boolean { collapseSelection(); - close(); + return true; } -async function _expandSelection(): Promise { +async function _expandSelection(): Promise { const updateList: IShape[] = []; await expandSelection(updateList); sendShapePositionUpdate(updateList, false); - close(); + return true; } const activeLayer = floorState.currentLayer as ComputedRef; diff --git a/client/src/game/ui/menu/AssetNode.vue b/client/src/game/ui/menu/AssetNode.vue deleted file mode 100644 index 4d2bf767b..000000000 --- a/client/src/game/ui/menu/AssetNode.vue +++ /dev/null @@ -1,141 +0,0 @@ - - - - - diff --git a/client/src/game/ui/menu/AssetParentNode.vue b/client/src/game/ui/menu/AssetParentNode.vue deleted file mode 100644 index 3d30cba33..000000000 --- a/client/src/game/ui/menu/AssetParentNode.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/client/src/game/ui/menu/Characters.vue b/client/src/game/ui/menu/Characters.vue index 4a06bc145..f958be5f3 100644 --- a/client/src/game/ui/menu/Characters.vue +++ b/client/src/game/ui/menu/Characters.vue @@ -2,7 +2,7 @@ import { computed, ref } from "vue"; import { useI18n } from "vue-i18n"; -import { getImageSrcFromHash } from "../../../assetManager/utils"; +import { getImageSrcFromHash } from "../../../assets/utils"; import { useModal } from "../../../core/plugins/modals/plugin"; import { setCenterPosition } from "../../position"; import { characterSystem } from "../../systems/characters"; diff --git a/client/src/game/ui/menu/MenuBar.vue b/client/src/game/ui/menu/MenuBar.vue index 747a3c334..4f313c9b9 100644 --- a/client/src/game/ui/menu/MenuBar.vue +++ b/client/src/game/ui/menu/MenuBar.vue @@ -1,14 +1,13 @@ @@ -224,7 +230,7 @@ async function changeAsset(): Promise {
- +
{{ t("game.ui.selection.edit_dialog.properties.advanced") }}
diff --git a/client/src/game/ui/settings/shape/VariantSwitcher.vue b/client/src/game/ui/settings/shape/VariantSwitcher.vue index 8398760e9..fe7225b32 100644 --- a/client/src/game/ui/settings/shape/VariantSwitcher.vue +++ b/client/src/game/ui/settings/shape/VariantSwitcher.vue @@ -2,7 +2,8 @@ import { computed, toRef } from "vue"; import { useToast } from "vue-toastification"; -import { getImageSrcFromHash } from "../../../../assetManager/utils"; +import { assetState } from "../../../../assets/state"; +import { getImageSrcFromHash } from "../../../../assets/utils"; import { cloneP } from "../../../../core/geometry"; import type { LocalId } from "../../../../core/id"; import { InvalidationMode, SERVER_SYNC, SyncMode } from "../../../../core/models/types"; @@ -12,6 +13,7 @@ import { dropAsset } from "../../../dropAsset"; import { getShape } from "../../../id"; import { compositeState } from "../../../layers/state"; import { ToggleComposite } from "../../../shapes/variants/toggleComposite"; +import { pickAsset } from "../../../systems/assets/ui"; const modals = useModal(); const toast = useToast(); @@ -58,12 +60,15 @@ function swapNext(): void { } async function addVariant(): Promise { - const asset = await modals.assetPicker(); - if (asset === undefined) return; + const assetId = await pickAsset(); + if (assetId === null) return; + + const assetInfo = assetState.raw.idMap.get(assetId); + if (assetInfo === undefined || assetInfo.fileHash === null) return; const shape = getShape(vState.id!)!; - if (asset.fileHash === undefined) { + if (assetInfo.fileHash === null) { console.error("Missing fileHash for new variant"); return; } @@ -72,7 +77,7 @@ async function addVariant(): Promise { if (name === undefined) return; const newShape = await dropAsset( - { imageSource: getImageSrcFromHash(asset.fileHash, { addBaseUrl: false }), assetId: asset.id }, + { imageSource: getImageSrcFromHash(assetInfo.fileHash, { addBaseUrl: false }), assetId: assetId }, shape.refPoint, ); if (newShape === undefined) { diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 867801022..fbc9fc207 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -96,6 +96,11 @@ "type": "Type", "background": "Background" }, + "assets": { + "add_shortcut": "Add shortcut", + "all_assets": "All Assets", + "remove_shortcut": "Remove shortcut" + }, "auth": { "login": { "register": "Register", @@ -691,4 +696,4 @@ "AccountSettings": "Account" } } -} +} \ No newline at end of file diff --git a/server/generate_types.sh b/server/generate_types.sh index 6acdfc2f7..81378d501 100644 --- a/server/generate_types.sh +++ b/server/generate_types.sh @@ -13,8 +13,8 @@ sed -i 's/"Role"/Role/g' ../client/src/apiTypes.ts sed -i 's/"VisionBlock"/VisionBlock/g' ../client/src/apiTypes.ts sed -i 's/"GridModeLabelFormat"/GridModeLabelFormat/g' ../client/src/apiTypes.ts sed -i '1s/^/'\ -'import type { AssetId } from ".\/assetManager\/models";\n'\ -'import type { GlobalId } from ".\/game\/id";\n'\ +'import type { AssetId } from ".\/assets\/models";\n'\ +'import type { GlobalId } from ".\/core\/id";\n'\ 'import type { LayerName } from ".\/game\/models\/floor";\n'\ 'import type { Role } from ".\/game\/models\/role";\n'\ 'import type { AuraId } from ".\/game\/systems\/auras\/models";\n'\ diff --git a/server/src/api/models/asset/__init__.py b/server/src/api/models/asset/__init__.py index cb0798706..af79a74a1 100644 --- a/server/src/api/models/asset/__init__.py +++ b/server/src/api/models/asset/__init__.py @@ -19,11 +19,11 @@ class ApiAsset(TypeIdModel): # The name of the asset can be shown differently depending on sharing state name: str owner: str - fileHash: str | None + fileHash: str | None = Field(..., noneAsNull=True) # If specified, this provides the list of children for this asset # This should only be provided for folders (i.e. assets without a fileHash) # And is only provided in specific calls - children: list["ApiAsset"] | None + children: list["ApiAsset"] | None = Field(..., noneAsNull=True) shares: list[ApiAssetShare] # Info on users that this specific asset is shared with diff --git a/server/src/api/socket/asset.py b/server/src/api/socket/asset.py index 7ed6e5133..4b8791030 100644 --- a/server/src/api/socket/asset.py +++ b/server/src/api/socket/asset.py @@ -4,6 +4,7 @@ from ...api.socket.constants import GAME_NS from ...app import app, sio from ...db.models.asset import Asset +from ...db.models.asset_shortcut import AssetShortcut from ...db.models.player_room import PlayerRoom from ...logs import logger from ...models.role import Role @@ -56,3 +57,33 @@ async def set_asset_options(sid: str, raw_data: Any): ) asset.options = asset_options.options asset.save() + + +@sio.on("Asset.Shortcut.Add", namespace=GAME_NS) +@auth.login_required(app, sio, "game") +async def add_asset_shortcut(sid: str, asset_id: int): + pr: PlayerRoom = game_state.get(sid) + + asset = Asset.get_or_none(id=asset_id) + if asset is None: + return + + AssetShortcut.create( + asset=asset, + player_room=pr, + ) + + +@sio.on("Asset.Shortcut.Remove", namespace=GAME_NS) +@auth.login_required(app, sio, "game") +async def remove_asset_shortcut(sid: str, asset_id: int): + pr: PlayerRoom = game_state.get(sid) + + asset = Asset.get_or_none(id=asset_id) + if asset is None: + return + + AssetShortcut.delete().where( + AssetShortcut.asset == asset, + AssetShortcut.player_room == pr, + ).execute() diff --git a/server/src/api/socket/asset_manager/core.py b/server/src/api/socket/asset_manager/core.py index 542e2b652..07ed090c0 100644 --- a/server/src/api/socket/asset_manager/core.py +++ b/server/src/api/socket/asset_manager/core.py @@ -12,7 +12,6 @@ from ....db.models.user import User from ....logs import logger from ....state.asset import asset_state -from ....state.game import game_state from ....transform.to_api.asset import transform_asset from ....utils import ASSETS_DIR, get_asset_hash_subpath from ...models.asset import ( @@ -24,19 +23,14 @@ ApiAssetRename, ApiAssetUpload, ) -from ..constants import ASSET_NS, GAME_NS +from ..constants import ASSET_NS from .ddraft import handle_ddraft_file +# todo: This used to send the entire asset list to the client, +# we should now only send the relevant update async def update_live_game(user: User): - for sid, pr in game_state._sid_map.items(): - if pr.player == user: - await sio.emit( - "Asset.List.Set", - Asset.get_user_structure(user), - room=sid, - namespace=GAME_NS, - ) + pass @sio.on("connect", namespace=ASSET_NS) @@ -58,22 +52,19 @@ async def disconnect(sid): await asset_state.remove_sid(sid) -async def _get_folder(asset: Asset, user: User, sid: str, *, path: list[int] | None): +def _get_folder(asset: Asset, user: User, sid: str, *, path: list[int] | None): if asset.can_be_accessed_by(user, right="all"): shared_parent = None if sp := asset.get_shared_parent(user): shared_parent = transform_asset(sp.asset, user) - await sio.emit( - "Folder.Set", + return ( ApiAssetFolder( folder=transform_asset(asset, user, children=True), sharedParent=shared_parent, sharedRight=None if sp is None else sp.right, path=path, ), - room=sid, - namespace=ASSET_NS, ) else: raise web.HTTPForbidden() @@ -92,7 +83,7 @@ async def get_folder(sid: str, folder: int | None = None): else: asset = Asset.get_by_id(folder) - await _get_folder(asset, user, sid, path=None) + return _get_folder(asset, user, sid, path=None) @sio.on("Folder.GetByPath", namespace=ASSET_NS) @@ -114,7 +105,7 @@ async def get_folder_by_path(sid: str, folder: str): target_folder = root_folder break - await _get_folder(target_folder, user, sid, path=id_path) + return _get_folder(target_folder, user, sid, path=id_path) @sio.on("Folder.Create", namespace=ASSET_NS) @@ -373,3 +364,30 @@ async def assetmgmt_upload(sid: str, raw_data: Any): await update_live_game(user) return return_data + + +@sio.on("Asset.Search", namespace=ASSET_NS) +@auth.login_required(app, sio, "asset") +async def assetmgmt_search(sid: str, query: str): + user = asset_state.get_user(sid) + + assets = Asset.select().where(Asset.owner == user & Asset.name.contains(query)).order_by(Asset.name) # type: ignore + + return [transform_asset(asset, user) for asset in assets] + + +@sio.on("Asset.FolderPath", namespace=ASSET_NS) +@auth.login_required(app, sio, "asset") +async def get_folder_path(sid: str, asset_id: int): + user = asset_state.get_user(sid) + + asset = Asset.get_by_id(asset_id) + if not asset.can_be_accessed_by(user, right="view"): + return [] + + path = [] + while asset is not None: + path.insert(0, {"id": asset.id, "name": asset.name}) + asset = asset.parent + + return path diff --git a/server/src/api/socket/location.py b/server/src/api/socket/location.py index 113d80ccf..e891e9815 100644 --- a/server/src/api/socket/location.py +++ b/server/src/api/socket/location.py @@ -9,7 +9,7 @@ from ...app import app, sio from ...config import config from ...db.create.floor import create_floor -from ...db.models.asset import Asset +from ...db.models.asset_shortcut import AssetShortcut from ...db.models.character import Character from ...db.models.floor import Floor from ...db.models.initiative import Initiative @@ -28,6 +28,7 @@ from ...models.access import has_ownership from ...models.role import Role from ...state.game import game_state +from ...transform.to_api.asset import transform_asset from ...transform.to_api.floor import transform_floor from ..helpers import _send_game from ..models.client import OptionalClientViewport @@ -278,10 +279,12 @@ async def load_location(sid: str, location: Location, *, complete=False): # 9. Load Assets - if complete: - # todo: pydantic + if complete and IS_DM: + shortcuts = AssetShortcut.select().where(AssetShortcut.player_room == pr) await _send_game( - "Asset.List.Set", Asset.get_user_structure(pr.player), room=sid + "Asset.Shortcuts.Set", + [transform_asset(shortcut.asset, pr.player) for shortcut in shortcuts], + room=sid, ) await _send_game("Location.Loaded", room=sid, data=None) diff --git a/server/src/db/all.py b/server/src/db/all.py index 54d6bd0b3..d3e0843c4 100644 --- a/server/src/db/all.py +++ b/server/src/db/all.py @@ -1,4 +1,6 @@ # Has to appear before Asset due to DeferredForeignKey +from .models.asset_shortcut import AssetShortcut + from .models.asset_share import AssetShare # isort: skip from .models.asset import Asset from .models.asset_rect import AssetRect @@ -43,6 +45,7 @@ AssetRect, Asset, AssetShare, + AssetShortcut, Aura, BaseRect, Character, diff --git a/server/src/db/models/asset.py b/server/src/db/models/asset.py index 27cae0841..e8896103e 100644 --- a/server/src/db/models/asset.py +++ b/server/src/db/models/asset.py @@ -49,7 +49,7 @@ def set_options(self, options: Dict[str, Any]) -> None: def get_child(self, name: str) -> "Asset | None": asset = Asset.get_or_none( - (Asset.owner == self.owner) & (Asset.parent == self) & (Asset.name == name) + (Asset.owner == self.owner) & (Asset.parent == self) & (Asset.name == name) # type: ignore ) if not asset: if share := AssetShare.get_or_none(user=self.owner, name=name, parent=self): diff --git a/server/src/db/models/asset_shortcut.py b/server/src/db/models/asset_shortcut.py new file mode 100644 index 000000000..1d0343b3f --- /dev/null +++ b/server/src/db/models/asset_shortcut.py @@ -0,0 +1,24 @@ +from typing import cast + +from peewee import ForeignKeyField + +from ..base import BaseDbModel +from .asset import Asset +from .player_room import PlayerRoom + + +class AssetShortcut(BaseDbModel): + id: int + + asset = cast( + "Asset", + ForeignKeyField( + Asset, + backref="shortcuts", + on_delete="CASCADE", + ), + ) + player_room = cast( + "PlayerRoom", + ForeignKeyField(PlayerRoom, backref="asset_shortcuts", on_delete="CASCADE"), + ) diff --git a/server/src/save.py b/server/src/save.py index 506f8eb69..9eb6a654c 100644 --- a/server/src/save.py +++ b/server/src/save.py @@ -14,7 +14,7 @@ - e.g. a column added to Circle also needs to be added to CircularToken """ -SAVE_VERSION = 99 +SAVE_VERSION = 100 import asyncio import json @@ -534,6 +534,12 @@ def upgrade( ).fetchall() loop.create_task(generate_thumbnails(data, loop)) + elif version == 99: + # Add AssetShortcut + with db.atomic(): + db.execute_sql( + "CREATE TABLE IF NOT EXISTS asset_shortcut (id INTEGER NOT NULL PRIMARY KEY, asset_id INTEGER NOT NULL, player_room_id INTEGER NOT NULL, FOREIGN KEY (asset_id) REFERENCES asset (id) ON DELETE CASCADE, FOREIGN KEY (player_room_id) REFERENCES player_room (id) ON DELETE CASCADE)" + ) else: raise UnknownVersionException( f"No upgrade code for save format {version} was found." diff --git a/server/src/transform/to_api/asset.py b/server/src/transform/to_api/asset.py index 79b8ed983..9f4927e34 100644 --- a/server/src/transform/to_api/asset.py +++ b/server/src/transform/to_api/asset.py @@ -27,7 +27,7 @@ def transform_asset( ) # We check if there are any assets that were shared with us that are located in this folder for child in AssetShare.select().where( - (AssetShare.parent == asset) & (AssetShare.user == user) + (AssetShare.parent == asset) & (AssetShare.user == user) # type: ignore ): pydantic_children.append( transform_asset(