Compare commits

...

10 commits

Author SHA1 Message Date
44f5666417 stash 2026-09-06 02:27:17 +02:00
7135fce421 stash 2026-09-06 01:51:19 +02:00
240a508306 stash 2026-09-06 00:53:21 +02:00
5cd3ae5a13 testdata imgs 2026-09-05 16:23:36 +02:00
197f173097 stash 2026-09-05 16:22:41 +02:00
9814ec7ecd stash 2026-09-05 15:37:39 +02:00
eb7fd65d6b stash 2026-09-04 15:07:40 +02:00
78a1e0301f stash 2026-09-04 15:04:13 +02:00
e88dc898b3 stash 2026-09-04 14:41:26 +02:00
3cd9200d87 stash 2026-09-02 21:30:37 +02:00
92 changed files with 1158 additions and 226 deletions

2
.gitignore vendored
View file

@ -14,7 +14,7 @@ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib/ #lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "frontend/extras"]
path = frontend/extras
url = https://git.neulandlabor.de/j3d1/vue-extras.git

View file

@ -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"]

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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"
}, },

View file

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

View file

@ -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"

View file

@ -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>

View 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>

View file

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

View file

@ -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>

View file

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

View file

@ -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() {

View file

@ -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

View 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";

View file

@ -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";

View 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: []},
])
})

View 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()
})

View 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>"
`;

View file

@ -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])

View file

@ -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"])

View file

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

View file

@ -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>

View file

@ -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() {

View file

@ -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: {

View file

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

View file

@ -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>

View file

@ -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: {

View file

@ -53,11 +53,11 @@
<div class="text-center"> <div class="text-center">
<p class="mb-0 text-muted"> <p class="mb-0 text-muted">
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>

View file

@ -97,11 +97,11 @@
<div class="text-center"> <div class="text-center">
<p class="mb-0 text-muted"> <p class="mb-0 text-muted">
Already have an account? Already have an account?
<router-link to="/login">Login</router-link> <router-link :to="{name: 'login'}">Login</router-link>
</p> </p>
<p class="mb-0 text-muted"> <p class="mb-0 text-muted">
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>

View file

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

View file

@ -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;

View file

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

View file

@ -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/');

View file

@ -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 = [

View file

@ -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>

View file

@ -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: {

View file

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

View file

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

View file

@ -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;
}
} }
} }
} }

View file

@ -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() {

View file

@ -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/');

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
testdata/avatars/avatar-3.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
testdata/avatars/avatar-4.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
testdata/avatars/avatar-5.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 MiB

BIN
testdata/avatars/avatar-6.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
testdata/avatars/avatar.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
testdata/imgs/1-800x600.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
testdata/imgs/1.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
testdata/imgs/10-600x800.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

BIN
testdata/imgs/10-800x600.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
testdata/imgs/10.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

BIN
testdata/imgs/2-600x800.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
testdata/imgs/2-800x600.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

BIN
testdata/imgs/2.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
testdata/imgs/3-600x800.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
testdata/imgs/3-800x600.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

BIN
testdata/imgs/3.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
testdata/imgs/4-600x800.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
testdata/imgs/4-800x600.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
testdata/imgs/4.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

BIN
testdata/imgs/5-600x800.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
testdata/imgs/5-800x600.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
testdata/imgs/5.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
testdata/imgs/6-600x800.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
testdata/imgs/6-800x600.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
testdata/imgs/6.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

BIN
testdata/imgs/7-600x800.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

BIN
testdata/imgs/7-800x600.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
testdata/imgs/7.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
testdata/imgs/8-600x800.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

BIN
testdata/imgs/8-800x600.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
testdata/imgs/8.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

BIN
testdata/imgs/9-600x800.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

BIN
testdata/imgs/9-800x600.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

BIN
testdata/imgs/9.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

BIN
testdata/imgs/unsplash-1.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
testdata/imgs/unsplash-2.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 KiB

BIN
testdata/imgs/unsplash-3.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

2
testdata/user-a.key vendored
View file

@ -1 +1 @@
795e884532881938a1b03e37731443f4d1974327dafd5d508e0cb00cb0b161b6 53da21dc8bf556e45b906bc9f1cd22688b29efb2e575c8fb8353199a93553d4b

BIN
testdata/user-a.zip vendored

Binary file not shown.

2
testdata/user-b.key vendored
View file

@ -1 +1 @@
d011fedc3ba733baef6aa7550bbee47db66c2ad3dbfb0f6dcc14c984866df5eb 8ed48f39bd054d88124d7b2ae4529a3da692b131194bd26da2bf5ede612baea9

BIN
testdata/user-b.zip vendored

Binary file not shown.