diff --git a/frontend/src/router.js b/frontend/src/router.js index f070d8e..b355620 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -13,7 +13,6 @@ import GroupDetail from '@/views/GroupDetail.vue'; import Inventory from '@/views/Inventory.vue'; import Search from '@/views/Search.vue'; import InventoryDetail from '@/views/InventoryDetail.vue'; -import InventoryDetailForeign from '@/views/InventoryDetailForeign.vue'; import InventoryNew from '@/views/InventoryNew.vue'; import InventoryEdit from '@/views/InventoryEdit.vue'; import StorageLocation from '@/views/StorageLocation.vue'; @@ -47,9 +46,18 @@ export function decodeHandleFromUrl(segment) { return segment.replace(/\+/g, "#"); } +// Both item-ish kinds land on the same /inventory/:handle/:id shape - only how they resolve +// their owner's handle differs (a personal owner vs. a group, see identityHandleById/ +// groupHandleById in store.js), so that resolution is the only part that stays separate. +function itemDetailRoute(handle, item_local_id) { + return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null; +} + const EXPANDED_ROUTE_BUILDERS = { - item: ({item_local_id}) => `/inventory/${item_local_id}`, - group_item: ({item_local_id}) => `/inventory/${item_local_id}`, + item: ({owner_identity_id, item_local_id}) => + itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id), + group_item: ({owner_group_id, item_local_id}) => + itemDetailRoute(store.getters.groupHandleById[owner_group_id], item_local_id), group: ({group_id}) => `/groups/${group_id}`, storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`, workflow: ({workflow_id}) => `/workflows/${workflow_id}`, @@ -69,27 +77,24 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { component: Inventory, meta: {requiresAuth: true} }, { - path: '/inventory/:id', + path: '/inventory/:handle/:id', component: InventoryDetail, meta: {requiresAuth: true}, props: true }, { - path: '/inventory/:id/edit', + path: '/inventory/:handle/:id/edit', component: InventoryEdit, meta: {requiresAuth: true}, props: true }, { - path: '/inventory/shared/:user/:id', - component: InventoryDetailForeign, - meta: {requiresAuth: true, foreign: true}, - props: true -}, { + // The self-contained label/short-link entry point (see label.js's LABEL_CONTENT_BUILDERS + // and docs/design-in-progress/items-labels.md) - :handle is already URL-escaped the same + // way /inventory/:handle/:id expects it, so this is just a shorter alias for that route, + // with no owner-is-the-viewer special case: get_shared_item (and friends_or_self()) already + // treat "it's the viewer's own item" as one case of "the viewer may see this owner's item", + // not a separate path. path: '/i/:handle/:id', - redirect: to => { - const handle = decodeHandleFromUrl(to.params.handle) - const {id} = to.params - return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + encodeHandleForUrl(handle) + '/' + id - } + redirect: to => `/inventory/${to.params.handle}/${to.params.id}` }, { path: '/:short_id', redirect: to => { diff --git a/frontend/src/store.js b/frontend/src/store.js index 9e3198a..cd61ff3 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -444,6 +444,31 @@ export default createStore({ return null; } }, + // A group handle (leading '#') has no working owner-handle GET route yet + // (get_shared_item, which fetchForeignItem calls, only resolves a personal + // ToolshedUser handle) - resolve it instead via the already-correct, already- + // authenticated group listing (?group=, see fetchGroupInventoryItems) and pick the + // matching item out of that, which only ever contains this one group's own items, so an + // id collision with anything else can't happen. A personal/friend handle still goes + // through fetchForeignItem as before. + async fetchItemByHandle({dispatch, getters}, {handle, id}) { + if (handle.startsWith('#')) { + // groupIdByHandle is derived from state.idmap (see store.js's getters), which + // nothing guarantees is loaded yet at this point - unlike Inventory.vue/ + // StorageLocation.vue/Print.vue, a direct or refreshed visit to an item's own + // detail/edit page never fetched it. Loading it here, every time, is simplest; + // fetchIdMap is cheap and already called unconditionally (no cache check) by + // every other caller too. + await dispatch('fetchIdMap') + const groupId = getters.groupIdByHandle[handle] + if (groupId === undefined) { + return null + } + const items = await dispatch('fetchGroupInventoryItems', groupId) + return items.find(item => item.id === parseInt(id)) || null + } + return dispatch('fetchForeignItem', {owner: handle, id}) + }, async fetchFriendRequests({state, dispatch, getters}) { const servers = await dispatch('getHomeServers') return await servers.get(getters.signAuth, '/api/friendrequests/') @@ -802,6 +827,15 @@ export default createStore({ groupIdByHandle(state) { return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id])) }, + // Reverse of the two getters above - turns a short-id's raw owner_identity_id/ + // owner_group_id back into a handle (see router.js's EXPANDED_ROUTE_BUILDERS), without + // a separate backend lookup since the idmap already has both directions of this data. + identityHandleById(state) { + return Object.fromEntries(state.idmap.identities.map(i => [i.id, i.username])) + }, + groupHandleById(state) { + return Object.fromEntries(state.idmap.groups.map(g => [g.id, g.handle])) + }, loaded_items(state) { return Object.entries(state.item_map).reduce((acc, [url, items]) => { return acc.concat(items) diff --git a/frontend/src/views/GroupDetail.vue b/frontend/src/views/GroupDetail.vue index 4d8aa15..181b089 100644 --- a/frontend/src/views/GroupDetail.vue +++ b/frontend/src/views/GroupDetail.vue @@ -73,17 +73,17 @@ - {{ item.name }} + {{ item.name }} {{ item.availability_policy }} {{ item.owned_quantity }} - + - + @@ -96,7 +96,7 @@
- + {{ item.name }}
@@ -108,7 +108,7 @@ - Edit
@@ -134,6 +134,7 @@ import {mapActions, mapGetters, mapState} from "vuex"; import * as BIcons from "bootstrap-icons-vue"; import BaseLayout from "@/components/BaseLayout.vue"; import UserNameTag from "@/components/UserNameTag.vue"; +import {encodeHandleForUrl} from "@/router"; export default { name: 'GroupDetail', @@ -174,6 +175,9 @@ export default { fetchItems() { this.fetchGroupInventoryItems(this.id) }, + itemRoute(item) { + return `/inventory/${encodeHandleForUrl(this.group.handle)}/${item.id}` + }, tryInvite() { this.inviteToGroup({groupId: this.id, groupHandle: this.group.handle, invitee: this.invitee}) .then((ok) => { diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index 257f4ba..7a28645 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -27,17 +27,17 @@ - {{ item.name }} + {{ item.name }} {{ item.availability_policy }} {{ item.owned_quantity }} - + - + @@ -62,7 +62,7 @@ class="card-img-top img-preview"/>
- + {{ item.name }}
@@ -73,7 +73,7 @@ - Edit file.mime_type.startsWith("image/")); }, diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index bc3bf20..5619a74 100644 --- a/frontend/src/views/InventoryDetail.vue +++ b/frontend/src/views/InventoryDetail.vue @@ -6,6 +6,10 @@
{{ item.name }}
+
+ + {{ item.owner || item.owner_group }} +
{{ item.description }} @@ -36,8 +40,8 @@
-
- @@ -46,8 +50,8 @@ Delete - @@ -64,6 +68,7 @@ import * as BIcons from "bootstrap-icons-vue"; import BaseLayout from "@/components/BaseLayout.vue"; import {mapActions, mapGetters, mapState} from "vuex"; import AuthenticatedImage from "@/components/AuthenticatedImage.vue"; +import {decodeHandleFromUrl} from "@/router"; export default { name: "InventoryDetail", @@ -73,27 +78,52 @@ export default { ...BIcons }, props: { + handle: { + type: String, + required: true + }, id: { type: String, required: true } }, + data() { + return { + item: {} + } + }, computed: { - ...mapGetters(["loaded_items", "getNameFromHandle"]), - ...mapState(["storage_locations", "user"]), - item() { - return this.loaded_items.find(item => item.id === parseInt(this.id)) || {} + ...mapGetters(["getNameFromHandle", "groupIdByHandle"]), + ...mapState(["user"]), + decodedHandle() { + return decodeHandleFromUrl(this.handle) }, - location() { - return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null + // Edit/Delete apply once the viewer is actually authorized to act on this item - their + // own item, or a group they belong to - not just anyone who can view it + // (get_shared_item's friends_or_self() also lets a friend view a shared item, but never + // act on it). + canEdit() { + return this.decodedHandle === this.user || this.decodedHandle in this.groupIdByHandle + }, + // Printed labels only support a personal owner handle so far (see label.js's + // splitUserHandle/Inventory.vue's printLinkFor) - group items don't get a print link + // until that's supported too. + canPrint() { + return this.decodedHandle === this.user } }, methods: { - ...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchFilesByItem", "fetchStorageLocations"]), + ...mapActions(["fetchItemByHandle", "deleteInventoryItem"]), + async loadItem() { + this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {} + } }, - async mounted() { - await this.fetchInventoryItems() - await this.fetchStorageLocations() + watch: { + handle: 'loadItem', + id: 'loadItem' + }, + mounted() { + this.loadItem() } } @@ -104,4 +134,4 @@ img { height: 107px; object-fit: contain; } - \ No newline at end of file + diff --git a/frontend/src/views/InventoryDetailForeign.vue b/frontend/src/views/InventoryDetailForeign.vue deleted file mode 100644 index 313ffa6..0000000 --- a/frontend/src/views/InventoryDetailForeign.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - - - diff --git a/frontend/src/views/InventoryEdit.vue b/frontend/src/views/InventoryEdit.vue index 331e02b..a711c9e 100644 --- a/frontend/src/views/InventoryEdit.vue +++ b/frontend/src/views/InventoryEdit.vue @@ -70,11 +70,12 @@