This commit is contained in:
j3d1 2026-09-04 14:41:26 +02:00
parent 3cd9200d87
commit e88dc898b3
18 changed files with 298 additions and 84 deletions

View file

@ -9,7 +9,7 @@
<SearchBox/> <SearchBox/>
<div class="navbar-collapse collapse"> <div class="navbar-collapse collapse">
<ul class="navbar-nav navbar-align"> <ul class="navbar-nav navbar-align">
<router-link to="/scan?navigate=immediate" class="nav-icon"> <router-link :to="{name: 'scan', query: {navigate: 'immediate'}}" class="nav-icon">
<b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan> <b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan>
</router-link> </router-link>
<Notifications :notifications="notifications"/> <Notifications :notifications="notifications"/>

View file

@ -1,7 +1,7 @@
<template> <template>
<nav id="sidebar" class="sidebar"> <nav id="sidebar" class="sidebar">
<div class="sidebar-content"> <div class="sidebar-content">
<router-link to="/" class="sidebar-brand"> <router-link :to="{name: 'dashboard'}" class="sidebar-brand">
<img src="/src/assets/icons/toolshed-48x48.png" alt="Toolshed Logo" class="align-middle logo mr-2 h-75"> <img src="/src/assets/icons/toolshed-48x48.png" alt="Toolshed Logo" class="align-middle logo mr-2 h-75">
<span class="align-middle">Toolshed</span> <span class="align-middle">Toolshed</span>
</router-link> </router-link>
@ -10,61 +10,61 @@
Tools & Components Tools & Components
</li> </li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/inventory" class="sidebar-link"> <router-link :to="{name: 'inventory'}" class="sidebar-link">
<b-icon-archive class="bi-valign-middle"></b-icon-archive> <b-icon-archive class="bi-valign-middle"></b-icon-archive>
<span class="align-middle">Inventory</span> <span class="align-middle">Inventory</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/storage-location" class="sidebar-link"> <router-link :to="{name: 'locations'}" class="sidebar-link">
<b-icon-boxes class="bi-valign-middle"></b-icon-boxes> <b-icon-boxes class="bi-valign-middle"></b-icon-boxes>
<span class="align-middle">Storage Locations</span> <span class="align-middle">Storage Locations</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/workflows" class="sidebar-link"> <router-link :to="{name: 'workflows'}" class="sidebar-link">
<b-icon-diagram3 class="bi-valign-middle"></b-icon-diagram3> <b-icon-diagram3 class="bi-valign-middle"></b-icon-diagram3>
<span class="align-middle">Workflows</span> <span class="align-middle">Workflows</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/friends" class="sidebar-link"> <router-link :to="{name: 'friends'}" class="sidebar-link">
<b-icon-people class="bi-valign-middle"></b-icon-people> <b-icon-people class="bi-valign-middle"></b-icon-people>
<span class="align-middle">Friends</span> <span class="align-middle">Friends</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/groups" class="sidebar-link"> <router-link :to="{name: 'groups'}" class="sidebar-link">
<b-icon-people-fill class="bi-valign-middle"></b-icon-people-fill> <b-icon-people-fill class="bi-valign-middle"></b-icon-people-fill>
<span class="align-middle">Groups</span> <span class="align-middle">Groups</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/files" class="sidebar-link"> <router-link :to="{name: 'files'}" class="sidebar-link">
<b-icon-folder class="bi-valign-middle"></b-icon-folder> <b-icon-folder class="bi-valign-middle"></b-icon-folder>
<span class="align-middle">Files</span> <span class="align-middle">Files</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/admin" class="sidebar-link"> <router-link :to="{name: 'admin'}" class="sidebar-link">
<b-icon-gear class="bi-valign-middle"></b-icon-gear> <b-icon-gear class="bi-valign-middle"></b-icon-gear>
<span class="align-middle">Admin</span> <span class="align-middle">Admin</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/swatch" class="sidebar-link"> <router-link :to="{name: 'swatch'}" class="sidebar-link">
<b-icon-gear class="bi-valign-middle"></b-icon-gear> <b-icon-gear class="bi-valign-middle"></b-icon-gear>
<span class="align-middle">Swatch</span> <span class="align-middle">Swatch</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/print" class="sidebar-link"> <router-link :to="{name: 'print'}" class="sidebar-link">
<b-icon-gear class="bi-valign-middle"></b-icon-gear> <b-icon-gear class="bi-valign-middle"></b-icon-gear>
<span class="align-middle">Print</span> <span class="align-middle">Print</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/scan" class="sidebar-link"> <router-link :to="{name: 'scan'}" class="sidebar-link">
<b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan> <b-icon-qr-code-scan class="bi-valign-middle"></b-icon-qr-code-scan>
<span class="align-middle">Scan</span> <span class="align-middle">Scan</span>
</router-link> </router-link>

