Compare commits
10 commits
1853577ff5
...
44f5666417
| Author | SHA1 | Date | |
|---|---|---|---|
| 44f5666417 | |||
| 7135fce421 | |||
| 240a508306 | |||
| 5cd3ae5a13 | |||
| 197f173097 | |||
| 9814ec7ecd | |||
| eb7fd65d6b | |||
| 78a1e0301f | |||
| e88dc898b3 | |||
| 3cd9200d87 |
2
.gitignore
vendored
|
|
@ -14,7 +14,7 @@ dist/
|
||||||
downloads/
|
downloads/
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
#lib/
|
||||||
lib64/
|
lib64/
|
||||||
parts/
|
parts/
|
||||||
sdist/
|
sdist/
|
||||||
|
|
|
||||||
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
[submodule "frontend/extras"]
|
|
||||||
path = frontend/extras
|
|
||||||
url = https://git.neulandlabor.de/j3d1/vue-extras.git
|
|
||||||
|
|
@ -9,7 +9,7 @@ RUN mkdir /git
|
||||||
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
|
||||||
COPY extras/ ./extras/
|
#COPY extras/ ./extras/
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
CMD [ "npm", "run", "dev", "--", "--host"]
|
CMD [ "npm", "run", "dev", "--", "--host"]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
|
# Worker stays unprivileged as the image's default "nginx" user, but gains the "root" group so
|
||||||
|
# it can read /var/www/userfiles - backend-b (running as root) writes those with
|
||||||
|
# FILE_UPLOAD_DIRECTORY_PERMISSIONS=0750, group-readable but not world-readable. Mirrors
|
||||||
|
# deploy/prod/playbook.yml's "Allow nginx to read backend user files" (www-data joins the
|
||||||
|
# backend's file-owning group instead of loosening the mode).
|
||||||
|
user nginx root;
|
||||||
|
|
||||||
events {}
|
events {}
|
||||||
|
|
||||||
http {
|
http {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ ARG GIT_COMMIT=unknown
|
||||||
ENV GIT_COMMIT=$GIT_COMMIT
|
ENV GIT_COMMIT=$GIT_COMMIT
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY extras/ ./extras/
|
#COPY extras/ ./extras/
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,9 @@ In `FotoFirstBulkImportWorkflow.vue`, `photos`' durable state is the `WorkflowIn
|
||||||
|
|
||||||
Once a photo finishes uploading, the gallery switches it to the server-fetched thumbnail by re-looking it up by hash rather than mutating the closed-over `photo` object from the upload closure - that reference predates the `this.photos.push()` call, so it's the raw object, not the reactive proxy Vue tracks, and writing to it wouldn't trigger a re-render.
|
Once a photo finishes uploading, the gallery switches it to the server-fetched thumbnail by re-looking it up by hash rather than mutating the closed-over `photo` object from the upload closure - that reference predates the `this.photos.push()` call, so it's the raw object, not the reactive proxy Vue tracks, and writing to it wouldn't trigger a re-render.
|
||||||
|
|
||||||
|
### Bulk Label Print Workflow
|
||||||
|
`BulkLabelPrintWorkflow.vue` implements the `bulk-label-print` workflow: step 1 collects a kind/owner/id range plus a label layout (reusing `LabelLayoutPreview.vue` for the template grid, previewed against the range's first id); step 2 connects a printer (or falls back to PNG downloads, same device-chip/PNG-Export pattern as `Print.vue`) and, on start, sequentially renders and prints/downloads one label per id in the range via `label.js`'s `drawLabel`, logging each id's outcome. `MAX_RANGE_SIZE` caps a single run so a mistyped range can't queue an unbounded number of labels.
|
||||||
|
|
||||||
### Foto First Bulk Import Workflow
|
### Foto First Bulk Import Workflow
|
||||||
`FotoFirstBulkImportWorkflow2.vue` implements every step of the `foto-first-bulk-import` workflow (photo capture, image processing, item detail entry, import completion) in a single component. Keeping the whole workflow in one file avoids splitting closely related state (photos, processed images, completed items) across many small step components and their prop/emit boundaries.
|
`FotoFirstBulkImportWorkflow2.vue` implements every step of the `foto-first-bulk-import` workflow (photo capture, image processing, item detail entry, import completion) in a single component. Keeping the whole workflow in one file avoids splitting closely related state (photos, processed images, completed items) across many small step components and their prop/emit boundaries.
|
||||||
|
|
||||||
|
|
@ -271,3 +274,10 @@ An inventory item's properties are encoded into the `properties` CSV cell as a c
|
||||||
|
|
||||||
### Print Link Shape
|
### Print Link Shape
|
||||||
`printLinkFor` in `Inventory.vue` and `StorageLocation.vue` (and the print buttons in `InventoryDetail.vue`/`StorageLocationDetail.vue`) all route to `Print.vue` with the same `{kind, userHandle, id}` query shape - the thing's raw identity - rather than any pre-built link. `kind` is one of `CONTENT_KINDS`' ids (`"item"`/`"storage-location"`, see Content Kinds Registry above); `label.js`'s `buildLabelFields` reads it generically since every kind's prefill now shares this one shape. That lets the print page derive every representation it needs (qualified handle, owner handle, URL, short link, …) itself, instead of being tied to whichever one the calling button happened to construct. Group-owned items have no individual owner handle - `short-id.js`'s `group_item` kind resolves them via `owner_group` instead (see `shortIdLink`) - so there's no `{userHandle, id}` to build yet; `Inventory.vue`'s `printLinkFor` returns `null` for them until group print support exists. Storage locations are always individually owned (see `StorageLocationViewSet.get_queryset`), so `StorageLocation.vue`'s version never has that fallback to make.
|
`printLinkFor` in `Inventory.vue` and `StorageLocation.vue` (and the print buttons in `InventoryDetail.vue`/`StorageLocationDetail.vue`) all route to `Print.vue` with the same `{kind, userHandle, id}` query shape - the thing's raw identity - rather than any pre-built link. `kind` is one of `CONTENT_KINDS`' ids (`"item"`/`"storage-location"`, see Content Kinds Registry above); `label.js`'s `buildLabelFields` reads it generically since every kind's prefill now shares this one shape. That lets the print page derive every representation it needs (qualified handle, owner handle, URL, short link, …) itself, instead of being tied to whichever one the calling button happened to construct. Group-owned items have no individual owner handle - `short-id.js`'s `group_item` kind resolves them via `owner_group` instead (see `shortIdLink`) - so there's no `{userHandle, id}` to build yet; `Inventory.vue`'s `printLinkFor` returns `null` for them until group print support exists. Storage locations are always individually owned (see `StorageLocationViewSet.get_queryset`), so `StorageLocation.vue`'s version never has that fallback to make.
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
### Category Tree Reconstruction From Flat Path Strings
|
||||||
|
`Category` (`backend/toolshed/models.py`) is a real parent/children tree (self-referencing FK), but `combined_info`'s `/api/v1/info/` response (`backend/toolshed/api/info.py`) flattens every category to `str(category)` - a `/`-joined ancestor path (`Category.__str__`) - with every ancestor and descendant listed as its own separate string, not nested. `Admin.vue`'s `categoryTree` computed rebuilds the hierarchy client-side by splitting each path on `/` and grouping nodes by shared prefixes, then renders it with `TreeView.vue` (`frontend/src/components/TreeView.vue`), a generic recursive collapsible tree component built for single-line slot content.
|
||||||
|
|
||||||
|
`StorageLocation` (`backend/toolshed/models.py`) is the same self-referencing-FK shape, but its `StorageLocationSerializer` keeps `parent` as a real owner-scoped id rather than flattening it away, so `Admin.vue`'s `storageLocationTree` and `StorageLocation.vue`'s `locationTree` computeds group nodes by that id directly instead of parsing path strings - the same tree-from-flat-list pattern, one step simpler since the parent relation already survives serialization.
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
Subproject commit 21bf28a8b6293872f5f7a42c9ca0bc12ede5aaae
|
|
||||||
3
frontend/package-lock.json
generated
|
|
@ -887,9 +887,6 @@
|
||||||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"extras": {
|
|
||||||
"version": "file:extras"
|
|
||||||
},
|
|
||||||
"fast-diff": {
|
"fast-diff": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@
|
||||||
"vue-multiselect": "^2.1.7",
|
"vue-multiselect": "^2.1.7",
|
||||||
"vue-router": "^4.1.6",
|
"vue-router": "^4.1.6",
|
||||||
"vuex": "^4.1.0",
|
"vuex": "^4.1.0",
|
||||||
"extras": "file:./extras",
|
|
||||||
"jquery": "^3.6.0",
|
"jquery": "^3.6.0",
|
||||||
"popper.js": "^1.16.1"
|
"popper.js": "^1.16.1"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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"/>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
<div style="position: relative;">
|
<div style="position: relative;">
|
||||||
<div class="image-list">
|
<div class="image-list">
|
||||||
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.owner)" :key="file.id"
|
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.owner)" :key="file.id"
|
||||||
@delete="deleteFile(file)">
|
confirm-message="Delete this file? This cannot be undone." @delete="deleteFile(file)">
|
||||||
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
|
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
|
||||||
</deletable-wrapper>
|
</deletable-wrapper>
|
||||||
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.data)" :key="file.id"
|
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.data)" :key="file.id"
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<span style="position: relative; display: inline-block;">
|
<span style="position: relative; display: inline-block;">
|
||||||
<div class="delete-button">
|
<div class="delete-button">
|
||||||
<button class="btn btn-danger btn-sm" @click="triggerDelete">
|
<guarded-button class="btn btn-danger btn-sm" :confirm-message="confirmMessage" @confirm="$emit('delete')">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
</button>
|
</guarded-button>
|
||||||
</div>
|
</div>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -20,17 +20,21 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
import GuardedButton from "@/components/GuardedButton.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "DeletableWrapper",
|
name: "DeletableWrapper",
|
||||||
components: {
|
components: {
|
||||||
|
GuardedButton,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
emits: ["delete"],
|
props: {
|
||||||
methods: {
|
// No confirmation when omitted - e.g. removing a not-yet-saved staged file, see CombinedFileField.vue's deleteTempFile.
|
||||||
triggerDelete() {
|
confirmMessage: {
|
||||||
this.$emit("delete");
|
type: String,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
emits: ["delete"]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
43
frontend/src/components/GuardedButton.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<template>
|
||||||
|
<component :is="tag" v-bind="rootProps" :class="{unguarded: shiftHeld}" @click="handleClick">
|
||||||
|
<slot></slot>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import {reactive} from "vue"
|
||||||
|
|
||||||
|
const shiftState = reactive({held: false})
|
||||||
|
window.addEventListener("keydown", event => shiftState.held = event.shiftKey)
|
||||||
|
window.addEventListener("keyup", event => shiftState.held = event.shiftKey)
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "GuardedButton",
|
||||||
|
props: {
|
||||||
|
confirmMessage: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
type: String,
|
||||||
|
default: "button"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ["confirm"],
|
||||||
|
computed: {
|
||||||
|
rootProps() {
|
||||||
|
return this.tag === "a" ? {href: "#"} : {}
|
||||||
|
},
|
||||||
|
shiftHeld() {
|
||||||
|
return shiftState.held
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleClick(event) {
|
||||||
|
if (this.tag === "a") event.preventDefault()
|
||||||
|
if (this.confirmMessage && !event.shiftKey && !confirm(this.confirmMessage)) return
|
||||||
|
this.$emit("confirm", event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
131
frontend/src/components/TreeView.vue
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
<template>
|
||||||
|
<ul class="tree-view" :class="{'tree-view-root': depth === 0}">
|
||||||
|
<li v-for="node in items" :key="node[itemKey]" class="tree-view-item">
|
||||||
|
<div class="tree-view-row">
|
||||||
|
<button v-if="hasChildren(node)" type="button" class="tree-view-toggle"
|
||||||
|
:aria-expanded="isExpanded(node)" @click="toggleNode(node)">
|
||||||
|
<b-icon-chevron-right v-if="!isExpanded(node)"></b-icon-chevron-right>
|
||||||
|
<b-icon-chevron-down v-else></b-icon-chevron-down>
|
||||||
|
</button>
|
||||||
|
<span v-else class="tree-view-toggle-spacer"></span>
|
||||||
|
<div class="tree-view-content">
|
||||||
|
<slot :item="node" :expanded="isExpanded(node)" :has-children="hasChildren(node)"
|
||||||
|
:depth="depth" :toggle="() => toggleNode(node)"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<tree-view v-if="hasChildren(node) && isExpanded(node)" :items="node[childrenKey]"
|
||||||
|
:item-key="itemKey" :children-key="childrenKey" :default-expanded="defaultExpanded"
|
||||||
|
:depth="depth + 1" @toggle="(...args) => $emit('toggle', ...args)">
|
||||||
|
<template #default="slotProps">
|
||||||
|
<slot v-bind="slotProps"></slot>
|
||||||
|
</template>
|
||||||
|
</tree-view>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-view {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-view .tree-view {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-view-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-view-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-view-toggle-spacer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-view-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "TreeView",
|
||||||
|
components: {
|
||||||
|
...BIcons
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
items: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
itemKey: {
|
||||||
|
type: String,
|
||||||
|
default: "id"
|
||||||
|
},
|
||||||
|
childrenKey: {
|
||||||
|
type: String,
|
||||||
|
default: "children"
|
||||||
|
},
|
||||||
|
// Whether nodes start out expanded. Only read once per rendered level, see expandedKeys below.
|
||||||
|
defaultExpanded: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
depth: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ["toggle"],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// Tracks which of this level's own nodes are expanded. Nested levels are separate
|
||||||
|
// TreeView instances with their own set, since they only exist while their parent
|
||||||
|
// node is expanded in the first place.
|
||||||
|
expandedKeys: new Set(this.defaultExpanded ? this.items.map(item => item[this.itemKey]) : [])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
hasChildren(node) {
|
||||||
|
return Array.isArray(node[this.childrenKey]) && node[this.childrenKey].length > 0
|
||||||
|
},
|
||||||
|
isExpanded(node) {
|
||||||
|
return this.expandedKeys.has(node[this.itemKey])
|
||||||
|
},
|
||||||
|
toggleNode(node) {
|
||||||
|
const key = node[this.itemKey]
|
||||||
|
if (this.expandedKeys.has(key)) {
|
||||||
|
this.expandedKeys.delete(key)
|
||||||
|
} else {
|
||||||
|
this.expandedKeys.add(key)
|
||||||
|
}
|
||||||
|
this.$emit("toggle", node, this.expandedKeys.has(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,12 @@
|
||||||
<div v-if="photos.length > 0" class="photo-gallery mb-4">
|
<div v-if="photos.length > 0" class="photo-gallery mb-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h6 class="mb-0">Captured Photos ({{ photos.length }})</h6>
|
<h6 class="mb-0">Captured Photos ({{ photos.length }})</h6>
|
||||||
<button class="btn btn-sm btn-outline-danger" @click="clearAllPhotos">
|
<guarded-button class="btn btn-sm btn-outline-danger"
|
||||||
|
confirm-message="Are you sure you want to remove all photos?"
|
||||||
|
@confirm="clearAllPhotos">
|
||||||
<b-icon-trash class="me-1"></b-icon-trash>
|
<b-icon-trash class="me-1"></b-icon-trash>
|
||||||
Clear All
|
Clear All
|
||||||
</button>
|
</guarded-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
<div v-for="(photo, index) in photos" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
|
||||||
|
|
@ -279,6 +281,7 @@
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import {mapActions, mapState} from "vuex";
|
import {mapActions, mapState} from "vuex";
|
||||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||||
|
import GuardedButton from "@/components/GuardedButton.vue";
|
||||||
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
|
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
|
||||||
import CameraFileSource from "@/components/inputs/CameraFileSource.vue";
|
import CameraFileSource from "@/components/inputs/CameraFileSource.vue";
|
||||||
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
||||||
|
|
@ -350,6 +353,7 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
WebcamFileSource,
|
WebcamFileSource,
|
||||||
AuthenticatedImage,
|
AuthenticatedImage,
|
||||||
|
GuardedButton,
|
||||||
DragDropFileSource,
|
DragDropFileSource,
|
||||||
CameraFileSource,
|
CameraFileSource,
|
||||||
FsFileSource,
|
FsFileSource,
|
||||||
|
|
@ -500,14 +504,12 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
async clearAllPhotos() {
|
async clearAllPhotos() {
|
||||||
if (confirm('Are you sure you want to remove all photos?')) {
|
const removed = this.photos;
|
||||||
const removed = this.photos;
|
this.photos = [];
|
||||||
this.photos = [];
|
await Promise.all(removed.map(photo =>
|
||||||
await Promise.all(removed.map(photo =>
|
this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash})
|
||||||
this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash})
|
.catch(error => console.error('Failed to unstage photo:', error))
|
||||||
.catch(error => console.error('Failed to unstage photo:', error))
|
));
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
proceedFromStep1() {
|
proceedFromStep1() {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import ShortId from '@/views/ShortId.vue';
|
||||||
import {
|
import {
|
||||||
encodeShortId, serializeShortId, decodeShortId, deserializeShortId,
|
encodeShortId, serializeShortId, decodeShortId, deserializeShortId,
|
||||||
isDomainQualifiedShortId, decodeDomainQualifiedShortId
|
isDomainQualifiedShortId, decodeDomainQualifiedShortId
|
||||||
} from '@/short-id';
|
} from '@/lib/short-id';
|
||||||
|
|
||||||
import Account from '@/views/settings/Account.vue';
|
import Account from '@/views/settings/Account.vue';
|
||||||
import Password from '@/views/settings/Password.vue';
|
import Password from '@/views/settings/Password.vue';
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
24
frontend/src/scss/_breakpoints.scss
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Single source of truth for the grid breakpoints, shared between toolshed.scss
|
||||||
|
// (which feeds them into Bootstrap's own grid/container generation) and any
|
||||||
|
// component's <style lang="scss"> block that needs a matching media query
|
||||||
|
// via media-breakpoint-up()/-down()/-between() (see mixins/_breakpoints.scss).
|
||||||
|
//
|
||||||
|
// Bootstrap 4 stops at xl; xxl is faked here for grid sizing until the
|
||||||
|
// Bootstrap 5 upgrade brings a real one.
|
||||||
|
$grid-breakpoints: (
|
||||||
|
xs: 0,
|
||||||
|
sm: 576px,
|
||||||
|
md: 768px,
|
||||||
|
lg: 992px,
|
||||||
|
xl: 1200px,
|
||||||
|
xxl: 3000px
|
||||||
|
);
|
||||||
|
$container-max-widths: (
|
||||||
|
sm: 540px,
|
||||||
|
md: 720px,
|
||||||
|
lg: 960px,
|
||||||
|
xl: 1140px,
|
||||||
|
xxl: 2960px
|
||||||
|
);
|
||||||
|
|
||||||
|
@import "bootstrap/scss/mixins/breakpoints";
|
||||||
|
|
@ -27,6 +27,8 @@ $h4-font-size: $font-size-base * 1.25;
|
||||||
$h5-font-size: $font-size-base;
|
$h5-font-size: $font-size-base;
|
||||||
$h6-font-size: $font-size-base;
|
$h6-font-size: $font-size-base;
|
||||||
|
|
||||||
|
@import "breakpoints";
|
||||||
|
|
||||||
@import "bootstrap/scss/functions";
|
@import "bootstrap/scss/functions";
|
||||||
@import "bootstrap/scss/variables";
|
@import "bootstrap/scss/variables";
|
||||||
|
|
||||||
|
|
|
||||||
56
frontend/src/tests/Admin.js
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import Admin from '../views/Admin.vue'
|
||||||
|
|
||||||
|
test('Admin categoryTree groups flat "/"-joined category paths into a tree', () => {
|
||||||
|
const categories = [
|
||||||
|
'Electronics',
|
||||||
|
'Electronics/Passives',
|
||||||
|
'Electronics/Passives/Resistors',
|
||||||
|
'Electronics/Actives',
|
||||||
|
'Tools',
|
||||||
|
]
|
||||||
|
|
||||||
|
const categoryTree = Admin.computed.categoryTree.call({categories})
|
||||||
|
|
||||||
|
expect(categoryTree).toEqual([
|
||||||
|
{
|
||||||
|
id: 'Electronics', name: 'Electronics', children: [
|
||||||
|
{id: 'Electronics/Actives', name: 'Actives', children: []},
|
||||||
|
{
|
||||||
|
id: 'Electronics/Passives', name: 'Passives', children: [
|
||||||
|
{id: 'Electronics/Passives/Resistors', name: 'Resistors', children: []},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{id: 'Tools', name: 'Tools', children: []},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Admin storageLocationTree groups locations by their real parent id', () => {
|
||||||
|
const storage_locations = [
|
||||||
|
{id: 3, name: 'Shelf B', parent: 1},
|
||||||
|
{id: 1, name: 'Garage', parent: null},
|
||||||
|
{id: 5, name: 'Attic', parent: null},
|
||||||
|
{id: 2, name: 'Shelf A', parent: 1},
|
||||||
|
{id: 4, name: 'Drawer 1', parent: 3},
|
||||||
|
// A location whose parent was deleted/isn't in this owner's own list falls back to root.
|
||||||
|
{id: 6, name: 'Orphan Box', parent: 99},
|
||||||
|
]
|
||||||
|
|
||||||
|
const storageLocationTree = Admin.computed.storageLocationTree.call({storage_locations})
|
||||||
|
|
||||||
|
expect(storageLocationTree).toEqual([
|
||||||
|
{id: 5, name: 'Attic', children: []},
|
||||||
|
{
|
||||||
|
id: 1, name: 'Garage', children: [
|
||||||
|
{id: 2, name: 'Shelf A', children: []},
|
||||||
|
{
|
||||||
|
id: 3, name: 'Shelf B', children: [
|
||||||
|
{id: 4, name: 'Drawer 1', children: []},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{id: 6, name: 'Orphan Box', children: []},
|
||||||
|
])
|
||||||
|
})
|
||||||
58
frontend/src/tests/TreeView.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
import {mount} from '@vue/test-utils'
|
||||||
|
import TreeView from '../components/TreeView.vue'
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Garage',
|
||||||
|
children: [
|
||||||
|
{id: 2, name: 'Shelf A'},
|
||||||
|
{id: 3, name: 'Shelf B', children: [{id: 4, name: 'Drawer 1'}]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{id: 5, name: 'Attic'},
|
||||||
|
]
|
||||||
|
|
||||||
|
test('TreeView component', async () => {
|
||||||
|
expect(TreeView).toBeTruthy()
|
||||||
|
|
||||||
|
const wrapper = mount(TreeView, {
|
||||||
|
props: {items},
|
||||||
|
slots: {
|
||||||
|
default: '<span class="label">{{ params.item.name }}</span>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Children start collapsed - only the two top-level nodes are visible.
|
||||||
|
expect(wrapper.findAll('.tree-view-item').length).toBe(2)
|
||||||
|
expect(wrapper.text()).toContain('Garage')
|
||||||
|
expect(wrapper.text()).toContain('Attic')
|
||||||
|
expect(wrapper.text()).not.toContain('Shelf A')
|
||||||
|
|
||||||
|
// Nothing is expanded yet, so these are exactly the two top-level nodes.
|
||||||
|
const topLevelItems = wrapper.findAll('.tree-view-item')
|
||||||
|
|
||||||
|
// The leaf node ("Attic") has no toggle button, just a spacer.
|
||||||
|
expect(topLevelItems[1].find('.tree-view-toggle').exists()).toBe(false)
|
||||||
|
expect(topLevelItems[1].find('.tree-view-toggle-spacer').exists()).toBe(true)
|
||||||
|
|
||||||
|
// Expanding "Garage" reveals its two direct children, recursively rendered.
|
||||||
|
await topLevelItems[0].find('.tree-view-toggle').trigger('click')
|
||||||
|
expect(wrapper.text()).toContain('Shelf A')
|
||||||
|
expect(wrapper.text()).toContain('Shelf B')
|
||||||
|
expect(wrapper.text()).not.toContain('Drawer 1')
|
||||||
|
expect(wrapper.emitted('toggle')[0]).toEqual([items[0], true])
|
||||||
|
|
||||||
|
// Expanding the nested "Shelf B" reveals the grandchild. Match on the exact text of its own
|
||||||
|
// row (not just "contains"), since the ancestor "Garage" item's text also contains "Shelf B".
|
||||||
|
const shelfB = wrapper.findAll('.tree-view-item').find(item => item.text().trim() === 'Shelf B')
|
||||||
|
await shelfB.find('.tree-view-toggle').trigger('click')
|
||||||
|
expect(wrapper.text()).toContain('Drawer 1')
|
||||||
|
|
||||||
|
// Collapsing "Garage" again hides its whole subtree.
|
||||||
|
await topLevelItems[0].find('.tree-view-toggle').trigger('click')
|
||||||
|
expect(wrapper.text()).not.toContain('Shelf A')
|
||||||
|
expect(wrapper.text()).not.toContain('Drawer 1')
|
||||||
|
|
||||||
|
expect(wrapper.html()).toMatchSnapshot()
|
||||||
|
})
|
||||||
20
frontend/src/tests/__snapshots__/TreeView.js.snap
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`TreeView component 1`] = `
|
||||||
|
"<ul data-v-4a3416e7=\\"\\" class=\\"tree-view tree-view-root\\">
|
||||||
|
<li data-v-4a3416e7=\\"\\" class=\\"tree-view-item\\">
|
||||||
|
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-row\\"><button data-v-4a3416e7=\\"\\" type=\\"button\\" class=\\"tree-view-toggle\\" aria-expanded=\\"false\\"><svg data-v-4a3416e7=\\"\\" width=\\"1em\\" height=\\"1em\\" viewBox=\\"0 0 16 16\\" fill=\\"currentColor\\" role=\\"img\\" focusable=\\"false\\">
|
||||||
|
<path fill-rule=\\"evenodd\\" d=\\"M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708\\"></path>
|
||||||
|
</svg></button>
|
||||||
|
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-content\\"><span class=\\"label\\">Garage</span></div>
|
||||||
|
</div>
|
||||||
|
<!--v-if-->
|
||||||
|
</li>
|
||||||
|
<li data-v-4a3416e7=\\"\\" class=\\"tree-view-item\\">
|
||||||
|
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-row\\"><span data-v-4a3416e7=\\"\\" class=\\"tree-view-toggle-spacer\\"></span>
|
||||||
|
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-content\\"><span class=\\"label\\">Attic</span></div>
|
||||||
|
</div>
|
||||||
|
<!--v-if-->
|
||||||
|
</li>
|
||||||
|
</ul>"
|
||||||
|
`;
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import {
|
import {
|
||||||
encodeShortId, decodeShortId, deserializeShortId, serializeShortId,
|
encodeShortId, decodeShortId, deserializeShortId, serializeShortId,
|
||||||
encodeDomainQualifiedShortId, decodeDomainQualifiedShortId, isDomainQualifiedShortId
|
encodeDomainQualifiedShortId, decodeDomainQualifiedShortId, isDomainQualifiedShortId
|
||||||
} from '../short-id.js'
|
} from '../lib/short-id.js'
|
||||||
|
|
||||||
test('encodes the worked example from docs/handles-and-shortids.md', () => {
|
test('encodes the worked example from docs/handles-and-shortids.md', () => {
|
||||||
const token = encodeShortId([0, 7, 42])
|
const token = encodeShortId([0, 7, 42])
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,9 @@
|
||||||
<h5 class="card-title">Categories</h5>
|
<h5 class="card-title">Categories</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<ul>
|
<TreeView :items="categoryTree">
|
||||||
<li v-for="category in categories.sort()" :key="category.id">
|
<template #default="{item}">{{ item.name }}</template>
|
||||||
{{ category }}
|
</TreeView>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
@ -84,11 +82,9 @@
|
||||||
<h5 class="card-title">Storage Locations</h5>
|
<h5 class="card-title">Storage Locations</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<ul>
|
<TreeView :items="storageLocationTree">
|
||||||
<li v-for="location in storage_locations.sort()" :key="location.id">
|
<template #default="{item}">{{ item.name }}</template>
|
||||||
{{ location.path }}
|
</TreeView>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,15 +98,49 @@
|
||||||
import {mapActions, mapState} from "vuex";
|
import {mapActions, 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 TreeView from "@/components/TreeView.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Admin",
|
name: "Admin",
|
||||||
components: {
|
components: {
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
|
TreeView,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(["tags", "properties", "categories", "availability_policies", "visibility_policies", "domains", "storage_locations"])
|
...mapState(["tags", "properties", "categories", "availability_policies", "visibility_policies", "domains", "storage_locations"]),
|
||||||
|
// See docs/implementation.md#category-tree-reconstruction-from-flat-path-strings
|
||||||
|
categoryTree() {
|
||||||
|
const roots = []
|
||||||
|
const nodesByPath = {}
|
||||||
|
for (const path of [...this.categories].sort()) {
|
||||||
|
let siblings = roots
|
||||||
|
let currentPath = ""
|
||||||
|
for (const segment of path.split("/")) {
|
||||||
|
currentPath = currentPath ? `${currentPath}/${segment}` : segment
|
||||||
|
if (!nodesByPath[currentPath]) {
|
||||||
|
nodesByPath[currentPath] = {id: currentPath, name: segment, children: []}
|
||||||
|
siblings.push(nodesByPath[currentPath])
|
||||||
|
}
|
||||||
|
siblings = nodesByPath[currentPath].children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
},
|
||||||
|
storageLocationTree() {
|
||||||
|
const nodesById = {}
|
||||||
|
for (const location of this.storage_locations) {
|
||||||
|
nodesById[location.id] = {id: location.id, name: location.name, children: []}
|
||||||
|
}
|
||||||
|
const roots = []
|
||||||
|
for (const location of [...this.storage_locations].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
const node = nodesById[location.id]
|
||||||
|
// Falls back to root if the parent isn't in this owner's own location list.
|
||||||
|
const parent = location.parent != null ? nodesById[location.parent] : null
|
||||||
|
;(parent ? parent.children : roots).push(node)
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchInfo", "fetchStorageLocations"])
|
...mapActions(["fetchInfo", "fetchStorageLocations"])
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<span v-for="file in only_images(files)" :key="file.id">
|
<span v-for="file in only_images(files)" :key="file.id">
|
||||||
<deletable-wrapper @delete="deleteFile(file)">
|
<deletable-wrapper confirm-message="Delete this file? This cannot be undone." @delete="deleteFile(file)">
|
||||||
<authenticated-image :src="thumbnailPathForHash(file.hash)" :owner="file.owner"
|
<authenticated-image :src="thumbnailPathForHash(file.hash)" :owner="file.owner"
|
||||||
class="img-thumbnail"/>
|
class="img-thumbnail"/>
|
||||||
</deletable-wrapper>
|
</deletable-wrapper>
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,12 @@
|
||||||
<b-icon-pencil-square></b-icon-pencil-square>
|
<b-icon-pencil-square></b-icon-pencil-square>
|
||||||
Edit
|
Edit
|
||||||
</a-->
|
</a-->
|
||||||
<a href="#" class="align-middle" @click="tryDropFriend(friend)">
|
<guarded-button tag="a" class="align-middle"
|
||||||
|
:confirm-message='`Are you sure you want to remove "${friend.handle}" as a friend?`'
|
||||||
|
@confirm="tryDropFriend(friend)">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
Delete
|
Delete
|
||||||
</a>
|
</guarded-button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -107,6 +109,7 @@ 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 AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||||
import UserNameTag from "@/components/UserNameTag.vue";
|
import UserNameTag from "@/components/UserNameTag.vue";
|
||||||
|
import GuardedButton from "@/components/GuardedButton.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Inventory',
|
name: 'Inventory',
|
||||||
|
|
@ -114,6 +117,7 @@ export default {
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
AuthenticatedImage,
|
AuthenticatedImage,
|
||||||
UserNameTag,
|
UserNameTag,
|
||||||
|
GuardedButton,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,12 @@
|
||||||
<user-name-tag :user="member"/>
|
<user-name-tag :user="member"/>
|
||||||
</td>
|
</td>
|
||||||
<td class="table-action">
|
<td class="table-action">
|
||||||
<a href="#" class="align-middle" @click.prevent="tryRemoveMember(member)">
|
<guarded-button tag="a" class="align-middle"
|
||||||
|
:confirm-message='`Are you sure you want to remove "${member.handle}" from ${group.handle}?`'
|
||||||
|
@confirm="tryRemoveMember(member)">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
Remove
|
Remove
|
||||||
</a>
|
</guarded-button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -61,6 +63,7 @@ import {mapActions} 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 UserNameTag from "@/components/UserNameTag.vue";
|
import UserNameTag from "@/components/UserNameTag.vue";
|
||||||
|
import GuardedButton from "@/components/GuardedButton.vue";
|
||||||
import {decodeHandleFromUrl} from "@/router";
|
import {decodeHandleFromUrl} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -68,6 +71,7 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
UserNameTag,
|
UserNameTag,
|
||||||
|
GuardedButton,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
|
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
|
||||||
<router-link v-if="canEdit" :to="addItemRoute" class="btn btn-primary">Add</router-link>
|
<router-link v-if="canEdit" :to="addItemRoute" class="btn btn-primary">Add</router-link>
|
||||||
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
|
<button v-if="layout === 'grid'" @click="layout = 'masonry'" class="btn">
|
||||||
|
<b-icon-columns-gap></b-icon-columns-gap>
|
||||||
|
</button>
|
||||||
|
<button v-else-if="layout === 'masonry'" @click="layout = 'table'" class="btn">
|
||||||
<b-icon-list></b-icon-list>
|
<b-icon-list></b-icon-list>
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="layout = 'grid'" class="btn">
|
<button v-else @click="layout = 'grid'" class="btn">
|
||||||
|
|
@ -55,32 +58,32 @@
|
||||||
</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)" title="Edit">
|
||||||
<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)">
|
<guarded-button v-if="canEdit" tag="a" title="Delete"
|
||||||
|
:confirm-message='`Are you sure you want to delete "${item.name}"? This cannot be undone.`'
|
||||||
|
@confirm="tryDeleteItem(item)">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
</a>
|
</guarded-button>
|
||||||
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
|
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
|
||||||
<b-icon-link></b-icon-link>
|
<b-icon-link></b-icon-link>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)">
|
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)" title="Print Label">
|
||||||
<b-icon-qr-code></b-icon-qr-code>
|
<b-icon-qr-code></b-icon-qr-code>
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="card-body" v-else>
|
<div class="card-body" v-else-if="layout === 'masonry'">
|
||||||
<div class="row">
|
<div class="masonry-grid">
|
||||||
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in items"
|
<div class="masonry-item" v-for="item in items" :key="item.id">
|
||||||
:key="item.id">
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img :src="item.image" class="" :alt="item.image">
|
|
||||||
<authenticated-image v-if="only_images(item.files).length > 0"
|
<authenticated-image v-if="only_images(item.files).length > 0"
|
||||||
:src="only_images(item.files)[0].name"
|
:src="only_images(item.files)[0].name"
|
||||||
:owner="only_images(item.files)[0].owner"
|
:owner="only_images(item.files)[0].owner"
|
||||||
class="card-img-top img-preview"/>
|
class="card-img-top img-preview-masonry"/>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title mb-0">
|
<h5 class="card-title mb-0">
|
||||||
<router-link :to="itemRoute(item)">
|
<router-link :to="itemRoute(item)">
|
||||||
|
|
@ -92,18 +95,66 @@
|
||||||
<span class="float-right">{{ item.owned_quantity }}</span>
|
<span class="float-right">{{ item.owned_quantity }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button v-if="canEdit" class="btn btn-danger btn-sm"
|
<guarded-button v-if="canEdit" class="btn btn-danger btn-sm"
|
||||||
@click="tryDeleteItem(item)">Delete
|
title="Delete"
|
||||||
</button>
|
:confirm-message='`Are you sure you want to delete "${item.name}"? This cannot be undone.`'
|
||||||
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`"
|
@confirm="tryDeleteItem(item)">
|
||||||
class="btn btn-primary btn-sm">Edit
|
<b-icon-trash></b-icon-trash>
|
||||||
|
</guarded-button>
|
||||||
|
<router-link v-if="canEdit" :to="itemEditRoute(item)"
|
||||||
|
class="btn btn-primary btn-sm" title="Edit">
|
||||||
|
<b-icon-pencil-square></b-icon-pencil-square>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
|
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
|
||||||
class="btn btn-secondary btn-sm">
|
class="btn btn-secondary btn-sm">
|
||||||
<b-icon-link></b-icon-link>
|
<b-icon-link></b-icon-link>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
|
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
|
||||||
|
class="btn btn-secondary btn-sm" title="Print Label">
|
||||||
|
<b-icon-qr-code></b-icon-qr-code>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body" v-else>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-sm-6 col-md-4 col-lg-3 col-xl-2 col-xxl-1 d-flex" v-for="item in items"
|
||||||
|
:key="item.id">
|
||||||
|
<div class="card w-100 d-flex flex-column">
|
||||||
|
<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-grid"/>
|
||||||
|
<div class="card-body d-flex flex-column flex-grow-1">
|
||||||
|
<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="badge bg-secondary text-white">{{ item.visibility_policy }}</span>
|
||||||
|
<span class="float-right">{{ item.owned_quantity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group mt-auto">
|
||||||
|
<guarded-button v-if="canEdit" class="btn btn-danger btn-sm"
|
||||||
|
title="Delete"
|
||||||
|
:confirm-message='`Are you sure you want to delete "${item.name}"? This cannot be undone.`'
|
||||||
|
@confirm="tryDeleteItem(item)">
|
||||||
|
<b-icon-trash></b-icon-trash>
|
||||||
|
</guarded-button>
|
||||||
|
<router-link v-if="canEdit" :to="itemEditRoute(item)"
|
||||||
|
class="btn btn-primary btn-sm" title="Edit">
|
||||||
|
<b-icon-pencil-square></b-icon-pencil-square>
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
|
||||||
class="btn btn-secondary btn-sm">
|
class="btn btn-secondary btn-sm">
|
||||||
|
<b-icon-link></b-icon-link>
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
|
||||||
|
class="btn btn-secondary btn-sm" title="Print Label">
|
||||||
<b-icon-qr-code></b-icon-qr-code>
|
<b-icon-qr-code></b-icon-qr-code>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -126,6 +177,7 @@ import {mapActions, mapGetters, mapMutations, 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 AuthenticatedImage from "../components/AuthenticatedImage.vue";
|
||||||
|
import GuardedButton from "@/components/GuardedButton.vue";
|
||||||
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
|
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -140,6 +192,7 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
AuthenticatedImage,
|
AuthenticatedImage,
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
|
GuardedButton,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -166,8 +219,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 +235,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 +255,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 +280,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: {
|
||||||
|
|
@ -244,8 +299,62 @@ export default {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped lang="scss">
|
||||||
.img-preview {
|
@import "../scss/breakpoints";
|
||||||
|
|
||||||
|
.img-preview-grid {
|
||||||
|
height: 160px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.img-preview-masonry {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-action {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masonry-grid {
|
||||||
|
column-count: 2;
|
||||||
|
column-gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.masonry-item {
|
||||||
|
break-inside: avoid;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(md) {
|
||||||
|
.masonry-grid {
|
||||||
|
column-count: 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(lg) {
|
||||||
|
.masonry-grid {
|
||||||
|
column-count: 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xl) {
|
||||||
|
.masonry-grid {
|
||||||
|
column-count: 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@include media-breakpoint-up(xxl) {
|
||||||
|
.masonry-grid {
|
||||||
|
column-count: 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.unguarded.btn-danger,
|
||||||
|
.unguarded.btn-danger:hover,
|
||||||
|
.unguarded.btn-danger:focus {
|
||||||
|
background-color: orange;
|
||||||
|
border-color: orange;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -41,17 +41,18 @@
|
||||||
</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>
|
||||||
<button type="submit" class="btn btn-danger"
|
<guarded-button class="btn btn-danger"
|
||||||
@click="deleteInventoryItem(item).then(() => $router.push(ownerOverviewRoute(item)))">
|
:confirm-message='`Are you sure you want to delete "${item.name}"? This cannot be undone.`'
|
||||||
|
@confirm="tryDeleteItem">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</guarded-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>
|
||||||
|
|
@ -68,6 +69,7 @@ import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import {mapActions, mapGetters, mapState} from "vuex";
|
import {mapActions, mapGetters, mapState} from "vuex";
|
||||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||||
|
import GuardedButton from "@/components/GuardedButton.vue";
|
||||||
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|
@ -75,6 +77,7 @@ export default {
|
||||||
components: {
|
components: {
|
||||||
AuthenticatedImage,
|
AuthenticatedImage,
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
|
GuardedButton,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
|
@ -116,6 +119,9 @@ export default {
|
||||||
...mapActions(["fetchItemByHandle", "deleteInventoryItem", "fetchGroupMemberships"]),
|
...mapActions(["fetchItemByHandle", "deleteInventoryItem", "fetchGroupMemberships"]),
|
||||||
async loadItem() {
|
async loadItem() {
|
||||||
this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {}
|
this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {}
|
||||||
|
},
|
||||||
|
tryDeleteItem() {
|
||||||
|
this.deleteInventoryItem(this.item).then(() => this.$router.push(ownerOverviewRoute(this.item)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
|
||||||
|
|
@ -53,11 +53,11 @@
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<p class="mb-0 text-muted">
|
<p class="mb-0 text-muted">
|
||||||
Don’t have an account?
|
Don’t 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>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
Don’t have an account?
|
Don’t 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ import {mapActions} from "vuex";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import CameraScanner from "@/components/CameraScanner.vue";
|
import CameraScanner from "@/components/CameraScanner.vue";
|
||||||
import {encodeHandleForUrl, expandedRoute, OWNED_KIND_FIELDS} from "@/router";
|
import {encodeHandleForUrl, expandedRoute, OWNED_KIND_FIELDS} from "@/router";
|
||||||
import {decodeShortId, deserializeShortId, isDomainQualifiedShortId, decodeDomainQualifiedShortId} from "@/short-id";
|
import {decodeShortId, deserializeShortId, isDomainQualifiedShortId, decodeDomainQualifiedShortId} from "@/lib/short-id";
|
||||||
|
|
||||||
const OWNER_QUALIFIED_ID_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):([is])(\d+)$/;
|
const OWNER_QUALIFIED_ID_RE = /^(#?[^\s@:/#+~]+@[^\s@:/#+~]+):([is])(\d+)$/;
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}}}))
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,11 @@
|
||||||
<router-link class="list-group-item list-group-item-action" :to="{name: 'data'}"
|
<router-link class="list-group-item list-group-item-action" :to="{name: 'data'}"
|
||||||
active-class="active">Your data
|
active-class="active">Your data
|
||||||
</router-link>
|
</router-link>
|
||||||
<a class="list-group-item list-group-item-action delete" @click="deleteAccount"
|
<guarded-button tag="a" class="list-group-item list-group-item-action delete"
|
||||||
href="#">
|
confirm-message="Are you sure you want to permanently delete your account? All your data will be deleted and you will not be able to log back in. This cannot be undone."
|
||||||
|
@confirm="deleteAccount">
|
||||||
Delete account
|
Delete account
|
||||||
</a>
|
</guarded-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -55,11 +56,12 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import GuardedButton from "@/components/GuardedButton.vue";
|
||||||
import {mapActions, mapGetters, mapMutations} from "vuex";
|
import {mapActions, mapGetters, mapMutations} from "vuex";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
components: {BaseLayout},
|
components: {BaseLayout, GuardedButton},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
deleteError: null,
|
deleteError: null,
|
||||||
|
|
@ -74,10 +76,6 @@ export default {
|
||||||
...mapMutations(['logout']),
|
...mapMutations(['logout']),
|
||||||
async deleteAccount() {
|
async deleteAccount() {
|
||||||
this.deleteError = null;
|
this.deleteError = null;
|
||||||
if (!confirm('Are you sure you want to permanently delete your account? ' +
|
|
||||||
'All your data will be deleted and you will not be able to log back in. This cannot be undone.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const servers = await this.getHomeServers();
|
const servers = await this.getHomeServers();
|
||||||
const response = await servers.delete(this.signAuth, '/api/v1/account/');
|
const response = await servers.delete(this.signAuth, '/api/v1/account/');
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ import BaseLayout from '@/components/BaseLayout.vue';
|
||||||
import {
|
import {
|
||||||
decodeShortId, deserializeShortId, encodeShortId, serializeShortId,
|
decodeShortId, deserializeShortId, encodeShortId, serializeShortId,
|
||||||
isDomainQualifiedShortId, decodeDomainQualifiedShortId
|
isDomainQualifiedShortId, decodeDomainQualifiedShortId
|
||||||
} from '@/short-id';
|
} from '@/lib/short-id';
|
||||||
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP, domainQualifiedRoute} from '@/router';
|
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP, domainQualifiedRoute} from '@/router';
|
||||||
|
|
||||||
const EXAMPLES = [
|
const EXAMPLES = [
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn" @click="fetchLocationsForOwner">Refresh</button>
|
<button class="btn" @click="fetchLocationsForOwner">Refresh</button>
|
||||||
<router-link :to="addLocationRoute" class="btn btn-primary">Add</router-link>
|
<router-link :to="addLocationRoute" class="btn btn-primary">Add</router-link>
|
||||||
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
|
<button v-if="layout === 'grid'" @click="layout = 'tree'" class="btn">
|
||||||
|
<b-icon-list-nested></b-icon-list-nested>
|
||||||
|
</button>
|
||||||
|
<button v-else-if="layout === 'tree'" @click="layout = 'table'" class="btn">
|
||||||
<b-icon-list></b-icon-list>
|
<b-icon-list></b-icon-list>
|
||||||
</button>
|
</button>
|
||||||
<button v-else @click="layout = 'grid'" class="btn">
|
<button v-else @click="layout = 'grid'" class="btn">
|
||||||
|
|
@ -55,28 +58,64 @@
|
||||||
<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)" title="Edit">
|
||||||
<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)">
|
<guarded-button tag="a" title="Delete"
|
||||||
|
:confirm-message='`Are you sure you want to delete "${location.name}"? This cannot be undone.`'
|
||||||
|
@confirm="tryDeleteLocation(location)">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
</a>
|
</guarded-button>
|
||||||
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
|
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
|
||||||
<b-icon-link></b-icon-link>
|
<b-icon-link></b-icon-link>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)">
|
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)" title="Print Label">
|
||||||
<b-icon-qr-code></b-icon-qr-code>
|
<b-icon-qr-code></b-icon-qr-code>
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="card-body" v-else-if="layout === 'tree'">
|
||||||
|
<TreeView :items="locationTree">
|
||||||
|
<template #default="{item: location}">
|
||||||
|
<div class="tree-location-row">
|
||||||
|
<router-link :to="locationRoute(location)" class="tree-location-name text-truncate">
|
||||||
|
{{ location.name }}
|
||||||
|
</router-link>
|
||||||
|
<span class="tree-location-category">
|
||||||
|
<span class="badge bg-info text-white" v-if="location.category">{{ location.category }}</span>
|
||||||
|
<span class="text-muted" v-else>-</span>
|
||||||
|
</span>
|
||||||
|
<span class="tree-location-visibility">
|
||||||
|
<span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span>
|
||||||
|
</span>
|
||||||
|
<div class="tree-location-actions table-action">
|
||||||
|
<router-link :to="locationEditRoute(location)" class="me-1" title="Edit">
|
||||||
|
<b-icon-pencil-square></b-icon-pencil-square>
|
||||||
|
</router-link>
|
||||||
|
<guarded-button tag="a" class="me-1" title="Delete"
|
||||||
|
:confirm-message='`Are you sure you want to delete "${location.name}"? This cannot be undone.`'
|
||||||
|
@confirm="tryDeleteLocation(location)">
|
||||||
|
<b-icon-trash></b-icon-trash>
|
||||||
|
</guarded-button>
|
||||||
|
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)" class="me-1">
|
||||||
|
<b-icon-link></b-icon-link>
|
||||||
|
</router-link>
|
||||||
|
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)" title="Print Label">
|
||||||
|
<b-icon-qr-code></b-icon-qr-code>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</TreeView>
|
||||||
|
</div>
|
||||||
<div class="card-body" v-else>
|
<div class="card-body" v-else>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="location in locations"
|
<div class="col-12 col-md-4 col-lg-3 col-xl-2 col-xxl-1 d-flex" v-for="location in locations"
|
||||||
:key="location.id">
|
:key="location.id">
|
||||||
<div class="card">
|
<div class="card w-100 d-flex flex-column">
|
||||||
<div class="card-body">
|
<div class="card-body d-flex flex-column flex-grow-1">
|
||||||
<h5 class="card-title mb-0">
|
<h5 class="card-title mb-0">
|
||||||
<router-link :to="locationRoute(location)">
|
<router-link :to="locationRoute(location)">
|
||||||
{{ location.name }}
|
{{ location.name }}
|
||||||
|
|
@ -90,19 +129,23 @@
|
||||||
<div class="card-text" v-if="location.description">
|
<div class="card-text" v-if="location.description">
|
||||||
<small>{{ location.description }}</small>
|
<small>{{ location.description }}</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group mt-2">
|
<div class="btn-group mt-auto">
|
||||||
<button class="btn btn-danger btn-sm"
|
<guarded-button class="btn btn-danger btn-sm"
|
||||||
@click="tryDeleteLocation(location)">Delete
|
title="Delete"
|
||||||
</button>
|
:confirm-message='`Are you sure you want to delete "${location.name}"? This cannot be undone.`'
|
||||||
<router-link :to="`${locationRoute(location)}/edit`"
|
@confirm="tryDeleteLocation(location)">
|
||||||
class="btn btn-primary btn-sm">Edit
|
<b-icon-trash></b-icon-trash>
|
||||||
|
</guarded-button>
|
||||||
|
<router-link :to="locationEditRoute(location)"
|
||||||
|
class="btn btn-primary btn-sm" title="Edit">
|
||||||
|
<b-icon-pencil-square></b-icon-pencil-square>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"
|
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"
|
||||||
class="btn btn-secondary btn-sm">
|
class="btn btn-secondary btn-sm">
|
||||||
<b-icon-link></b-icon-link>
|
<b-icon-link></b-icon-link>
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)"
|
<router-link v-if="printLinkFor(location)" :to="printLinkFor(location)"
|
||||||
class="btn btn-secondary btn-sm">
|
class="btn btn-secondary btn-sm" title="Print Label">
|
||||||
<b-icon-qr-code></b-icon-qr-code>
|
<b-icon-qr-code></b-icon-qr-code>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -124,12 +167,14 @@
|
||||||
import {mapActions, mapGetters, mapState} from "vuex";
|
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 GuardedButton from "@/components/GuardedButton.vue";
|
||||||
|
import TreeView from "@/components/TreeView.vue";
|
||||||
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
|
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
|
||||||
|
|
@ -137,6 +182,8 @@ export default {
|
||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
|
GuardedButton,
|
||||||
|
TreeView,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
@ -163,17 +210,31 @@ 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)
|
||||||
},
|
},
|
||||||
|
// See docs/implementation.md#category-tree-reconstruction-from-flat-path-strings.
|
||||||
|
locationTree() {
|
||||||
|
const nodesById = {}
|
||||||
|
for (const location of this.locations) {
|
||||||
|
nodesById[location.id] = {...location, children: []}
|
||||||
|
}
|
||||||
|
const roots = []
|
||||||
|
for (const location of [...this.locations].sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
const node = nodesById[location.id]
|
||||||
|
// Falls back to root if the parent isn't in this owner's own location list.
|
||||||
|
const parent = location.parent != null ? nodesById[location.parent] : null
|
||||||
|
;(parent ? parent.children : roots).push(node)
|
||||||
|
}
|
||||||
|
return roots
|
||||||
|
},
|
||||||
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 +251,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 +272,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: {
|
||||||
|
|
@ -230,11 +294,41 @@ export default {
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-group.mt-2 {
|
.tree-location-row {
|
||||||
|
display: grid;
|
||||||
|
/* Category/Visibility match the table's 20% th widths; actions is max-content so it never wraps. */
|
||||||
|
grid-template-columns: minmax(0, 1fr) 20% 20% auto;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-group.mt-2 .btn {
|
.tree-location-name {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-location-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The th widths above leave Actions only the leftover 5% - without this it wraps mid-icon. */
|
||||||
|
.table-action {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group.mt-auto {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-group.mt-auto .btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.unguarded.btn-danger,
|
||||||
|
.unguarded.btn-danger:hover,
|
||||||
|
.unguarded.btn-danger:focus {
|
||||||
|
background-color: orange;
|
||||||
|
border-color: orange;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -34,17 +34,18 @@
|
||||||
</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>
|
||||||
<button type="submit" class="btn btn-danger"
|
<guarded-button class="btn btn-danger"
|
||||||
@click="deleteStorageLocation(location).then(() => $router.push(ownerLocationOverviewRoute(location)))">
|
:confirm-message='`Are you sure you want to delete "${location.name}"? This cannot be undone.`'
|
||||||
|
@confirm="tryDeleteLocation">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</guarded-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>
|
||||||
|
|
@ -58,6 +59,7 @@
|
||||||
<script>
|
<script>
|
||||||
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 GuardedButton from "@/components/GuardedButton.vue";
|
||||||
import {mapActions, mapGetters, mapState} from "vuex";
|
import {mapActions, mapGetters, mapState} from "vuex";
|
||||||
import {decodeHandleFromUrl, ownerLocationOverviewRoute} from "@/router";
|
import {decodeHandleFromUrl, ownerLocationOverviewRoute} from "@/router";
|
||||||
|
|
||||||
|
|
@ -65,6 +67,7 @@ export default {
|
||||||
name: "StorageLocationDetail",
|
name: "StorageLocationDetail",
|
||||||
components: {
|
components: {
|
||||||
BaseLayout,
|
BaseLayout,
|
||||||
|
GuardedButton,
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
|
|
@ -103,6 +106,9 @@ export default {
|
||||||
...mapActions(["fetchStorageLocationByHandle", "deleteStorageLocation", "fetchGroupMemberships"]),
|
...mapActions(["fetchStorageLocationByHandle", "deleteStorageLocation", "fetchGroupMemberships"]),
|
||||||
async loadLocation() {
|
async loadLocation() {
|
||||||
this.location = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id}) || {}
|
this.location = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id}) || {}
|
||||||
|
},
|
||||||
|
tryDeleteLocation() {
|
||||||
|
this.deleteStorageLocation(this.location).then(() => this.$router.push(ownerLocationOverviewRoute(this.location)))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -19,10 +19,12 @@
|
||||||
<b-icon-arrow-left class="me-1"></b-icon-arrow-left>
|
<b-icon-arrow-left class="me-1"></b-icon-arrow-left>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<button v-if="canAbort" class="btn btn-outline-danger" @click="abortWorkflow" :disabled="loading">
|
<guarded-button v-if="canAbort" class="btn btn-outline-danger"
|
||||||
|
:confirm-message='`Are you sure you want to abort "${getWorkflowDisplayName || workflowInstance.title}"?`'
|
||||||
|
@confirm="abortWorkflow" :disabled="loading">
|
||||||
<b-icon-x-circle class="me-1"></b-icon-x-circle>
|
<b-icon-x-circle class="me-1"></b-icon-x-circle>
|
||||||
Abort
|
Abort
|
||||||
</button>
|
</guarded-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -199,6 +201,7 @@
|
||||||
<script>
|
<script>
|
||||||
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 GuardedButton from "@/components/GuardedButton.vue";
|
||||||
import { mapState, mapActions } from 'vuex';
|
import { mapState, mapActions } from 'vuex';
|
||||||
import { getWorkflow, getWorkflowComponent } from '@/workflows.js';
|
import { getWorkflow, getWorkflowComponent } from '@/workflows.js';
|
||||||
|
|
||||||
|
|
@ -206,7 +209,8 @@ export default {
|
||||||
name: 'WorkflowDetail',
|
name: 'WorkflowDetail',
|
||||||
components: {
|
components: {
|
||||||
...BIcons,
|
...BIcons,
|
||||||
BaseLayout
|
BaseLayout,
|
||||||
|
GuardedButton
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
id: {
|
id: {
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,12 @@
|
||||||
:disabled="loading">
|
:disabled="loading">
|
||||||
<b-icon-eye></b-icon-eye>
|
<b-icon-eye></b-icon-eye>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger"
|
<guarded-button class="btn btn-sm btn-outline-danger"
|
||||||
@click="abortWorkflowInstance(workflow)"
|
:confirm-message='`Are you sure you want to abort "${getWorkflowDisplayName(workflow)}"?`'
|
||||||
|
@confirm="abortWorkflowInstance(workflow)"
|
||||||
:disabled="loading">
|
:disabled="loading">
|
||||||
<b-icon-x-circle></b-icon-x-circle>
|
<b-icon-x-circle></b-icon-x-circle>
|
||||||
</button>
|
</guarded-button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -132,6 +133,7 @@
|
||||||
<script>
|
<script>
|
||||||
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 GuardedButton from "@/components/GuardedButton.vue";
|
||||||
import {mapState, mapActions} from 'vuex';
|
import {mapState, mapActions} from 'vuex';
|
||||||
import {getAllWorkflows, getWorkflow, buildWorkflowApiPayload} from '@/workflows.js';
|
import {getAllWorkflows, getWorkflow, buildWorkflowApiPayload} from '@/workflows.js';
|
||||||
|
|
||||||
|
|
@ -139,7 +141,8 @@ export default {
|
||||||
name: 'Workflows',
|
name: 'Workflows',
|
||||||
components: {
|
components: {
|
||||||
...BIcons,
|
...BIcons,
|
||||||
BaseLayout
|
BaseLayout,
|
||||||
|
GuardedButton
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -264,19 +267,17 @@ export default {
|
||||||
},
|
},
|
||||||
async abortWorkflowInstance(workflow) {
|
async abortWorkflowInstance(workflow) {
|
||||||
const displayName = this.getWorkflowDisplayName(workflow);
|
const displayName = this.getWorkflowDisplayName(workflow);
|
||||||
if (confirm(`Are you sure you want to abort "${displayName}"?`)) {
|
try {
|
||||||
try {
|
this.loading = true;
|
||||||
this.loading = true;
|
this.error = null;
|
||||||
this.error = null;
|
await this.deleteWorkflow(workflow.id);
|
||||||
await this.deleteWorkflow(workflow.id);
|
await this.loadActiveWorkflows();
|
||||||
await this.loadActiveWorkflows();
|
console.log('Workflow aborted successfully:', displayName);
|
||||||
console.log('Workflow aborted successfully:', displayName);
|
} catch (error) {
|
||||||
} catch (error) {
|
console.error('Error aborting workflow:', error);
|
||||||
console.error('Error aborting workflow:', error);
|
this.error = `Failed to abort ${displayName}`;
|
||||||
this.error = `Failed to abort ${displayName}`;
|
} finally {
|
||||||
} finally {
|
this.loading = false;
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,11 @@
|
||||||
<fs-file-source @input="uploadPicture">
|
<fs-file-source @input="uploadPicture">
|
||||||
<span class="btn btn-primary"><i class="fas fa-upload"></i> Upload</span>
|
<span class="btn btn-primary"><i class="fas fa-upload"></i> Upload</span>
|
||||||
</fs-file-source>
|
</fs-file-source>
|
||||||
<button class="btn btn-outline-secondary ml-2" type="button" @click="removePicture">
|
<guarded-button class="btn btn-outline-secondary ml-2" type="button"
|
||||||
|
confirm-message="Are you sure you want to remove your profile picture?"
|
||||||
|
@confirm="removePicture">
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</guarded-button>
|
||||||
</div>
|
</div>
|
||||||
<small>For best results, use an image at least 128px by
|
<small>For best results, use an image at least 128px by
|
||||||
128px in .jpg format</small>
|
128px in .jpg format</small>
|
||||||
|
|
@ -107,10 +109,11 @@
|
||||||
import {mapActions, mapState} from "vuex";
|
import {mapActions, mapState} from "vuex";
|
||||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||||
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
||||||
|
import GuardedButton from "@/components/GuardedButton.vue";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Account',
|
name: 'Account',
|
||||||
components: {AuthenticatedImage, FsFileSource},
|
components: {AuthenticatedImage, FsFileSource, GuardedButton},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['user', 'user_profile']),
|
...mapState(['user', 'user_profile']),
|
||||||
profilePictureSrc() {
|
profilePictureSrc() {
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,11 @@
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label
|
<label class="form-label
|
||||||
d-block">Delete your data</label>
|
d-block">Delete your data</label>
|
||||||
<button type="button" class="btn btn-danger" @click="deleteData">
|
<guarded-button type="button" class="btn btn-danger"
|
||||||
|
confirm-message="Are you sure you want to permanently delete all your data (inventory, locations, settings, friends and files)? Your account itself will stay - this cannot be undone."
|
||||||
|
@confirm="deleteData">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</guarded-button>
|
||||||
<br>
|
<br>
|
||||||
<small>Delete all your data including photos, videos,
|
<small>Delete all your data including photos, videos,
|
||||||
comments, profile information and more</small>
|
comments, profile information and more</small>
|
||||||
|
|
@ -78,12 +80,15 @@
|
||||||
<script>
|
<script>
|
||||||
import {mapActions, mapGetters} from 'vuex';
|
import {mapActions, mapGetters} from 'vuex';
|
||||||
import router from "@/router";
|
import router from "@/router";
|
||||||
|
import GuardedButton from "@/components/GuardedButton.vue";
|
||||||
|
|
||||||
//import VueQrcode from '@chenfengyuan/vue-qrcode';
|
//import VueQrcode from '@chenfengyuan/vue-qrcode';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Data',
|
name: 'Data',
|
||||||
components: {},
|
components: {
|
||||||
|
GuardedButton
|
||||||
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
selectedFile: null,
|
selectedFile: null,
|
||||||
localUserIdentityRecord: null,
|
localUserIdentityRecord: null,
|
||||||
|
|
@ -106,10 +111,6 @@ export default {
|
||||||
async deleteData() {
|
async deleteData() {
|
||||||
this.deleteError = null;
|
this.deleteError = null;
|
||||||
this.deleteSuccess = null;
|
this.deleteSuccess = null;
|
||||||
if (!confirm('Are you sure you want to permanently delete all your data (inventory, locations, ' +
|
|
||||||
'settings, friends and files)? Your account itself will stay - this cannot be undone.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const servers = await this.getHomeServers();
|
const servers = await this.getHomeServers();
|
||||||
const response = await servers.delete(this.signAuth, '/api/v1/account_data/');
|
const response = await servers.delete(this.signAuth, '/api/v1/account_data/');
|
||||||
|
|
|
||||||
|
|
@ -6,6 +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';
|
||||||
|
|
||||||
const workflows = [
|
const workflows = [
|
||||||
{...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow},
|
{...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow},
|
||||||
|
|
@ -15,6 +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},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getAllWorkflows() {
|
export function getAllWorkflows() {
|
||||||
|
|
|
||||||
BIN
testdata/avatars/avatar-2.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
testdata/avatars/avatar-3.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
testdata/avatars/avatar-4.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
testdata/avatars/avatar-5.png
vendored
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
testdata/avatars/avatar-6.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
testdata/avatars/avatar.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
151
testdata/generate_testdata.py
vendored
|
|
@ -8,6 +8,7 @@ who are friends with each other.
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
@ -15,6 +16,10 @@ from hashlib import sha256
|
||||||
from nacl.signing import SigningKey
|
from nacl.signing import SigningKey
|
||||||
from nacl.encoding import HexEncoder
|
from nacl.encoding import HexEncoder
|
||||||
|
|
||||||
|
IMGS_DIR = os.path.join(os.path.dirname(__file__), 'imgs')
|
||||||
|
AVATARS_DIR = os.path.join(os.path.dirname(__file__), 'avatars')
|
||||||
|
IMAGE_ATTACHMENT_PROBABILITY = 0.9
|
||||||
|
|
||||||
|
|
||||||
# Realistic item database with shared_data category/tag references using fully qualified handles
|
# Realistic item database with shared_data category/tag references using fully qualified handles
|
||||||
# Format: (name, category_handle, tags_handles, policy, description, qty, properties_list)
|
# Format: (name, category_handle, tags_handles, policy, description, qty, properties_list)
|
||||||
|
|
@ -250,6 +255,31 @@ def load_shared_data():
|
||||||
return all_tags, all_categories, all_properties
|
return all_tags, all_categories, all_properties
|
||||||
|
|
||||||
|
|
||||||
|
def _load_images_from_dir(directory):
|
||||||
|
"""Read every image file in the given directory and return a list of (data, mime_type) tuples."""
|
||||||
|
images = []
|
||||||
|
for filename in sorted(os.listdir(directory)):
|
||||||
|
filepath = os.path.join(directory, filename)
|
||||||
|
if not os.path.isfile(filepath):
|
||||||
|
continue
|
||||||
|
mime_type, _ = mimetypes.guess_type(filename)
|
||||||
|
if not mime_type:
|
||||||
|
continue
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
images.append((f.read(), mime_type))
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def load_available_images():
|
||||||
|
"""Read every file in imgs/ and return a list of (data, mime_type) tuples."""
|
||||||
|
return _load_images_from_dir(IMGS_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def load_available_avatars():
|
||||||
|
"""Read every file in avatars/ and return a list of (data, mime_type) tuples."""
|
||||||
|
return _load_images_from_dir(AVATARS_DIR)
|
||||||
|
|
||||||
|
|
||||||
def generate_keypair():
|
def generate_keypair():
|
||||||
"""Generate a signing key pair and return (hex_private_key, hex_public_key)."""
|
"""Generate a signing key pair and return (hex_private_key, hex_public_key)."""
|
||||||
signing_key = SigningKey.generate()
|
signing_key = SigningKey.generate()
|
||||||
|
|
@ -258,6 +288,18 @@ def generate_keypair():
|
||||||
return private_hex, public_hex
|
return private_hex, public_hex
|
||||||
|
|
||||||
|
|
||||||
|
def load_or_generate_keypair(key_path):
|
||||||
|
"""Return (hex_private_key, hex_public_key), reusing the key at key_path if it already
|
||||||
|
exists instead of generating (and thereby invalidating) a new identity."""
|
||||||
|
if os.path.exists(key_path):
|
||||||
|
with open(key_path, 'r') as f:
|
||||||
|
private_hex = f.read().strip()
|
||||||
|
signing_key = SigningKey(private_hex.encode('utf-8'), encoder=HexEncoder)
|
||||||
|
public_hex = signing_key.verify_key.encode(encoder=HexEncoder).decode('utf-8')
|
||||||
|
return private_hex, public_hex
|
||||||
|
return generate_keypair()
|
||||||
|
|
||||||
|
|
||||||
def generate_locations_csv(count):
|
def generate_locations_csv(count):
|
||||||
"""Generate a realistic locations.csv with varied storage locations and parent-child relationships."""
|
"""Generate a realistic locations.csv with varied storage locations and parent-child relationships."""
|
||||||
locations = random.sample(LOCATIONS_DATABASE, min(count, len(LOCATIONS_DATABASE)))
|
locations = random.sample(LOCATIONS_DATABASE, min(count, len(LOCATIONS_DATABASE)))
|
||||||
|
|
@ -341,7 +383,7 @@ def _quote_value_if_needed(value):
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def generate_inventory_csv(count, num_locations, location_id_map, item_range=None):
|
def generate_inventory_csv(count, num_locations, location_id_map, item_range=None, available_images=None):
|
||||||
"""Generate a realistic inventory.csv with varied items using shared_data references and properties.
|
"""Generate a realistic inventory.csv with varied items using shared_data references and properties.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -349,6 +391,12 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
|
||||||
num_locations: Number of locations
|
num_locations: Number of locations
|
||||||
location_id_map: Map of location names to IDs
|
location_id_map: Map of location names to IDs
|
||||||
item_range: Tuple of (start_idx, end_idx) to partition the ITEMS_DATABASE, or None for all items
|
item_range: Tuple of (start_idx, end_idx) to partition the ITEMS_DATABASE, or None for all items
|
||||||
|
available_images: List of (data, mime_type) tuples to randomly attach to items, or None to skip
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (inventory_csv_bytes, files) where files maps a zip arcname (matching
|
||||||
|
`toolshed.offlinedata.inventory_files()`'s 'files/<hash><ext>' convention) to file bytes,
|
||||||
|
for every image actually referenced by an item.
|
||||||
"""
|
"""
|
||||||
# Get location names from the map
|
# Get location names from the map
|
||||||
location_names = list(location_id_map.keys())
|
location_names = list(location_id_map.keys())
|
||||||
|
|
@ -365,6 +413,7 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
|
||||||
selected_items = random.sample(available_items, min(count, len(available_items)))
|
selected_items = random.sample(available_items, min(count, len(available_items)))
|
||||||
|
|
||||||
rows = []
|
rows = []
|
||||||
|
files = {}
|
||||||
for i, item_tuple in enumerate(selected_items, 1):
|
for i, item_tuple in enumerate(selected_items, 1):
|
||||||
# Handle both old format (6 elements) and new format (7 elements with properties)
|
# Handle both old format (6 elements) and new format (7 elements with properties)
|
||||||
if len(item_tuple) == 7:
|
if len(item_tuple) == 7:
|
||||||
|
|
@ -382,6 +431,16 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
|
||||||
properties_str = ', '.join(
|
properties_str = ', '.join(
|
||||||
f"{prop_handle}={_quote_value_if_needed(value)}" for prop_handle, value in properties_list)
|
f"{prop_handle}={_quote_value_if_needed(value)}" for prop_handle, value in properties_list)
|
||||||
|
|
||||||
|
# Attach a random image to most items, matching `toolshed.offlinedata.inventory_files()`'s
|
||||||
|
# 'files/<hash><ext>' arcname convention so the import side resolves it as the same file.
|
||||||
|
files_str = ''
|
||||||
|
if available_images and random.random() < IMAGE_ATTACHMENT_PROBABILITY:
|
||||||
|
data, mime_type = random.choice(available_images)
|
||||||
|
extension = mimetypes.guess_extension(mime_type) or ''
|
||||||
|
arcname = f'files/{sha256(data).hexdigest()}{extension}'
|
||||||
|
files[arcname] = data
|
||||||
|
files_str = arcname
|
||||||
|
|
||||||
rows.append({
|
rows.append({
|
||||||
'id': str(i),
|
'id': str(i),
|
||||||
'name': name,
|
'name': name,
|
||||||
|
|
@ -393,7 +452,7 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
|
||||||
'storage_location': location,
|
'storage_location': location,
|
||||||
'tags': ', '.join(tags_for_item),
|
'tags': ', '.join(tags_for_item),
|
||||||
'properties': properties_str,
|
'properties': properties_str,
|
||||||
'files': '',
|
'files': files_str,
|
||||||
'created_at': '2026-08-01T00:00:00+00:00',
|
'created_at': '2026-08-01T00:00:00+00:00',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -403,7 +462,25 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
|
||||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
writer.writerows(rows)
|
writer.writerows(rows)
|
||||||
return output.getvalue().encode('utf-8')
|
return output.getvalue().encode('utf-8'), files
|
||||||
|
|
||||||
|
|
||||||
|
def generate_profile_data(username, domain, first_name, last_name, avatar_data, avatar_mime_type):
|
||||||
|
"""Build profile.json content and the (arcname, data) for its picture, matching
|
||||||
|
`toolshed.offlinedata.profile_data()`/`profile_picture_files()`'s 'files/<hash><ext>' convention.
|
||||||
|
"""
|
||||||
|
extension = mimetypes.guess_extension(avatar_mime_type) or ''
|
||||||
|
arcname = f'files/{sha256(avatar_data).hexdigest()}{extension}'
|
||||||
|
|
||||||
|
profile = {
|
||||||
|
'username': username,
|
||||||
|
'domain': domain,
|
||||||
|
'email': f'{username}@{domain}',
|
||||||
|
'first_name': first_name,
|
||||||
|
'last_name': last_name,
|
||||||
|
'profile_picture': arcname,
|
||||||
|
}
|
||||||
|
return json.dumps(profile, indent=2).encode('utf-8'), arcname
|
||||||
|
|
||||||
|
|
||||||
def generate_sample_files():
|
def generate_sample_files():
|
||||||
|
|
@ -422,7 +499,8 @@ def generate_sample_files():
|
||||||
|
|
||||||
|
|
||||||
def generate_export_zip(username, domain, friend_username, friend_domain, friend_public_key,
|
def generate_export_zip(username, domain, friend_username, friend_domain, friend_public_key,
|
||||||
num_locations=10, num_items=150, item_range=None):
|
num_locations=10, num_items=150, item_range=None,
|
||||||
|
first_name='', last_name='', avatar=None):
|
||||||
"""Generate a zip file (bytes) like user_data() produces.
|
"""Generate a zip file (bytes) like user_data() produces.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -434,6 +512,9 @@ def generate_export_zip(username, domain, friend_username, friend_domain, friend
|
||||||
num_locations: Number of locations to generate
|
num_locations: Number of locations to generate
|
||||||
num_items: Number of items to generate
|
num_items: Number of items to generate
|
||||||
item_range: Tuple of (start_idx, end_idx) to partition the ITEMS_DATABASE, or None for all items
|
item_range: Tuple of (start_idx, end_idx) to partition the ITEMS_DATABASE, or None for all items
|
||||||
|
first_name: First name for profile.json
|
||||||
|
last_name: Last name for profile.json
|
||||||
|
avatar: (data, mime_type) tuple for the profile picture, or None to skip profile.json
|
||||||
"""
|
"""
|
||||||
num_locations = max(5, min(20, num_locations))
|
num_locations = max(5, min(20, num_locations))
|
||||||
num_items = max(100, min(200, num_items))
|
num_items = max(100, min(200, num_items))
|
||||||
|
|
@ -447,13 +528,25 @@ def generate_export_zip(username, domain, friend_username, friend_domain, friend
|
||||||
friends_csv = generate_friends_csv(friend_username, friend_domain, friend_public_key)
|
friends_csv = generate_friends_csv(friend_username, friend_domain, friend_public_key)
|
||||||
zip_file.writestr('friends.csv', friends_csv)
|
zip_file.writestr('friends.csv', friends_csv)
|
||||||
|
|
||||||
inventory_csv = generate_inventory_csv(num_items, num_locations, location_id_map, item_range)
|
available_images = load_available_images()
|
||||||
|
inventory_csv, item_files = generate_inventory_csv(
|
||||||
|
num_items, num_locations, location_id_map, item_range, available_images)
|
||||||
zip_file.writestr('inventory.csv', inventory_csv)
|
zip_file.writestr('inventory.csv', inventory_csv)
|
||||||
|
|
||||||
|
for arcname, content in item_files.items():
|
||||||
|
zip_file.writestr(arcname, content)
|
||||||
|
|
||||||
sample_files = generate_sample_files()
|
sample_files = generate_sample_files()
|
||||||
for arcname, content in sample_files.items():
|
for arcname, content in sample_files.items():
|
||||||
zip_file.writestr(arcname, content)
|
zip_file.writestr(arcname, content)
|
||||||
|
|
||||||
|
if avatar:
|
||||||
|
avatar_data, avatar_mime_type = avatar
|
||||||
|
profile_json, avatar_arcname = generate_profile_data(
|
||||||
|
username, domain, first_name, last_name, avatar_data, avatar_mime_type)
|
||||||
|
zip_file.writestr('profile.json', profile_json)
|
||||||
|
zip_file.writestr(avatar_arcname, avatar_data)
|
||||||
|
|
||||||
return zip_buffer.getvalue()
|
return zip_buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -465,20 +558,30 @@ def main():
|
||||||
all_tags, all_categories, all_properties = load_shared_data()
|
all_tags, all_categories, all_properties = load_shared_data()
|
||||||
print(f" ✓ Loaded {len(all_tags)} tags, {len(all_categories)} categories, and {len(all_properties)} properties")
|
print(f" ✓ Loaded {len(all_tags)} tags, {len(all_categories)} categories, and {len(all_properties)} properties")
|
||||||
|
|
||||||
print("\nGenerating keypairs...")
|
print("\nLoading/generating keypairs...")
|
||||||
user_a_private, user_a_public = generate_keypair()
|
user_a_key_path = os.path.join(script_dir, 'user-a.key')
|
||||||
user_b_private, user_b_public = generate_keypair()
|
user_b_key_path = os.path.join(script_dir, 'user-b.key')
|
||||||
|
user_a_existed = os.path.exists(user_a_key_path)
|
||||||
|
user_b_existed = os.path.exists(user_b_key_path)
|
||||||
|
user_a_private, user_a_public = load_or_generate_keypair(user_a_key_path)
|
||||||
|
user_b_private, user_b_public = load_or_generate_keypair(user_b_key_path)
|
||||||
|
|
||||||
print(f" user-a@a.localhost: {user_a_private[:16]}...{user_a_private[-16:]}")
|
print(f" test_a@a.localhost: {user_a_private[:16]}...{user_a_private[-16:]}")
|
||||||
print(f" user-b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
|
print(f" test_b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
|
||||||
|
|
||||||
print("\nWriting key files...")
|
print("\nWriting key files...")
|
||||||
with open(os.path.join(script_dir, 'user-a.key'), 'w') as f:
|
if user_a_existed:
|
||||||
f.write(user_a_private)
|
print(" ✓ user-a.key (reused existing)")
|
||||||
with open(os.path.join(script_dir, 'user-b.key'), 'w') as f:
|
else:
|
||||||
f.write(user_b_private)
|
with open(user_a_key_path, 'w') as f:
|
||||||
print(" ✓ user-a.key")
|
f.write(user_a_private)
|
||||||
print(" ✓ user-b.key")
|
print(" ✓ user-a.key (generated)")
|
||||||
|
if user_b_existed:
|
||||||
|
print(" ✓ user-b.key (reused existing)")
|
||||||
|
else:
|
||||||
|
with open(user_b_key_path, 'w') as f:
|
||||||
|
f.write(user_b_private)
|
||||||
|
print(" ✓ user-b.key (generated)")
|
||||||
|
|
||||||
print("\nGenerating realistic export zips with partitioned items...")
|
print("\nGenerating realistic export zips with partitioned items...")
|
||||||
# Partition the ITEMS_DATABASE randomly
|
# Partition the ITEMS_DATABASE randomly
|
||||||
|
|
@ -490,13 +593,21 @@ def main():
|
||||||
print(f" User A items: {user_a_item_range[0]}-{user_a_item_range[1]} ({user_a_item_range[1] - user_a_item_range[0] + 1} unique items)")
|
print(f" User A items: {user_a_item_range[0]}-{user_a_item_range[1]} ({user_a_item_range[1] - user_a_item_range[0] + 1} unique items)")
|
||||||
print(f" User B items: {user_b_item_range[0]}-{user_b_item_range[1]} ({user_b_item_range[1] - user_b_item_range[0] + 1} unique items)")
|
print(f" User B items: {user_b_item_range[0]}-{user_b_item_range[1]} ({user_b_item_range[1] - user_b_item_range[0] + 1} unique items)")
|
||||||
|
|
||||||
|
available_avatars = load_available_avatars()
|
||||||
|
if len(available_avatars) >= 2:
|
||||||
|
user_a_avatar, user_b_avatar = random.sample(available_avatars, 2)
|
||||||
|
else:
|
||||||
|
user_a_avatar = user_b_avatar = available_avatars[0] if available_avatars else None
|
||||||
|
|
||||||
user_a_zip = generate_export_zip(
|
user_a_zip = generate_export_zip(
|
||||||
'user-a', 'a.localhost', 'user-b', 'b.localhost', user_b_public,
|
'test_a', 'a.localhost', 'test_b', 'b.localhost', user_b_public,
|
||||||
num_locations=12, num_items=175, item_range=user_a_item_range
|
num_locations=12, num_items=175, item_range=user_a_item_range,
|
||||||
|
first_name='Alice', last_name='Anderson', avatar=user_a_avatar
|
||||||
)
|
)
|
||||||
user_b_zip = generate_export_zip(
|
user_b_zip = generate_export_zip(
|
||||||
'user-b', 'b.localhost', 'user-a', 'a.localhost', user_a_public,
|
'test_b', 'b.localhost', 'test_a', 'a.localhost', user_a_public,
|
||||||
num_locations=8, num_items=140, item_range=user_b_item_range
|
num_locations=8, num_items=140, item_range=user_b_item_range,
|
||||||
|
first_name='Bob', last_name='Baker', avatar=user_b_avatar
|
||||||
)
|
)
|
||||||
|
|
||||||
with open(os.path.join(script_dir, 'user-a.zip'), 'wb') as f:
|
with open(os.path.join(script_dir, 'user-a.zip'), 'wb') as f:
|
||||||
|
|
|
||||||
BIN
testdata/imgs/1-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
testdata/imgs/1-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
testdata/imgs/1.png
vendored
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
testdata/imgs/10-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
testdata/imgs/10-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
testdata/imgs/10.png
vendored
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
testdata/imgs/2-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
testdata/imgs/2-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
testdata/imgs/2.png
vendored
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
testdata/imgs/3-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
testdata/imgs/3-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
testdata/imgs/3.png
vendored
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
testdata/imgs/4-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
testdata/imgs/4-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
testdata/imgs/4.png
vendored
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
testdata/imgs/5-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
testdata/imgs/5-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
testdata/imgs/5.png
vendored
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
testdata/imgs/6-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
testdata/imgs/6-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
testdata/imgs/6.png
vendored
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
testdata/imgs/7-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
testdata/imgs/7-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
testdata/imgs/7.png
vendored
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
testdata/imgs/8-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
testdata/imgs/8-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
testdata/imgs/8.png
vendored
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
testdata/imgs/9-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
testdata/imgs/9-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 522 KiB |
BIN
testdata/imgs/9.png
vendored
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
testdata/imgs/unsplash-1.jpg
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
testdata/imgs/unsplash-2.jpg
vendored
Normal file
|
After Width: | Height: | Size: 888 KiB |
BIN
testdata/imgs/unsplash-3.jpg
vendored
Normal file
|
After Width: | Height: | Size: 970 KiB |
2
testdata/user-a.key
vendored
|
|
@ -1 +1 @@
|
||||||
795e884532881938a1b03e37731443f4d1974327dafd5d508e0cb00cb0b161b6
|
53da21dc8bf556e45b906bc9f1cd22688b29efb2e575c8fb8353199a93553d4b
|
||||||
BIN
testdata/user-a.zip
vendored
2
testdata/user-b.key
vendored
|
|
@ -1 +1 @@
|
||||||
d011fedc3ba733baef6aa7550bbee47db66c2ad3dbfb0f6dcc14c984866df5eb
|
8ed48f39bd054d88124d7b2ae4529a3da692b131194bd26da2bf5ede612baea9
|
||||||