This commit is contained in:
j3d1 2026-08-27 19:41:52 +02:00
parent ebd0db4b43
commit 2bb6624d50
4 changed files with 156 additions and 216 deletions

View file

@ -619,12 +619,16 @@ export default createStore({
const {domain} = splitGroupHandle(groupHandle)
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
const items = await servers.get(getters.signAuth, '/api/v1/inventory_items/' + encodeHandleForUrl(groupHandle) + '/')
// Keyed by the full handle, not a bare id: a group pk is only unique within its own
// backend's database, so two different domains could otherwise collide on the same
// item_map key - the handle already carries the domain, so no separate namespacing is needed.
commit('setInventoryItems', {url: '/' + groupHandle, items})
return items
},
async fetchFriendInventoryItems({commit, dispatch, getters}, {friendHandle}) {
const servers = await dispatch('getFriendServers', {username: friendHandle})
const items = await servers.get(getters.signAuth, '/api/v1/inventory_items/' + encodeHandleForUrl(friendHandle) + '/')
items.map(item => item.files.map(file => file.owner = item.owner))
commit('setInventoryItems', {url: '/' + friendHandle, items})
return items
},
async fetchFiles({state, commit, dispatch, getters}) {
if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) {
return state.files

View file

@ -4,7 +4,7 @@
<div class="container-fluid p-0">
<h1 class="h3 mb-3">{{ group ? group.handle : '' }}</h1>
<div class="row" v-if="group">
<div class="col-12 col-xl-6">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="card-title">Members</h5>
@ -50,136 +50,6 @@
</table>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card">
<div class="card-header">
<h5 class="card-title">Group inventory</h5>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
<table class="table table-striped" v-if="layout === 'table'">
<thead>
<tr>
<th style="width:40%;">Name</th>
<th style="width:25%">Availability Policy</th>
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id">
<td>
<router-link :to="itemRoute(item)">{{ item.name }}</router-link>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
</td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action">
<router-link :to="`${itemRoute(item)}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`${itemRoute(item)}/delete`" @click.prevent="tryDeleteItem(item)">
<b-icon-trash></b-icon-trash>
</a>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td>
</tr>
</tbody>
</table>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in items" :key="item.id">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="itemRoute(item)">
{{ item.name }}
</router-link>
</h5>
<div class="card-text text-black-50">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
<span class="float-right">{{ item.owned_quantity }}</span>
</div>
<div class="btn-group">
<button class="btn btn-danger btn-sm" @click="tryDeleteItem(item)">
Delete
</button>
<router-link :to="`${itemRoute(item)}/edit`"
class="btn btn-primary btn-sm">Edit
</router-link>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
class="btn btn-secondary btn-sm">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<button class="btn" @click="fetchItems">Refresh</button>
<router-link :to="`/inventory/new?group=${encodeHandleForUrl(group.handle)}`"
class="btn btn-primary">Add
</router-link>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card">
<div class="card-header">
<h5 class="card-title">Group storage locations</h5>
</div>
<table class="table table-striped">
<thead>
<tr>
<th style="width:40%;">Name</th>
<th style="width:25%">Path</th>
<th class="d-none d-md-table-cell" style="width:25%">Category</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="location in storageLocations" :key="location.id">
<td>
<router-link :to="locationRoute(location)">{{ location.name }}</router-link>
</td>
<td class="d-none d-md-table-cell">
<span class="text-muted">{{ location.path }}</span>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
<span class="text-muted" v-else>-</span>
</td>
<td class="table-action">
<router-link :to="`${locationRoute(location)}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`${locationRoute(location)}/delete`" @click.prevent="tryDeleteLocation(location)">
<b-icon-trash></b-icon-trash>
</a>
<router-link v-if="locationPrintLinkFor(location)" :to="locationPrintLinkFor(location)">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<button class="btn" @click="fetchLocations">Refresh</button>
<router-link :to="`/storage-locations/new?group=${encodeHandleForUrl(group.handle)}`"
class="btn btn-primary">Add
</router-link>
</div>
</div>
</div>
</div>
</main>
@ -187,11 +57,11 @@
</template>
<script>
import {mapActions, mapGetters} from "vuex";
import {mapActions} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import UserNameTag from "@/components/UserNameTag.vue";
import {decodeHandleFromUrl, encodeHandleForUrl} from "@/router";
import {decodeHandleFromUrl} from "@/router";
export default {
name: 'GroupDetail',
@ -209,7 +79,6 @@ export default {
data() {
return {
group: null,
layout: "grid",
show_invite: false,
invitee: ""
}
@ -217,19 +86,10 @@ export default {
computed: {
decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
items() {
return this.group ? this.groupInventoryItems(this.group.handle) : []
},
storageLocations() {
return this.group ? this.groupStorageLocations(this.group.handle) : []
},
...mapGetters(['groupInventoryItems', 'groupStorageLocations'])
}
},
methods: {
encodeHandleForUrl,
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
'deleteInventoryItem', 'fetchGroupStorageLocations', 'deleteStorageLocation']),
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup']),
// Loads by handle regardless of which backend actually hosts the group - see
// store.js's fetchGroup.
refresh() {
@ -237,33 +97,6 @@ export default {
this.group = group
})
},
fetchItems() {
if (this.group) {
this.fetchGroupInventoryItems({groupHandle: this.group.handle})
}
},
itemRoute(item) {
return `/inventory/${encodeHandleForUrl(this.group.handle)}/${item.id}`
},
printLinkFor(item) {
return {path: '/print', query: {kind: 'item', userHandle: this.group.handle, item: item.id}}
},
fetchLocations() {
if (this.group) {
this.fetchGroupStorageLocations({groupHandle: this.group.handle})
}
},
locationRoute(location) {
return `/storage-locations/${encodeHandleForUrl(this.group.handle)}/${location.id}`
},
locationPrintLinkFor(location) {
return {path: '/print', query: {kind: 'storage-location', userHandle: this.group.handle, location: location.id}}
},
tryDeleteLocation(location) {
this.deleteStorageLocation(location).then(() => {
this.fetchLocations()
})
},
tryInvite() {
this.inviteToGroup({groupHandle: this.group.handle, invitee: this.invitee}).then((ok) => {
if (ok) {
@ -279,18 +112,10 @@ export default {
this.refresh()
}).catch(() => {
})
},
tryDeleteItem(item) {
this.deleteInventoryItem(item).then(() => {
this.fetchItems()
})
}
},
mounted() {
this.refresh().then(() => {
this.fetchItems()
this.fetchLocations()
})
this.refresh()
}
}
</script>

View file

@ -2,12 +2,26 @@
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Inventory Own & Friends"</h1>
<h1 class="h3 mb-3">Inventory</h1>
<div class="row mb-3">
<div class="col-12 col-md-4">
<label for="ownerSelect" class="form-label">Viewing inventory for</label>
<select id="ownerSelect" class="form-select" v-model="selectedOwner">
<option :value="user">{{ user }} (you)</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
<option v-for="friend in friends" :value="friend.handle" :key="friend.handle">
{{ friend.handle }}
</option>
</select>
</div>
</div>
<div class="row">
<div class="col-12 col-xl-12">
<div class="card">
<div class="card-header">
<h5 class="card-title">{{ user }}'s Inventory</h5>
<h5 class="card-title">{{ selectedOwner }}'s Inventory</h5>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
@ -25,7 +39,7 @@
</tr>
</thead>
<tbody>
<tr v-for="item in inventory_items" :key="item.id">
<tr v-for="item in items" :key="item.id">
<td>
<router-link :to="itemRoute(item)">{{ item.name }}</router-link>
</td>
@ -34,10 +48,10 @@
</td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action">
<router-link :to="`${itemRoute(item)}/edit`">
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`${itemRoute(item)}/delete`" @click.prevent="deleteInventoryItem(item)">
<a v-if="canEdit" :href="`${itemRoute(item)}/delete`" @click.prevent="tryDeleteItem(item)">
<b-icon-trash></b-icon-trash>
</a>
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
@ -52,7 +66,7 @@
</table>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in inventory_items"
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in items"
:key="item.id">
<div class="card">
<img :src="item.image" class="" :alt="item.image">
@ -70,10 +84,10 @@
<span class="float-right">{{ item.owned_quantity }}</span>
</div>
<div class="btn-group">
<button class="btn btn-danger btn-sm"
@click="deleteInventoryItem(item)">Delete
<button v-if="canEdit" class="btn btn-danger btn-sm"
@click="tryDeleteItem(item)">Delete
</button>
<router-link :to="`${itemRoute(item)}/edit`"
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`"
class="btn btn-primary btn-sm">Edit
</router-link>
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
@ -93,8 +107,8 @@
</div>
<div class="card">
<button class="btn" @click="fetchInventoryItems">Refresh</button>
<router-link to="/inventory/new" class="btn btn-primary">Add</router-link>
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
<router-link v-if="canEdit" :to="addItemRoute" class="btn btn-primary">Add</router-link>
</div>
</div>
</div>
@ -115,6 +129,7 @@ export default {
data() {
return {
layout: "grid",
selectedOwner: null,
}
},
components: {
@ -123,15 +138,53 @@ export default {
...BIcons
},
computed: {
...mapGetters(["inventory_items", "loaded_items", "identityIdByHandle", "groupIdByHandle"]),
...mapState(["user", "storage_locations"]),
...mapGetters(["inventory_items", "groupInventoryItems", "loaded_items", "identityIdByHandle", "groupIdByHandle"]),
...mapState(["user", "storage_locations", "groups", "groupMemberships", "friends"]),
// Groups hosted here plus groups only known via a GroupMembership pointer - see
// Groups.vue's allGroups and InventoryNew.vue's ownerGroups for the same merge/dedupe.
ownerGroups() {
const hostedHandles = new Set(this.groups.map(group => group.handle))
const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle))
return [...this.groups, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle))
},
isGroupSelected() {
return !!this.selectedOwner && this.selectedOwner.startsWith('#')
},
// Own items and group items can be created/edited/deleted here; a friend's items are
// shown for browsing only - the backend rejects writes for anyone but the owner or a
// fellow group member (see inventory.py's perform_create/update/destroy).
canEdit() {
return this.selectedOwner === this.user || this.isGroupSelected
},
items() {
// item_map is keyed by owner handle regardless of whether that owner is a group or a
// friend, so the same getter serves both - see store.js's groupInventoryItems.
return this.selectedOwner === this.user ? this.inventory_items : this.groupInventoryItems(this.selectedOwner)
},
addItemRoute() {
return this.selectedOwner === this.user
? '/inventory/new'
: `/inventory/new?group=${encodeHandleForUrl(this.selectedOwner)}`
}
},
methods: {
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap"]),
// Always the viewer's own items (fetchInventoryItems has no group filter); the owner
// handle is always included so /inventory/:handle/:id has exactly one shape.
...mapActions(["fetchInventoryItems", "fetchGroupInventoryItems", "fetchFriendInventoryItems",
"deleteInventoryItem", "fetchStorageLocations", "fetchIdMap", "fetchGroups",
"fetchGroupMemberships", "fetchFriends"]),
fetchItemsForOwner() {
if (this.selectedOwner === this.user) return this.fetchInventoryItems()
if (this.isGroupSelected) return this.fetchGroupInventoryItems({groupHandle: this.selectedOwner})
return this.fetchFriendInventoryItems({friendHandle: this.selectedOwner})
},
tryDeleteItem(item) {
this.deleteInventoryItem(item).then(() => {
this.fetchItemsForOwner()
})
},
// The owner handle is derived from the item itself, not the current selection, so a
// link stays correct even if the dropdown selection changes underneath it.
itemRoute(item) {
return `/inventory/${encodeHandleForUrl(this.user)}/${item.id}`
return `/inventory/${encodeHandleForUrl(item.owner_group || item.owner)}/${item.id}`
},
only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/"));
@ -156,10 +209,22 @@ export default {
return {path: '/print', query: {kind: 'item', userHandle, item: item.id}}
},
},
watch: {
selectedOwner() {
this.fetchItemsForOwner()
}
},
created() {
// Set before the first render so addItemRoute/items never see selectedOwner=null
// while user is already populated (which would misroute to the group branch).
this.selectedOwner = this.user
},
async mounted() {
await this.fetchInventoryItems()
await this.fetchStorageLocations()
await this.fetchIdMap()
await this.fetchGroups()
await this.fetchGroupMemberships()
await this.fetchFriends()
}
}
</script>

View file

@ -3,11 +3,22 @@
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Storage Locations</h1>
<div class="row mb-3">
<div class="col-12 col-md-4">
<label for="ownerSelect" class="form-label">Viewing storage locations for</label>
<select id="ownerSelect" class="form-select" v-model="selectedOwner">
<option :value="user">{{ user }} (you)</option>
<option v-for="group in ownerGroups" :value="group.handle" :key="group.handle">
{{ group.handle }}
</option>
</select>
</div>
</div>
<div class="row">
<div class="col-12 col-xl-12">
<div class="card">
<div class="card-header">
<h5 class="card-title">{{ user }}'s Storage Locations</h5>
<h5 class="card-title">{{ selectedOwner }}'s Storage Locations</h5>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
@ -25,7 +36,7 @@
</tr>
</thead>
<tbody>
<tr v-for="location in storage_locations" :key="location.id">
<tr v-for="location in locations" :key="location.id">
<td>
<router-link :to="locationRoute(location)">{{ location.name }}</router-link>
</td>
@ -40,7 +51,7 @@
<router-link :to="`${locationRoute(location)}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`${locationRoute(location)}/delete`" @click.prevent="deleteStorageLocation(location)">
<a :href="`${locationRoute(location)}/delete`" @click.prevent="tryDeleteLocation(location)">
<b-icon-trash></b-icon-trash>
</a>
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
@ -55,7 +66,7 @@
</table>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="location in storage_locations"
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="location in locations"
:key="location.id">
<div class="card">
<div class="card-body">
@ -73,7 +84,7 @@
</div>
<div class="btn-group mt-2">
<button class="btn btn-danger btn-sm"
@click="deleteStorageLocation(location)">Delete
@click="tryDeleteLocation(location)">Delete
</button>
<router-link :to="`${locationRoute(location)}/edit`"
class="btn btn-primary btn-sm">Edit
@ -95,8 +106,8 @@
</div>
<div class="card">
<button class="btn" @click="fetchStorageLocations">Refresh</button>
<router-link to="/storage-locations/new" class="btn btn-primary">Add</router-link>
<button class="btn" @click="fetchLocationsForOwner">Refresh</button>
<router-link :to="addLocationRoute" class="btn btn-primary">Add</router-link>
</div>
</div>
</div>
@ -116,6 +127,7 @@ export default {
data() {
return {
layout: "grid",
selectedOwner: null,
}
},
components: {
@ -123,14 +135,37 @@ export default {
...BIcons
},
computed: {
...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
...mapState(["user", "storage_locations"]),
...mapGetters(["identityIdByHandle", "groupIdByHandle", "groupStorageLocations"]),
...mapState(["user", "storage_locations", "groups", "groupMemberships"]),
// Groups hosted here plus groups only known via a GroupMembership pointer - see
// Groups.vue's allGroups and Inventory.vue's ownerGroups for the same merge/dedupe.
ownerGroups() {
const hostedHandles = new Set(this.groups.map(group => group.handle))
const foreign = this.groupMemberships.filter(m => !hostedHandles.has(m.handle))
return [...this.groups, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle))
},
locations() {
return this.selectedOwner === this.user ? this.storage_locations : this.groupStorageLocations(this.selectedOwner)
},
addLocationRoute() {
return this.selectedOwner === this.user
? '/storage-locations/new'
: `/storage-locations/new?group=${encodeHandleForUrl(this.selectedOwner)}`
}
},
methods: {
...mapActions(["fetchStorageLocations", "deleteStorageLocation", "fetchIdMap"]),
// This list is always the caller's own personal locations (fetchStorageLocations has no
// group content), so the owner_group branches below are currently unreachable here - kept
// for parity with Inventory.vue's shortIdLink/printLinkFor in case that ever changes.
...mapActions(["fetchStorageLocations", "fetchGroupStorageLocations", "deleteStorageLocation",
"fetchIdMap", "fetchGroups", "fetchGroupMemberships"]),
fetchLocationsForOwner() {
return this.selectedOwner === this.user
? this.fetchStorageLocations()
: this.fetchGroupStorageLocations({groupHandle: this.selectedOwner})
},
tryDeleteLocation(location) {
this.deleteStorageLocation(location).then(() => {
this.fetchLocationsForOwner()
})
},
locationRoute(location) {
return `/storage-locations/${encodeHandleForUrl(location.owner_group || location.owner)}/${location.id}`
},
@ -153,9 +188,20 @@ export default {
return {path: '/print', query: {kind: 'storage-location', userHandle, location: location.id}}
},
},
watch: {
selectedOwner() {
this.fetchLocationsForOwner()
}
},
created() {
// Set before the first render so addLocationRoute/locations never see selectedOwner=null
// while user is already populated (which would misroute to the group branch).
this.selectedOwner = this.user
},
async mounted() {
await this.fetchStorageLocations()
await this.fetchIdMap()
await this.fetchGroups()
await this.fetchGroupMemberships()
}
}
</script>