View file

@ -18,11 +18,11 @@
</span> </span>
</div> </div>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
<router-link to="/profile" class="dropdown-item"> <router-link :to="{name: 'profile'}" class="dropdown-item">
<b-icon-person class="bi-valign-middle mr-1"></b-icon-person> <b-icon-person class="bi-valign-middle mr-1"></b-icon-person>
Profile Profile
</router-link> </router-link>
<router-link to="/settings" class="dropdown-item"> <router-link :to="{name: 'settings'}" class="dropdown-item">
<b-icon-sliders class="bi-valign-middle mr-1"></b-icon-sliders> <b-icon-sliders class="bi-valign-middle mr-1"></b-icon-sliders>
Settings & Settings &
Privacy Privacy

View file

@ -51,35 +51,39 @@ export function decodeHandleFromUrl(segment) {
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings. // See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
export function ownerOverviewRoute(item) { export function ownerOverviewRoute(item) {
return item.owner_group ? `/inventory/${encodeHandleForUrl(item.owner_group)}` : '/inventory'; return item.owner_group
? {name: 'inventory', params: {owner: encodeHandleForUrl(item.owner_group)}}
: {name: 'inventory'};
} }
export function ownerLocationOverviewRoute(location) { export function ownerLocationOverviewRoute(location) {
return location.owner_group ? `/storage-location/${encodeHandleForUrl(location.owner_group)}` : '/storage-location'; return location.owner_group
? {name: 'locations', params: {owner: encodeHandleForUrl(location.owner_group)}}
: {name: 'locations'};
} }
const EXPANDED_ROUTE_BUILDERS = { const EXPANDED_ROUTE_BUILDERS = {
item: ({owner_identity_id, item_local_id}) => { item: ({owner_identity_id, item_local_id}) => {
const handle = store.getters.identityHandleById[owner_identity_id]; const handle = store.getters.identityHandleById[owner_identity_id];
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null; return handle ? {name: 'inventory-detail', params: {handle: encodeHandleForUrl(handle), id: item_local_id}} : null;
}, },
group_item: ({owner_group_id, item_local_id}) => { group_item: ({owner_group_id, item_local_id}) => {
const handle = store.getters.groupHandleById[owner_group_id]; const handle = store.getters.groupHandleById[owner_group_id];
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null; return handle ? {name: 'inventory-detail', params: {handle: encodeHandleForUrl(handle), id: item_local_id}} : null;
}, },
group: ({group_id}) => { group: ({group_id}) => {
const handle = store.getters.groupHandleById[group_id]; const handle = store.getters.groupHandleById[group_id];
return handle ? `/groups/${encodeHandleForUrl(handle)}` : null; return handle ? {name: 'group-detail', params: {handle: encodeHandleForUrl(handle)}} : null;
}, },
storage_location: ({owner_identity_id, storage_location_id}) => { storage_location: ({owner_identity_id, storage_location_id}) => {
const handle = store.getters.identityHandleById[owner_identity_id]; const handle = store.getters.identityHandleById[owner_identity_id];
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null; return handle ? {name: 'locations-detail', params: {handle: encodeHandleForUrl(handle), id: storage_location_id}} : null;
}, },
group_storage_location: ({owner_group_id, storage_location_id}) => { group_storage_location: ({owner_group_id, storage_location_id}) => {
const handle = store.getters.groupHandleById[owner_group_id]; const handle = store.getters.groupHandleById[owner_group_id];
return handle ? `/storage-locations/${encodeHandleForUrl(handle)}/${storage_location_id}` : null; return handle ? {name: 'locations-detail', params: {handle: encodeHandleForUrl(handle), id: storage_location_id}} : null;
}, },
workflow: ({workflow_id}) => `/workflows/${workflow_id}`, workflow: ({workflow_id}) => ({name: 'workflow-detail', params: {id: workflow_id}}),
}; };
// Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these. // Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these.
@ -121,30 +125,33 @@ export async function domainQualifiedRoute(domain, ints) {
return undefined; return undefined;
} }
return owned.isLocation return owned.isLocation
? `/storage-locations/${encodeHandleForUrl(resolved.handle)}/${resolved.id}` ? {name: 'locations-detail', params: {handle: encodeHandleForUrl(resolved.handle), id: resolved.id}}
: `/inventory/${encodeHandleForUrl(resolved.handle)}/${resolved.id}`; : {name: 'inventory-detail', params: {handle: encodeHandleForUrl(resolved.handle), id: resolved.id}};
} }
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { const routes = [{path: '/', name: 'dashboard', component: Dashboard, meta: {requiresAuth: true}}, {
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings. // See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
path: '/inventory/:owner?', path: '/inventory/:owner?',
name: 'inventory',
component: Inventory, component: Inventory,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, { }, {
path: '/inventory/:handle/:id', path: '/inventory/:handle/:id',
name: 'inventory-detail',
component: InventoryDetail, component: InventoryDetail,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, { }, {
path: '/inventory/:handle/:id/edit', path: '/inventory/:handle/:id/edit',
name: 'inventory-edit',
component: InventoryEdit, component: InventoryEdit,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, { }, {
// Label/short-link entry point; alias of /inventory/:handle/:id. See docs/implementation.md#item-short-link-redirect-route. // Label/short-link entry point; alias of /inventory/:handle/:id. See docs/implementation.md#item-short-link-redirect-route.
path: '/i/:handle/:id', path: '/i/:handle/:id',
redirect: to => `/inventory/${to.params.handle}/${to.params.id}` redirect: to => ({name: 'inventory-detail', params: to.params})
}, { }, {
// beforeEnter, not redirect: falls through to ShortId.vue when the route can't resolve synchronously. See docs/implementation.md#beforeenter-guard-vs-redirect-for-short_id. // beforeEnter, not redirect: falls through to ShortId.vue when the route can't resolve synchronously. See docs/implementation.md#beforeenter-guard-vs-redirect-for-short_id.
path: '/:short_id', path: '/:short_id',
@ -160,28 +167,34 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
}, { }, {
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings. // See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
path: '/inventory/new/:group?', path: '/inventory/new/:group?',
name: 'inventory-new',
component: InventoryNew, component: InventoryNew,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, { }, {
path: '/friends', path: '/friends',
name: 'friends',
component: Friends, component: Friends,
meta: {requiresAuth: true} meta: {requiresAuth: true}
}, { }, {
path: '/groups', path: '/groups',
name: 'groups',
component: Groups, component: Groups,
meta: {requiresAuth: true} meta: {requiresAuth: true}
}, { }, {
path: '/groups/new', path: '/groups/new',
name: 'groups-new',
component: GroupNew, component: GroupNew,
meta: {requiresAuth: true} meta: {requiresAuth: true}
}, { }, {
path: '/groups/:handle', path: '/groups/:handle',
name: 'group-detail',
component: GroupDetail, component: GroupDetail,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, {path: '/files', component: Files, meta: {requiresAuth: true} }, {path: '/files', name: 'files', component: Files, meta: {requiresAuth: true}
}, {path: '/workflows', }, {path: '/workflows',
name: 'workflows',
component: Workflows, component: Workflows,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
}, {path: '/workflows/:id/:step?', }, {path: '/workflows/:id/:step?',
@ -190,10 +203,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, {path: '/admin', }, {path: '/admin',
name: 'admin',
component: Admin, component: Admin,
meta: {requiresAuth: true} meta: {requiresAuth: true}
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, { }, {path: '/swatch', name: 'swatch', component: Swatch, meta: {requiresAuth: true}}, {
path: '/print', path: '/print',
name: 'print',
component: Print, component: Print,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: route => { props: route => {
@ -204,23 +219,27 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
} }
}, { }, {
path: '/scan', path: '/scan',
name: 'scan',
component: Scan, component: Scan,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
}, { }, {
path: '/search/:query', path: '/search/:query',
name: 'search',
component: Search, component: Search,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, {path: '/login', component: Login, meta: {requiresAuth: false}}, { }, {path: '/login', name: 'login', component: Login, meta: {requiresAuth: false}}, {
path: '/register', path: '/register',
name: 'register',
component: Register, component: Register,
meta: {requiresAuth: false} meta: {requiresAuth: false}
}, {path: '/pairing', component: Pairing, meta: {requiresAuth: false}}, { }, {path: '/pairing', name: 'pairing', component: Pairing, meta: {requiresAuth: false}}, {
path: '/profile', path: '/profile',
name: 'profile',
component: Profile, component: Profile,
meta: {requiresAuth: true} meta: {requiresAuth: true}
}, { }, {
path: '/settings', component: Settings, meta: {requiresAuth: true}, children: [{ path: '/settings', name: 'settings', component: Settings, meta: {requiresAuth: true}, children: [{
path: '', name: 'account', component: Account, meta: {requiresAuth: true} path: '', name: 'account', component: Account, meta: {requiresAuth: true}
},{ },{
path: 'password/', name: 'password', component: Password, meta: {requiresAuth: true} path: 'password/', name: 'password', component: Password, meta: {requiresAuth: true}
@ -237,27 +256,31 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
}] }]
}, { }, {
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings. // See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
path: '/storage-location/:owner?', path: '/locations/:owner?',
name: 'locations',
component: StorageLocation, component: StorageLocation,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, { }, {
path: '/storage-locations/:handle/:id', path: '/locations/:handle/:id',
name: 'locations-detail',
component: StorageLocationDetail, component: StorageLocationDetail,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, { }, {
path: '/storage-locations/:handle/:id/edit', path: '/locations/:handle/:id/edit',
name: 'locations-edit',
component: StorageLocationEdit, component: StorageLocationEdit,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true
}, { }, {
// Label/short-link entry point for storage locations, the counterpart of /i/:handle/:id. See docs/implementation.md#item-short-link-redirect-route. // Label/short-link entry point for storage locations, the counterpart of /i/:handle/:id. See docs/implementation.md#item-short-link-redirect-route.
path: '/s/:handle/:id', path: '/s/:handle/:id',
redirect: to => `/storage-locations/${to.params.handle}/${to.params.id}` redirect: to => ({name: 'locations-detail', params: to.params})
}, { }, {
// See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings. // See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
path: '/storage-locations/new/:group?', path: '/locations/new/:group?',
name: 'locations-new',
component: StorageLocationNew, component: StorageLocationNew,
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: true props: true

View file

@ -2,17 +2,130 @@
<BaseLayout> <BaseLayout>
<main class="content"> <main class="content">
<div class="container-fluid p-0"> <div class="container-fluid p-0">
<h1 class="h3 mb-3">Dashboard</h1> <div class="d-flex justify-content-between align-items-center flex-wrap mb-3">
<h1 class="h3 mb-0">Welcome back, {{ user }}</h1>
<div class="btn-group">
<router-link :to="{name: 'inventory-new'}" class="btn btn-primary">
<b-icon-plus-lg></b-icon-plus-lg>
Add item
</router-link>
<router-link :to="{name: 'locations-new'}" class="btn btn-outline-secondary">
<b-icon-plus-lg></b-icon-plus-lg>
Add storage location
</router-link>
</div>
</div>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-6 col-md-3">
<router-link :to="{name: 'inventory'}" class="text-decoration-none">
<div class="card stat-card">
<div class="card-body d-flex align-items-center">
<b-icon-boxes class="stat-icon me-3"></b-icon-boxes>
<div>
<div class="stat-value">{{ inventory_items.length }}</div>
<div class="stat-label">Items</div>
</div>
</div>
</div>
</router-link>
</div>
<div class="col-6 col-md-3">
<router-link :to="{name: 'locations'}" class="text-decoration-none">
<div class="card stat-card">
<div class="card-body d-flex align-items-center">
<b-icon-geo-alt class="stat-icon me-3"></b-icon-geo-alt>
<div>
<div class="stat-value">{{ storage_locations.length }}</div>
<div class="stat-label">Storage locations</div>
</div>
</div>
</div>
</router-link>
</div>
<div class="col-6 col-md-3">
<router-link :to="{name: 'friends'}" class="text-decoration-none">
<div class="card stat-card">
<div class="card-body d-flex align-items-center">
<b-icon-people class="stat-icon me-3"></b-icon-people>
<div>
<div class="stat-value">{{ friends.length }}</div>
<div class="stat-label">Friends</div>
</div>
</div>
</div>
</router-link>
</div>
<div class="col-6 col-md-3">
<router-link :to="{name: 'groups'}" class="text-decoration-none">
<div class="card stat-card">
<div class="card-body d-flex align-items-center">
<b-icon-diagram3 class="stat-icon me-3"></b-icon-diagram3>
<div>
<div class="stat-value">{{ ownerGroups.length }}</div>
<div class="stat-label">Groups</div>
</div>
</div>
</div>
</router-link>
</div>
</div>
<div class="row">
<div class="col-12 col-lg-8">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">Empty card</h5> <h5 class="card-title mb-0">Recent items</h5>
<router-link :to="{name: 'inventory'}" class="small">View all</router-link>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="logo"> <div class="row" v-if="recentItems.length > 0">
<img src="/src/assets/icons/toolshed-48x48.png" alt="Toolshed logo"> <div class="col-6 col-md-4 col-xl-3" v-for="item in recentItems" :key="item.id">
<router-link :to="itemRoute(item)" class="text-decoration-none">
<div class="card">
<authenticated-image v-if="only_images(item.files).length > 0"
:src="only_images(item.files)[0].name"
:owner="only_images(item.files)[0].owner"
class="card-img-top img-preview"/>
<div class="card-body">
<h6 class="card-title mb-0 text-truncate">{{ item.name }}</h6>
<span class="text-black-50">{{ item.owned_quantity }}</span>
</div>
</div>
</router-link>
</div>
</div> </div>
<p class="text-muted mb-0" v-else>
No items yet.
<router-link :to="{name: 'inventory-new'}">Add your first one</router-link>.
</p>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0">Needs your attention</h5>
</div>
<ul class="list-group list-group-flush" v-if="hasPendingItems">
<router-link v-if="active_workflows.length > 0" :to="{name: 'workflows'}"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
Workflows in progress
<span class="badge bg-primary rounded-pill">{{ active_workflows.length }}</span>
</router-link>
<router-link v-if="friendRequests.length > 0" :to="{name: 'friends'}"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
Friend requests
<span class="badge bg-primary rounded-pill">{{ friendRequests.length }}</span>
</router-link>
<router-link v-if="groupInvites.length > 0" :to="{name: 'groups'}"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
Group invites
<span class="badge bg-primary rounded-pill">{{ groupInvites.length }}</span>
</router-link>
</ul>
<div class="card-body" v-else>
<p class="text-muted mb-0">You're all caught up.</p>
</div> </div>
</div> </div>
</div> </div>
@ -23,18 +136,92 @@
</template> </template>
<script> <script>
import {mapActions, mapGetters, mapState} from 'vuex';
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue"; import BaseLayout from "@/components/BaseLayout.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import {encodeHandleForUrl} from "@/router";
export default { export default {
name: 'Dashboard', name: 'Dashboard',
components: { components: {
...BIcons, ...BIcons,
BaseLayout BaseLayout,
AuthenticatedImage
}, },
data() {
return {
friendRequests: []
}
},
computed: {
...mapState(['user', 'storage_locations', 'friends', 'groups', 'groupMemberships', 'groupInvites',
'active_workflows']),
...mapGetters(['inventory_items']),
// Groups hosted here plus GroupMembership-only ones - see 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]
},
recentItems() {
return [...this.inventory_items].sort((a, b) => b.id - a.id).slice(0, 8)
},
hasPendingItems() {
return this.active_workflows.length > 0 || this.friendRequests.length > 0 || this.groupInvites.length > 0
}
},
methods: {
...mapActions(['fetchInventoryItems', 'fetchStorageLocations', 'fetchFriends', 'fetchGroups',
'fetchGroupMemberships', 'fetchGroupInvites', 'fetchActiveWorkflows', 'fetchFriendRequests']),
itemRoute(item) {
return {name: 'inventory-detail', params: {handle: encodeHandleForUrl(item.owner_group || item.owner), id: item.id}}
},
only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/"));
},
},
async mounted() {
await this.fetchInventoryItems()
await this.fetchStorageLocations()
await this.fetchFriends()
await this.fetchGroups()
await this.fetchGroupMemberships()
await this.fetchGroupInvites()
await this.fetchActiveWorkflows()
this.friendRequests = await this.fetchFriendRequests()
}
} }
</script> </script>
<style scoped> <style scoped>
.stat-card {
transition: transform .15s ease-in-out;
}
</style> .stat-card:hover {
transform: translateY(-2px);
}
.stat-icon {
font-size: 1.75rem;
color: var(--bs-primary);
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
line-height: 1.2;
}
.stat-label {
font-size: .8rem;
color: var(--bs-text-3);
}
.img-preview {
object-fit: cover;
height: 100px;
width: 100%;
}
</style>

View file

@ -19,7 +19,7 @@
<b-icon-arrow-clockwise></b-icon-arrow-clockwise> <b-icon-arrow-clockwise></b-icon-arrow-clockwise>
Refresh Refresh
</a> </a>
<router-link to="/groups/new" class="align-middle"> <router-link :to="{name: 'groups-new'}" class="align-middle">
<b-icon-plus></b-icon-plus> <b-icon-plus></b-icon-plus>
New group New group
</router-link> </router-link>
@ -29,7 +29,7 @@
<tbody> <tbody>
<tr v-for="group in allGroups" :key="group.key"> <tr v-for="group in allGroups" :key="group.key">
<td> <td>
<router-link :to="`/groups/${encodeHandleForUrl(group.handle)}`"> <router-link :to="{name: 'group-detail', params: {handle: encodeHandleForUrl(group.handle)}}">
{{ group.handle }} {{ group.handle }}
</router-link> </router-link>
</td> </td>

View file

@ -55,10 +55,10 @@
</td> </td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td> <td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action"> <td class="table-action">
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`"> <router-link v-if="canEdit" :to="itemEditRoute(item)">
<b-icon-pencil-square></b-icon-pencil-square> <b-icon-pencil-square></b-icon-pencil-square>
</router-link> </router-link>
<a v-if="canEdit" :href="`${itemRoute(item)}/delete`" @click.prevent="tryDeleteItem(item)"> <a v-if="canEdit" href="#" @click.prevent="tryDeleteItem(item)">
<b-icon-trash></b-icon-trash> <b-icon-trash></b-icon-trash>
</a> </a>
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"> <router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
@ -95,7 +95,7 @@
<button v-if="canEdit" class="btn btn-danger btn-sm" <button v-if="canEdit" class="btn btn-danger btn-sm"
@click="tryDeleteItem(item)">Delete @click="tryDeleteItem(item)">Delete
</button> </button>
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`" <router-link v-if="canEdit" :to="itemEditRoute(item)"
class="btn btn-primary btn-sm">Edit class="btn btn-primary btn-sm">Edit
</router-link> </router-link>
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)" <router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
@ -166,8 +166,8 @@ export default {
return this.owner ? decodeHandleFromUrl(this.owner) : this.user return this.owner ? decodeHandleFromUrl(this.owner) : this.user
}, },
set(value) { set(value) {
const path = value === this.user ? '/inventory' : `/inventory/${encodeHandleForUrl(value)}` const params = value === this.user ? {} : {owner: encodeHandleForUrl(value)}
this.$router.replace(path) this.$router.replace({name: 'inventory', params})
} }
}, },
isGroupSelected() { isGroupSelected() {
@ -182,9 +182,8 @@ export default {
return this.selectedOwner === this.user ? this.inventory_items : this.groupInventoryItems(this.selectedOwner) return this.selectedOwner === this.user ? this.inventory_items : this.groupInventoryItems(this.selectedOwner)
}, },
addItemRoute() { addItemRoute() {
return this.selectedOwner === this.user const params = this.selectedOwner === this.user ? {} : {group: encodeHandleForUrl(this.selectedOwner)}
? '/inventory/new' return {name: 'inventory-new', params}
: `/inventory/new/${encodeHandleForUrl(this.selectedOwner)}`
} }
}, },
methods: { methods: {
@ -203,7 +202,10 @@ export default {
}, },
// Derived from the item's own owner, not the current selection, so the link stays correct if the dropdown changes underneath it. // Derived from the item's own owner, not the current selection, so the link stays correct if the dropdown changes underneath it.
itemRoute(item) { itemRoute(item) {
return `/inventory/${encodeHandleForUrl(item.owner_group || item.owner)}/${item.id}` return {name: 'inventory-detail', params: {handle: encodeHandleForUrl(item.owner_group || item.owner), id: item.id}}
},
itemEditRoute(item) {
return {name: 'inventory-edit', params: {handle: encodeHandleForUrl(item.owner_group || item.owner), id: item.id}}
}, },
only_images(files) { only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/")); return files.filter(file => file.mime_type.startsWith("image/"));
@ -225,7 +227,7 @@ export default {
printLinkFor(item) { printLinkFor(item) {
const userHandle = item.owner || item.owner_group const userHandle = item.owner || item.owner_group
if (!userHandle) return null if (!userHandle) return null
return {path: '/print', query: {kind: 'item', userHandle, id: item.id}} return {name: 'print', query: {kind: 'item', userHandle, id: item.id}}
}, },
}, },
watch: { watch: {

View file

@ -41,7 +41,7 @@
</div> </div>
</div> </div>
<div class="card" v-if="canEdit"> <div class="card" v-if="canEdit">
<button class="btn btn-primary" @click="$router.push(`/inventory/${handle}/${id}/edit`)"> <button class="btn btn-primary" @click="$router.push({name: 'inventory-edit', params: {handle, id}})">
<b-icon-pencil-square></b-icon-pencil-square> <b-icon-pencil-square></b-icon-pencil-square>
Edit Edit
</button> </button>
@ -51,7 +51,7 @@
Delete Delete
</button> </button>
<button v-if="canPrint" class="btn btn-secondary" <button v-if="canPrint" class="btn btn-secondary"
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, id: id}})"> @click="$router.push({name: 'print', query: {kind: 'item', userHandle: decodedHandle, id: id}})">
<b-icon-printer></b-icon-printer> <b-icon-printer></b-icon-printer>
Print label Print label
</button> </button>

View file

@ -53,11 +53,11 @@
<div class="text-center"> <div class="text-center">
<p class="mb-0 text-muted"> <p class="mb-0 text-muted">
Dont have an account? Dont have an account?
<router-link to="/register">Sign up</router-link> <router-link :to="{name: 'register'}">Sign up</router-link>
</p> </p>
<p class="mb-0 text-muted"> <p class="mb-0 text-muted">
Connect with existing account? Connect with existing account?
<router-link to="/pairing">Pairing</router-link> <router-link :to="{name: 'pairing'}">Pairing</router-link>
</p> </p>
</div> </div>
</div> </div>

View file

@ -97,11 +97,11 @@
<div class="text-center"> <div class="text-center">
<p class="mb-0 text-muted"> <p class="mb-0 text-muted">
Already have an account? Already have an account?
<router-link to="/login">Login</router-link> <router-link :to="{name: 'login'}">Login</router-link>
</p> </p>
<p class="mb-0 text-muted"> <p class="mb-0 text-muted">
Dont have an account? Dont have an account?
<router-link to="/register">Sign up</router-link> <router-link :to="{name: 'register'}">Sign up</router-link>
</p> </p>
</div> </div>
</div> </div>

View file

@ -77,11 +77,11 @@
<div class="text-center"> <div class="text-center">
<p class="mb-0 text-muted"> <p class="mb-0 text-muted">
Already have an account? Already have an account?
<router-link to="/login">Login</router-link> <router-link :to="{name: 'login'}">Login</router-link>
</p> </p>
<p class="mb-0 text-muted"> <p class="mb-0 text-muted">
Connect with existing account? Connect with existing account?
<router-link to="/pairing">Pairing</router-link> <router-link :to="{name: 'pairing'}">Pairing</router-link>
</p> </p>
</div> </div>
</div> </div>

View file

@ -101,8 +101,8 @@ function classifyScanText(text) {
const [, handle, kindLetter, id] = ownerMatch; const [, handle, kindLetter, id] = ownerMatch;
const kind = kindLetter === "s" ? "storage_location" : "item"; const kind = kindLetter === "s" ? "storage_location" : "item";
const to = kind === "storage_location" const to = kind === "storage_location"
? `/storage-locations/${encodeHandleForUrl(handle)}/${id}` ? {name: 'locations-detail', params: {handle: encodeHandleForUrl(handle), id}}
: `/inventory/${encodeHandleForUrl(handle)}/${id}`; : {name: 'inventory-detail', params: {handle: encodeHandleForUrl(handle), id}};
return {to, ownerQualifiedId: {handle, kind, id}}; return {to, ownerQualifiedId: {handle, kind, id}};
} }
return null; return null;

View file

@ -112,7 +112,7 @@ export default {
// user handle - one route shape, no owner-is-me special case. // user handle - one route shape, no owner-is-me special case.
this.fetchSearchResults({query: this.query}).then((results) => { this.fetchSearchResults({query: this.query}).then((results) => {
this.search_results = results.map(e => ( this.search_results = results.map(e => (
{...e, route: `/inventory/${encodeHandleForUrl(e.owner)}/${e.id}`})) {...e, route: {name: 'inventory-detail', params: {handle: encodeHandleForUrl(e.owner), id: e.id}}}))
}); });
} }
}, },

View file

@ -55,10 +55,10 @@
<span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span> <span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span>
</td> </td>
<td class="table-action"> <td class="table-action">
<router-link :to="`${locationRoute(location)}/edit`"> <router-link :to="locationEditRoute(location)">
<b-icon-pencil-square></b-icon-pencil-square> <b-icon-pencil-square></b-icon-pencil-square>
</router-link> </router-link>
<a :href="`${locationRoute(location)}/delete`" @click.prevent="tryDeleteLocation(location)"> <a href="#" @click.prevent="tryDeleteLocation(location)">
<b-icon-trash></b-icon-trash> <b-icon-trash></b-icon-trash>
</a> </a>
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"> <router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
@ -94,7 +94,7 @@
<button class="btn btn-danger btn-sm" <button class="btn btn-danger btn-sm"
@click="tryDeleteLocation(location)">Delete @click="tryDeleteLocation(location)">Delete
</button> </button>
<router-link :to="`${locationRoute(location)}/edit`" <router-link :to="locationEditRoute(location)"
class="btn btn-primary btn-sm">Edit class="btn btn-primary btn-sm">Edit
</router-link> </router-link>
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)" <router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"
@ -129,7 +129,7 @@ import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router"
export default { export default {
name: "StorageLocation", name: "StorageLocation",
props: { props: {
// Matches /storage-location/:owner; absent means "me". See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings. // Matches /locations/:owner; absent means "me". See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
owner: { owner: {
type: String, type: String,
default: null default: null
@ -163,17 +163,16 @@ export default {
return this.owner ? decodeHandleFromUrl(this.owner) : this.user return this.owner ? decodeHandleFromUrl(this.owner) : this.user
}, },
set(value) { set(value) {
const path = value === this.user ? '/storage-location' : `/storage-location/${encodeHandleForUrl(value)}` const params = value === this.user ? {} : {owner: encodeHandleForUrl(value)}
this.$router.replace(path) this.$router.replace({name: 'locations', params})
} }
}, },
locations() { locations() {
return this.selectedOwner === this.user ? this.storage_locations : this.groupStorageLocations(this.selectedOwner) return this.selectedOwner === this.user ? this.storage_locations : this.groupStorageLocations(this.selectedOwner)
}, },
addLocationRoute() { addLocationRoute() {
return this.selectedOwner === this.user const params = this.selectedOwner === this.user ? {} : {group: encodeHandleForUrl(this.selectedOwner)}
? '/storage-locations/new' return {name: 'locations-new', params}
: `/storage-locations/new/${encodeHandleForUrl(this.selectedOwner)}`
} }
}, },
methods: { methods: {
@ -190,7 +189,10 @@ export default {
}) })
}, },
locationRoute(location) { locationRoute(location) {
return `/storage-locations/${encodeHandleForUrl(location.owner_group || location.owner)}/${location.id}` return {name: 'locations-detail', params: {handle: encodeHandleForUrl(location.owner_group || location.owner), id: location.id}}
},
locationEditRoute(location) {
return {name: 'locations-edit', params: {handle: encodeHandleForUrl(location.owner_group || location.owner), id: location.id}}
}, },
shortIdLink(location) { shortIdLink(location) {
if (location.owner_group) { if (location.owner_group) {
@ -208,7 +210,7 @@ export default {
// printLinkFor. See docs/implementation.md#print-link-shape. // printLinkFor. See docs/implementation.md#print-link-shape.
printLinkFor(location) { printLinkFor(location) {
const userHandle = location.owner_group || location.owner const userHandle = location.owner_group || location.owner
return {path: '/print', query: {kind: 'storage-location', userHandle, id: location.id}} return {name: 'print', query: {kind: 'storage-location', userHandle, id: location.id}}
}, },
}, },
watch: { watch: {

View file

@ -34,7 +34,7 @@
</div> </div>
</div> </div>
<div class="card" v-if="canEdit"> <div class="card" v-if="canEdit">
<button class="btn btn-primary" @click="$router.push(`/storage-locations/${handle}/${id}/edit`)"> <button class="btn btn-primary" @click="$router.push({name: 'locations-edit', params: {handle, id}})">
<b-icon-pencil-square></b-icon-pencil-square> <b-icon-pencil-square></b-icon-pencil-square>
Edit Edit
</button> </button>
@ -44,7 +44,7 @@
Delete Delete
</button> </button>
<button class="btn btn-secondary" <button class="btn btn-secondary"
@click="$router.push({path: '/print', query: {kind: 'storage-location', userHandle: decodedHandle, id: id}})"> @click="$router.push({name: 'print', query: {kind: 'storage-location', userHandle: decodedHandle, id: id}})">
<b-icon-printer></b-icon-printer> <b-icon-printer></b-icon-printer>
Print label Print label
</button> </button>

View file

@ -81,7 +81,7 @@ export default {
...BIcons ...BIcons
}, },
props: { props: {
// Matches /storage-locations/new/:group?. See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings. // Matches /locations/new/:group?. See docs/implementation.md#owner-filtered-overview-routes-use-path-segments-not-query-strings.
group: { group: {
type: String, type: String,
default: null default: null

View file

@ -8,7 +8,7 @@
<nav aria-label="breadcrumb"> <nav aria-label="breadcrumb">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li class="breadcrumb-item"> <li class="breadcrumb-item">
<router-link to="/workflows" class="text-decoration-none">Workflows</router-link> <router-link :to="{name: 'workflows'}" class="text-decoration-none">Workflows</router-link>
</li> </li>
<li class="breadcrumb-item active" aria-current="page">{{ workflowDefinition?.title || workflowInstance?.title || 'Loading...' }}</li> <li class="breadcrumb-item active" aria-current="page">{{ workflowDefinition?.title || workflowInstance?.title || 'Loading...' }}</li>
</ol> </ol>

View file

@ -6,7 +6,7 @@ import StorageOptimizationWorkflow from '@/components/workflow/workflows/Storage
import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/MaintenanceScheduleWorkflow.vue'; import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/MaintenanceScheduleWorkflow.vue';
import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue'; import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue';
import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue'; import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue';
import BulkLabelPrintWorkflow from '@/components/workflow/workflows/BulkLabelPrintWorkflow.vue'; //import BulkLabelPrintWorkflow from '@/components/workflow/workflows/BulkLabelPrintWorkflow.vue';
const workflows = [ const workflows = [
{...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow}, {...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow},
@ -16,7 +16,7 @@ const workflows = [
{...MaintenanceScheduleWorkflow.meta, component: MaintenanceScheduleWorkflow}, {...MaintenanceScheduleWorkflow.meta, component: MaintenanceScheduleWorkflow},
{...ExpiryCheckWorkflow.meta, component: ExpiryCheckWorkflow}, {...ExpiryCheckWorkflow.meta, component: ExpiryCheckWorkflow},
{...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow}, {...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow},
{...BulkLabelPrintWorkflow.meta, component: BulkLabelPrintWorkflow}, // {...BulkLabelPrintWorkflow.meta, component: BulkLabelPrintWorkflow},
]; ];
export function getAllWorkflows() { export function getAllWorkflows() {