Compare commits
10 commits
1853577ff5
...
44f5666417
| Author | SHA1 | Date | |
|---|---|---|---|
| 44f5666417 | |||
| 7135fce421 | |||
| 240a508306 | |||
| 5cd3ae5a13 | |||
| 197f173097 | |||
| 9814ec7ecd | |||
| eb7fd65d6b | |||
| 78a1e0301f | |||
| e88dc898b3 | |||
| 3cd9200d87 |
2
.gitignore
vendored
|
|
@ -14,7 +14,7 @@ dist/
|
|||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
#lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
|
|
|||
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "frontend/extras"]
|
||||
path = frontend/extras
|
||||
url = https://git.neulandlabor.de/j3d1/vue-extras.git
|
||||
|
|
@ -9,7 +9,7 @@ RUN mkdir /git
|
|||
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
||||
COPY package.json ./
|
||||
|
||||
COPY extras/ ./extras/
|
||||
#COPY extras/ ./extras/
|
||||
RUN npm install
|
||||
|
||||
CMD [ "npm", "run", "dev", "--", "--host"]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,10 @@
|
|||
# Worker stays unprivileged as the image's default "nginx" user, but gains the "root" group so
|
||||
# it can read /var/www/userfiles - backend-b (running as root) writes those with
|
||||
# FILE_UPLOAD_DIRECTORY_PERMISSIONS=0750, group-readable but not world-readable. Mirrors
|
||||
# deploy/prod/playbook.yml's "Allow nginx to read backend user files" (www-data joins the
|
||||
# backend's file-owning group instead of loosening the mode).
|
||||
user nginx root;
|
||||
|
||||
events {}
|
||||
|
||||
http {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ ARG GIT_COMMIT=unknown
|
|||
ENV GIT_COMMIT=$GIT_COMMIT
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY extras/ ./extras/
|
||||
#COPY extras/ ./extras/
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
|
|
|||
|
|
@ -229,6 +229,9 @@ In `FotoFirstBulkImportWorkflow.vue`, `photos`' durable state is the `WorkflowIn
|
|||
|
||||
Once a photo finishes uploading, the gallery switches it to the server-fetched thumbnail by re-looking it up by hash rather than mutating the closed-over `photo` object from the upload closure - that reference predates the `this.photos.push()` call, so it's the raw object, not the reactive proxy Vue tracks, and writing to it wouldn't trigger a re-render.
|
||||
|
||||
### 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
|
||||
`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
|
||||
`printLinkFor` in `Inventory.vue` and `StorageLocation.vue` (and the print buttons in `InventoryDetail.vue`/`StorageLocationDetail.vue`) all route to `Print.vue` with the same `{kind, userHandle, id}` query shape - the thing's raw identity - rather than any pre-built link. `kind` is one of `CONTENT_KINDS`' ids (`"item"`/`"storage-location"`, see Content Kinds Registry above); `label.js`'s `buildLabelFields` reads it generically since every kind's prefill now shares this one shape. That lets the print page derive every representation it needs (qualified handle, owner handle, URL, short link, …) itself, instead of being tied to whichever one the calling button happened to construct. Group-owned items have no individual owner handle - `short-id.js`'s `group_item` kind resolves them via `owner_group` instead (see `shortIdLink`) - so there's no `{userHandle, id}` to build yet; `Inventory.vue`'s `printLinkFor` returns `null` for them until group print support exists. Storage locations are always individually owned (see `StorageLocationViewSet.get_queryset`), so `StorageLocation.vue`'s version never has that fallback to make.
|
||||
|
||||
## Categories
|
||||
|
||||
### Category Tree Reconstruction From Flat Path Strings
|
||||
`Category` (`backend/toolshed/models.py`) is a real parent/children tree (self-referencing FK), but `combined_info`'s `/api/v1/info/` response (`backend/toolshed/api/info.py`) flattens every category to `str(category)` - a `/`-joined ancestor path (`Category.__str__`) - with every ancestor and descendant listed as its own separate string, not nested. `Admin.vue`'s `categoryTree` computed rebuilds the hierarchy client-side by splitting each path on `/` and grouping nodes by shared prefixes, then renders it with `TreeView.vue` (`frontend/src/components/TreeView.vue`), a generic recursive collapsible tree component built for single-line slot content.
|
||||
|
||||
`StorageLocation` (`backend/toolshed/models.py`) is the same self-referencing-FK shape, but its `StorageLocationSerializer` keeps `parent` as a real owner-scoped id rather than flattening it away, so `Admin.vue`'s `storageLocationTree` and `StorageLocation.vue`'s `locationTree` computeds group nodes by that id directly instead of parsing path strings - the same tree-from-flat-list pattern, one step simpler since the parent relation already survives serialization.
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 21bf28a8b6293872f5f7a42c9ca0bc12ede5aaae
|
||||
3
frontend/package-lock.json
generated
|
|
@ -887,9 +887,6 @@
|
|||
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
|
||||
"dev": true
|
||||
},
|
||||
"extras": {
|
||||
"version": "file:extras"
|
||||
},
|
||||
"fast-diff": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@
|
|||
"vue-multiselect": "^2.1.7",
|
||||
"vue-router": "^4.1.6",
|
||||
"vuex": "^4.1.0",
|
||||
"extras": "file:./extras",
|
||||
"jquery": "^3.6.0",
|
||||
"popper.js": "^1.16.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<SearchBox/>
|
||||
<div class="navbar-collapse collapse">
|
||||
<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>
|
||||
</router-link>
|
||||
<Notifications :notifications="notifications"/>
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<div style="position: relative;">
|
||||
<div class="image-list">
|
||||
<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"/>
|
||||
</deletable-wrapper>
|
||||
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.data)" :key="file.id"
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<span style="position: relative; display: inline-block;">
|
||||
<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>
|
||||
</button>
|
||||
</guarded-button>
|
||||
</div>
|
||||
<slot></slot>
|
||||
</span>
|
||||
|
|
@ -20,17 +20,21 @@
|
|||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
|
||||
export default {
|
||||
name: "DeletableWrapper",
|
||||
components: {
|
||||
GuardedButton,
|
||||
...BIcons
|
||||
},
|
||||
emits: ["delete"],
|
||||
methods: {
|
||||
triggerDelete() {
|
||||
this.$emit("delete");
|
||||
props: {
|
||||
// No confirmation when omitted - e.g. removing a not-yet-saved staged file, see CombinedFileField.vue's deleteTempFile.
|
||||
confirmMessage: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
}
|
||||
},
|
||||
emits: ["delete"]
|
||||
}
|
||||
</script>
|
||||
43
frontend/src/components/GuardedButton.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<template>
|
||||
<component :is="tag" v-bind="rootProps" :class="{unguarded: shiftHeld}" @click="handleClick">
|
||||
<slot></slot>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {reactive} from "vue"
|
||||
|
||||
const shiftState = reactive({held: false})
|
||||
window.addEventListener("keydown", event => shiftState.held = event.shiftKey)
|
||||
window.addEventListener("keyup", event => shiftState.held = event.shiftKey)
|
||||
|
||||
export default {
|
||||
name: "GuardedButton",
|
||||
props: {
|
||||
confirmMessage: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
tag: {
|
||||
type: String,
|
||||
default: "button"
|
||||
}
|
||||
},
|
||||
emits: ["confirm"],
|
||||
computed: {
|
||||
rootProps() {
|
||||
return this.tag === "a" ? {href: "#"} : {}
|
||||
},
|
||||
shiftHeld() {
|
||||
return shiftState.held
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleClick(event) {
|
||||
if (this.tag === "a") event.preventDefault()
|
||||
if (this.confirmMessage && !event.shiftKey && !confirm(this.confirmMessage)) return
|
||||
this.$emit("confirm", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<nav id="sidebar" class="sidebar">
|
||||
<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">
|
||||
<span class="align-middle">Toolshed</span>
|
||||
</router-link>
|
||||
|
|
@ -10,61 +10,61 @@
|
|||
Tools & Components
|
||||
</li>
|
||||
<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>
|
||||
<span class="align-middle">Inventory</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<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>
|
||||
<span class="align-middle">Storage Locations</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<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>
|
||||
<span class="align-middle">Workflows</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<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>
|
||||
<span class="align-middle">Friends</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<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>
|
||||
<span class="align-middle">Groups</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<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>
|
||||
<span class="align-middle">Files</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<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>
|
||||
<span class="align-middle">Admin</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<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>
|
||||
<span class="align-middle">Swatch</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<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>
|
||||
<span class="align-middle">Print</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<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>
|
||||
<span class="align-middle">Scan</span>
|
||||
</router-link>
|
||||
|
|
|
|||
131
frontend/src/components/TreeView.vue
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
<template>
|
||||
<ul class="tree-view" :class="{'tree-view-root': depth === 0}">
|
||||
<li v-for="node in items" :key="node[itemKey]" class="tree-view-item">
|
||||
<div class="tree-view-row">
|
||||
<button v-if="hasChildren(node)" type="button" class="tree-view-toggle"
|
||||
:aria-expanded="isExpanded(node)" @click="toggleNode(node)">
|
||||
<b-icon-chevron-right v-if="!isExpanded(node)"></b-icon-chevron-right>
|
||||
<b-icon-chevron-down v-else></b-icon-chevron-down>
|
||||
</button>
|
||||
<span v-else class="tree-view-toggle-spacer"></span>
|
||||
<div class="tree-view-content">
|
||||
<slot :item="node" :expanded="isExpanded(node)" :has-children="hasChildren(node)"
|
||||
:depth="depth" :toggle="() => toggleNode(node)"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<tree-view v-if="hasChildren(node) && isExpanded(node)" :items="node[childrenKey]"
|
||||
:item-key="itemKey" :children-key="childrenKey" :default-expanded="defaultExpanded"
|
||||
:depth="depth + 1" @toggle="(...args) => $emit('toggle', ...args)">
|
||||
<template #default="slotProps">
|
||||
<slot v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</tree-view>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tree-view {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.tree-view .tree-view {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.tree-view-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
|
||||
.tree-view-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tree-view-toggle-spacer {
|
||||
flex: 0 0 auto;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.tree-view-content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
|
||||
export default {
|
||||
name: "TreeView",
|
||||
components: {
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
itemKey: {
|
||||
type: String,
|
||||
default: "id"
|
||||
},
|
||||
childrenKey: {
|
||||
type: String,
|
||||
default: "children"
|
||||
},
|
||||
// Whether nodes start out expanded. Only read once per rendered level, see expandedKeys below.
|
||||
defaultExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
emits: ["toggle"],
|
||||
data() {
|
||||
return {
|
||||
// Tracks which of this level's own nodes are expanded. Nested levels are separate
|
||||
// TreeView instances with their own set, since they only exist while their parent
|
||||
// node is expanded in the first place.
|
||||
expandedKeys: new Set(this.defaultExpanded ? this.items.map(item => item[this.itemKey]) : [])
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hasChildren(node) {
|
||||
return Array.isArray(node[this.childrenKey]) && node[this.childrenKey].length > 0
|
||||
},
|
||||
isExpanded(node) {
|
||||
return this.expandedKeys.has(node[this.itemKey])
|
||||
},
|
||||
toggleNode(node) {
|
||||
const key = node[this.itemKey]
|
||||
if (this.expandedKeys.has(key)) {
|
||||
this.expandedKeys.delete(key)
|
||||
} else {
|
||||
this.expandedKeys.add(key)
|
||||
}
|
||||
this.$emit("toggle", node, this.expandedKeys.has(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -18,11 +18,11 @@
|
|||
</span>
|
||||
</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>
|
||||
Profile
|
||||
</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>
|
||||
Settings &
|
||||
Privacy
|
||||
|
|
|
|||
|
|
@ -38,10 +38,12 @@
|
|||
<div v-if="photos.length > 0" class="photo-gallery mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<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>
|
||||
Clear All
|
||||
</button>
|
||||
</guarded-button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<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 {mapActions, mapState} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
|
||||
import CameraFileSource from "@/components/inputs/CameraFileSource.vue";
|
||||
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
||||
|
|
@ -350,6 +353,7 @@ export default {
|
|||
components: {
|
||||
WebcamFileSource,
|
||||
AuthenticatedImage,
|
||||
GuardedButton,
|
||||
DragDropFileSource,
|
||||
CameraFileSource,
|
||||
FsFileSource,
|
||||
|
|
@ -500,14 +504,12 @@ export default {
|
|||
},
|
||||
|
||||
async clearAllPhotos() {
|
||||
if (confirm('Are you sure you want to remove all photos?')) {
|
||||
const removed = this.photos;
|
||||
this.photos = [];
|
||||
await Promise.all(removed.map(photo =>
|
||||
this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash})
|
||||
.catch(error => console.error('Failed to unstage photo:', error))
|
||||
));
|
||||
}
|
||||
const removed = this.photos;
|
||||
this.photos = [];
|
||||
await Promise.all(removed.map(photo =>
|
||||
this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash})
|
||||
.catch(error => console.error('Failed to unstage photo:', error))
|
||||
));
|
||||
},
|
||||
|
||||
proceedFromStep1() {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import ShortId from '@/views/ShortId.vue';
|
|||
import {
|
||||
encodeShortId, serializeShortId, decodeShortId, deserializeShortId,
|
||||
isDomainQualifiedShortId, decodeDomainQualifiedShortId
|
||||
} from '@/short-id';
|
||||
} from '@/lib/short-id';
|
||||
|
||||
import Account from '@/views/settings/Account.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.
|
||||
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) {
|
||||
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 = {
|
||||
item: ({owner_identity_id, item_local_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}) => {
|
||||
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}) => {
|
||||
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}) => {
|
||||
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}) => {
|
||||
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.
|
||||
|
|
@ -121,30 +125,33 @@ export async function domainQualifiedRoute(domain, ints) {
|
|||
return undefined;
|
||||
}
|
||||
return owned.isLocation
|
||||
? `/storage-locations/${encodeHandleForUrl(resolved.handle)}/${resolved.id}`
|
||||
: `/inventory/${encodeHandleForUrl(resolved.handle)}/${resolved.id}`;
|
||||
? {name: 'locations-detail', params: {handle: encodeHandleForUrl(resolved.handle), id: 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.
|
||||
path: '/inventory/:owner?',
|
||||
name: 'inventory',
|
||||
component: Inventory,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/inventory/:handle/:id',
|
||||
name: 'inventory-detail',
|
||||
component: InventoryDetail,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/inventory/:handle/:id/edit',
|
||||
name: 'inventory-edit',
|
||||
component: InventoryEdit,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
// Label/short-link entry point; alias of /inventory/:handle/:id. See docs/implementation.md#item-short-link-redirect-route.
|
||||
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.
|
||||
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.
|
||||
path: '/inventory/new/:group?',
|
||||
name: 'inventory-new',
|
||||
component: InventoryNew,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/friends',
|
||||
name: 'friends',
|
||||
component: Friends,
|
||||
meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: '/groups',
|
||||
name: 'groups',
|
||||
component: Groups,
|
||||
meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: '/groups/new',
|
||||
name: 'groups-new',
|
||||
component: GroupNew,
|
||||
meta: {requiresAuth: true}
|
||||
}, {
|
||||
path: '/groups/:handle',
|
||||
name: 'group-detail',
|
||||
component: GroupDetail,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {path: '/files', component: Files, meta: {requiresAuth: true}
|
||||
}, {path: '/files', name: 'files', component: Files, meta: {requiresAuth: true}
|
||||
}, {path: '/workflows',
|
||||
name: 'workflows',
|
||||
component: Workflows,
|
||||
meta: {requiresAuth: true},
|
||||
}, {path: '/workflows/:id/:step?',
|
||||
|
|
@ -190,10 +203,12 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {path: '/admin',
|
||||
name: 'admin',
|
||||
component: Admin,
|
||||
meta: {requiresAuth: true}
|
||||
}, {path: '/swatch', component: Swatch, meta: {requiresAuth: true}}, {
|
||||
}, {path: '/swatch', name: 'swatch', component: Swatch, meta: {requiresAuth: true}}, {
|
||||
path: '/print',
|
||||
name: 'print',
|
||||
component: Print,
|
||||
meta: {requiresAuth: true},
|
||||
props: route => {
|
||||
|
|
@ -204,23 +219,27 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
|||
}
|
||||
}, {
|
||||
path: '/scan',
|
||||
name: 'scan',
|
||||
component: Scan,
|
||||
meta: {requiresAuth: true},
|
||||
}, {
|
||||
path: '/search/:query',
|
||||
name: 'search',
|
||||
component: Search,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {path: '/login', component: Login, meta: {requiresAuth: false}}, {
|
||||
}, {path: '/login', name: 'login', component: Login, meta: {requiresAuth: false}}, {
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: Register,
|
||||
meta: {requiresAuth: false}
|
||||
}, {path: '/pairing', component: Pairing, meta: {requiresAuth: false}}, {
|
||||
}, {path: '/pairing', name: 'pairing', component: Pairing, meta: {requiresAuth: false}}, {
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
component: Profile,
|
||||
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: '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.
|
||||
path: '/storage-location/:owner?',
|
||||
path: '/locations/:owner?',
|
||||
name: 'locations',
|
||||
component: StorageLocation,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/storage-locations/:handle/:id',
|
||||
path: '/locations/:handle/:id',
|
||||
name: 'locations-detail',
|
||||
component: StorageLocationDetail,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
}, {
|
||||
path: '/storage-locations/:handle/:id/edit',
|
||||
path: '/locations/:handle/:id/edit',
|
||||
name: 'locations-edit',
|
||||
component: StorageLocationEdit,
|
||||
meta: {requiresAuth: 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.
|
||||
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.
|
||||
path: '/storage-locations/new/:group?',
|
||||
path: '/locations/new/:group?',
|
||||
name: 'locations-new',
|
||||
component: StorageLocationNew,
|
||||
meta: {requiresAuth: true},
|
||||
props: true
|
||||
|
|
|
|||
24
frontend/src/scss/_breakpoints.scss
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Single source of truth for the grid breakpoints, shared between toolshed.scss
|
||||
// (which feeds them into Bootstrap's own grid/container generation) and any
|
||||
// component's <style lang="scss"> block that needs a matching media query
|
||||
// via media-breakpoint-up()/-down()/-between() (see mixins/_breakpoints.scss).
|
||||
//
|
||||
// Bootstrap 4 stops at xl; xxl is faked here for grid sizing until the
|
||||
// Bootstrap 5 upgrade brings a real one.
|
||||
$grid-breakpoints: (
|
||||
xs: 0,
|
||||
sm: 576px,
|
||||
md: 768px,
|
||||
lg: 992px,
|
||||
xl: 1200px,
|
||||
xxl: 3000px
|
||||
);
|
||||
$container-max-widths: (
|
||||
sm: 540px,
|
||||
md: 720px,
|
||||
lg: 960px,
|
||||
xl: 1140px,
|
||||
xxl: 2960px
|
||||
);
|
||||
|
||||
@import "bootstrap/scss/mixins/breakpoints";
|
||||
|
|
@ -27,6 +27,8 @@ $h4-font-size: $font-size-base * 1.25;
|
|||
$h5-font-size: $font-size-base;
|
||||
$h6-font-size: $font-size-base;
|
||||
|
||||
@import "breakpoints";
|
||||
|
||||
@import "bootstrap/scss/functions";
|
||||
@import "bootstrap/scss/variables";
|
||||
|
||||
|
|
|
|||
56
frontend/src/tests/Admin.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import Admin from '../views/Admin.vue'
|
||||
|
||||
test('Admin categoryTree groups flat "/"-joined category paths into a tree', () => {
|
||||
const categories = [
|
||||
'Electronics',
|
||||
'Electronics/Passives',
|
||||
'Electronics/Passives/Resistors',
|
||||
'Electronics/Actives',
|
||||
'Tools',
|
||||
]
|
||||
|
||||
const categoryTree = Admin.computed.categoryTree.call({categories})
|
||||
|
||||
expect(categoryTree).toEqual([
|
||||
{
|
||||
id: 'Electronics', name: 'Electronics', children: [
|
||||
{id: 'Electronics/Actives', name: 'Actives', children: []},
|
||||
{
|
||||
id: 'Electronics/Passives', name: 'Passives', children: [
|
||||
{id: 'Electronics/Passives/Resistors', name: 'Resistors', children: []},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{id: 'Tools', name: 'Tools', children: []},
|
||||
])
|
||||
})
|
||||
|
||||
test('Admin storageLocationTree groups locations by their real parent id', () => {
|
||||
const storage_locations = [
|
||||
{id: 3, name: 'Shelf B', parent: 1},
|
||||
{id: 1, name: 'Garage', parent: null},
|
||||
{id: 5, name: 'Attic', parent: null},
|
||||
{id: 2, name: 'Shelf A', parent: 1},
|
||||
{id: 4, name: 'Drawer 1', parent: 3},
|
||||
// A location whose parent was deleted/isn't in this owner's own list falls back to root.
|
||||
{id: 6, name: 'Orphan Box', parent: 99},
|
||||
]
|
||||
|
||||
const storageLocationTree = Admin.computed.storageLocationTree.call({storage_locations})
|
||||
|
||||
expect(storageLocationTree).toEqual([
|
||||
{id: 5, name: 'Attic', children: []},
|
||||
{
|
||||
id: 1, name: 'Garage', children: [
|
||||
{id: 2, name: 'Shelf A', children: []},
|
||||
{
|
||||
id: 3, name: 'Shelf B', children: [
|
||||
{id: 4, name: 'Drawer 1', children: []},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{id: 6, name: 'Orphan Box', children: []},
|
||||
])
|
||||
})
|
||||
58
frontend/src/tests/TreeView.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import {mount} from '@vue/test-utils'
|
||||
import TreeView from '../components/TreeView.vue'
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Garage',
|
||||
children: [
|
||||
{id: 2, name: 'Shelf A'},
|
||||
{id: 3, name: 'Shelf B', children: [{id: 4, name: 'Drawer 1'}]},
|
||||
]
|
||||
},
|
||||
{id: 5, name: 'Attic'},
|
||||
]
|
||||
|
||||
test('TreeView component', async () => {
|
||||
expect(TreeView).toBeTruthy()
|
||||
|
||||
const wrapper = mount(TreeView, {
|
||||
props: {items},
|
||||
slots: {
|
||||
default: '<span class="label">{{ params.item.name }}</span>',
|
||||
},
|
||||
})
|
||||
|
||||
// Children start collapsed - only the two top-level nodes are visible.
|
||||
expect(wrapper.findAll('.tree-view-item').length).toBe(2)
|
||||
expect(wrapper.text()).toContain('Garage')
|
||||
expect(wrapper.text()).toContain('Attic')
|
||||
expect(wrapper.text()).not.toContain('Shelf A')
|
||||
|
||||
// Nothing is expanded yet, so these are exactly the two top-level nodes.
|
||||
const topLevelItems = wrapper.findAll('.tree-view-item')
|
||||
|
||||
// The leaf node ("Attic") has no toggle button, just a spacer.
|
||||
expect(topLevelItems[1].find('.tree-view-toggle').exists()).toBe(false)
|
||||
expect(topLevelItems[1].find('.tree-view-toggle-spacer').exists()).toBe(true)
|
||||
|
||||
// Expanding "Garage" reveals its two direct children, recursively rendered.
|
||||
await topLevelItems[0].find('.tree-view-toggle').trigger('click')
|
||||
expect(wrapper.text()).toContain('Shelf A')
|
||||
expect(wrapper.text()).toContain('Shelf B')
|
||||
expect(wrapper.text()).not.toContain('Drawer 1')
|
||||
expect(wrapper.emitted('toggle')[0]).toEqual([items[0], true])
|
||||
|
||||
// Expanding the nested "Shelf B" reveals the grandchild. Match on the exact text of its own
|
||||
// row (not just "contains"), since the ancestor "Garage" item's text also contains "Shelf B".
|
||||
const shelfB = wrapper.findAll('.tree-view-item').find(item => item.text().trim() === 'Shelf B')
|
||||
await shelfB.find('.tree-view-toggle').trigger('click')
|
||||
expect(wrapper.text()).toContain('Drawer 1')
|
||||
|
||||
// Collapsing "Garage" again hides its whole subtree.
|
||||
await topLevelItems[0].find('.tree-view-toggle').trigger('click')
|
||||
expect(wrapper.text()).not.toContain('Shelf A')
|
||||
expect(wrapper.text()).not.toContain('Drawer 1')
|
||||
|
||||
expect(wrapper.html()).toMatchSnapshot()
|
||||
})
|
||||
20
frontend/src/tests/__snapshots__/TreeView.js.snap
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`TreeView component 1`] = `
|
||||
"<ul data-v-4a3416e7=\\"\\" class=\\"tree-view tree-view-root\\">
|
||||
<li data-v-4a3416e7=\\"\\" class=\\"tree-view-item\\">
|
||||
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-row\\"><button data-v-4a3416e7=\\"\\" type=\\"button\\" class=\\"tree-view-toggle\\" aria-expanded=\\"false\\"><svg data-v-4a3416e7=\\"\\" width=\\"1em\\" height=\\"1em\\" viewBox=\\"0 0 16 16\\" fill=\\"currentColor\\" role=\\"img\\" focusable=\\"false\\">
|
||||
<path fill-rule=\\"evenodd\\" d=\\"M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708\\"></path>
|
||||
</svg></button>
|
||||
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-content\\"><span class=\\"label\\">Garage</span></div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</li>
|
||||
<li data-v-4a3416e7=\\"\\" class=\\"tree-view-item\\">
|
||||
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-row\\"><span data-v-4a3416e7=\\"\\" class=\\"tree-view-toggle-spacer\\"></span>
|
||||
<div data-v-4a3416e7=\\"\\" class=\\"tree-view-content\\"><span class=\\"label\\">Attic</span></div>
|
||||
</div>
|
||||
<!--v-if-->
|
||||
</li>
|
||||
</ul>"
|
||||
`;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
encodeShortId, decodeShortId, deserializeShortId, serializeShortId,
|
||||
encodeDomainQualifiedShortId, decodeDomainQualifiedShortId, isDomainQualifiedShortId
|
||||
} from '../short-id.js'
|
||||
} from '../lib/short-id.js'
|
||||
|
||||
test('encodes the worked example from docs/handles-and-shortids.md', () => {
|
||||
const token = encodeShortId([0, 7, 42])
|
||||
|
|
|
|||
|
|
@ -36,11 +36,9 @@
|
|||
<h5 class="card-title">Categories</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="category in categories.sort()" :key="category.id">
|
||||
{{ category }}
|
||||
</li>
|
||||
</ul>
|
||||
<TreeView :items="categoryTree">
|
||||
<template #default="{item}">{{ item.name }}</template>
|
||||
</TreeView>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
|
|
@ -84,11 +82,9 @@
|
|||
<h5 class="card-title">Storage Locations</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li v-for="location in storage_locations.sort()" :key="location.id">
|
||||
{{ location.path }}
|
||||
</li>
|
||||
</ul>
|
||||
<TreeView :items="storageLocationTree">
|
||||
<template #default="{item}">{{ item.name }}</template>
|
||||
</TreeView>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -102,15 +98,49 @@
|
|||
import {mapActions, mapState} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import TreeView from "@/components/TreeView.vue";
|
||||
|
||||
export default {
|
||||
name: "Admin",
|
||||
components: {
|
||||
BaseLayout,
|
||||
TreeView,
|
||||
...BIcons
|
||||
},
|
||||
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: {
|
||||
...mapActions(["fetchInfo", "fetchStorageLocations"])
|
||||
|
|
|
|||
|
|
@ -2,17 +2,130 @@
|
|||
<BaseLayout>
|
||||
<main class="content">
|
||||
<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="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-header">
|
||||
<h5 class="card-title mb-0">Empty card</h5>
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">Recent items</h5>
|
||||
<router-link :to="{name: 'inventory'}" class="small">View all</router-link>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="logo">
|
||||
<img src="/src/assets/icons/toolshed-48x48.png" alt="Toolshed logo">
|
||||
<div class="row" v-if="recentItems.length > 0">
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -23,18 +136,92 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import {mapActions, mapGetters, mapState} from 'vuex';
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import {encodeHandleForUrl} from "@/router";
|
||||
|
||||
export default {
|
||||
name: 'Dashboard',
|
||||
components: {
|
||||
...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>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
transition: transform .15s ease-in-out;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 1.75rem;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: .8rem;
|
||||
color: var(--bs-text-3);
|
||||
}
|
||||
|
||||
.img-preview {
|
||||
object-fit: cover;
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
</div>
|
||||
<div class="card-body">
|
||||
<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"
|
||||
class="img-thumbnail"/>
|
||||
</deletable-wrapper>
|
||||
|
|
|
|||
|
|
@ -46,10 +46,12 @@
|
|||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
Edit
|
||||
</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>
|
||||
Delete
|
||||
</a>
|
||||
</guarded-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -107,6 +109,7 @@ import * as BIcons from "bootstrap-icons-vue";
|
|||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import UserNameTag from "@/components/UserNameTag.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
|
||||
export default {
|
||||
name: 'Inventory',
|
||||
|
|
@ -114,6 +117,7 @@ export default {
|
|||
BaseLayout,
|
||||
AuthenticatedImage,
|
||||
UserNameTag,
|
||||
GuardedButton,
|
||||
...BIcons
|
||||
},
|
||||
data() {
|
||||
|
|
|
|||
|
|
@ -40,10 +40,12 @@
|
|||
<user-name-tag :user="member"/>
|
||||
</td>
|
||||
<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>
|
||||
Remove
|
||||
</a>
|
||||
</guarded-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -61,6 +63,7 @@ import {mapActions} from "vuex";
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import UserNameTag from "@/components/UserNameTag.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
import {decodeHandleFromUrl} from "@/router";
|
||||
|
||||
export default {
|
||||
|
|
@ -68,6 +71,7 @@ export default {
|
|||
components: {
|
||||
BaseLayout,
|
||||
UserNameTag,
|
||||
GuardedButton,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||
Refresh
|
||||
</a>
|
||||
<router-link to="/groups/new" class="align-middle">
|
||||
<router-link :to="{name: 'groups-new'}" class="align-middle">
|
||||
<b-icon-plus></b-icon-plus>
|
||||
New group
|
||||
</router-link>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
<tbody>
|
||||
<tr v-for="group in allGroups" :key="group.key">
|
||||
<td>
|
||||
<router-link :to="`/groups/${encodeHandleForUrl(group.handle)}`">
|
||||
<router-link :to="{name: 'group-detail', params: {handle: encodeHandleForUrl(group.handle)}}">
|
||||
{{ group.handle }}
|
||||
</router-link>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@
|
|||
<div class="btn-group">
|
||||
<button class="btn" @click="fetchItemsForOwner">Refresh</button>
|
||||
<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>
|
||||
</button>
|
||||
<button v-else @click="layout = 'grid'" class="btn">
|
||||
|
|
@ -55,32 +58,32 @@
|
|||
</td>
|
||||
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
|
||||
<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>
|
||||
</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>
|
||||
</a>
|
||||
</guarded-button>
|
||||
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
|
||||
<b-icon-link></b-icon-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>
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card-body" v-else>
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in items"
|
||||
:key="item.id">
|
||||
<div class="card-body" v-else-if="layout === 'masonry'">
|
||||
<div class="masonry-grid">
|
||||
<div class="masonry-item" v-for="item in items" :key="item.id">
|
||||
<div class="card">
|
||||
<img :src="item.image" class="" :alt="item.image">
|
||||
<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"/>
|
||||
class="card-img-top img-preview-masonry"/>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-0">
|
||||
<router-link :to="itemRoute(item)">
|
||||
|
|
@ -92,18 +95,66 @@
|
|||
<span class="float-right">{{ item.owned_quantity }}</span>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button v-if="canEdit" class="btn btn-danger btn-sm"
|
||||
@click="tryDeleteItem(item)">Delete
|
||||
</button>
|
||||
<router-link v-if="canEdit" :to="`${itemRoute(item)}/edit`"
|
||||
class="btn btn-primary btn-sm">Edit
|
||||
<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">
|
||||
<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>
|
||||
</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">
|
||||
<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>
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
@ -126,6 +177,7 @@ import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
import {shortenedRoute, encodeHandleForUrl, decodeHandleFromUrl} from "@/router";
|
||||
|
||||
export default {
|
||||
|
|
@ -140,6 +192,7 @@ export default {
|
|||
components: {
|
||||
AuthenticatedImage,
|
||||
BaseLayout,
|
||||
GuardedButton,
|
||||
...BIcons
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -166,8 +219,8 @@ export default {
|
|||
return this.owner ? decodeHandleFromUrl(this.owner) : this.user
|
||||
},
|
||||
set(value) {
|
||||
const path = value === this.user ? '/inventory' : `/inventory/${encodeHandleForUrl(value)}`
|
||||
this.$router.replace(path)
|
||||
const params = value === this.user ? {} : {owner: encodeHandleForUrl(value)}
|
||||
this.$router.replace({name: 'inventory', params})
|
||||
}
|
||||
},
|
||||
isGroupSelected() {
|
||||
|
|
@ -182,9 +235,8 @@ export default {
|
|||
return this.selectedOwner === this.user ? this.inventory_items : this.groupInventoryItems(this.selectedOwner)
|
||||
},
|
||||
addItemRoute() {
|
||||
return this.selectedOwner === this.user
|
||||
? '/inventory/new'
|
||||
: `/inventory/new/${encodeHandleForUrl(this.selectedOwner)}`
|
||||
const params = this.selectedOwner === this.user ? {} : {group: encodeHandleForUrl(this.selectedOwner)}
|
||||
return {name: 'inventory-new', params}
|
||||
}
|
||||
},
|
||||
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.
|
||||
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) {
|
||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||
|
|
@ -225,7 +280,7 @@ export default {
|
|||
printLinkFor(item) {
|
||||
const userHandle = item.owner || item.owner_group
|
||||
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: {
|
||||
|
|
@ -244,8 +299,62 @@ export default {
|
|||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.img-preview {
|
||||
<style scoped lang="scss">
|
||||
@import "../scss/breakpoints";
|
||||
|
||||
.img-preview-grid {
|
||||
height: 160px;
|
||||
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>
|
||||
|
|
@ -41,17 +41,18 @@
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
Edit
|
||||
</button>
|
||||
<button type="submit" class="btn btn-danger"
|
||||
@click="deleteInventoryItem(item).then(() => $router.push(ownerOverviewRoute(item)))">
|
||||
<guarded-button class="btn btn-danger"
|
||||
:confirm-message='`Are you sure you want to delete "${item.name}"? This cannot be undone.`'
|
||||
@confirm="tryDeleteItem">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
Delete
|
||||
</button>
|
||||
</guarded-button>
|
||||
<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>
|
||||
Print label
|
||||
</button>
|
||||
|
|
@ -68,6 +69,7 @@ import * as BIcons from "bootstrap-icons-vue";
|
|||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||
|
||||
export default {
|
||||
|
|
@ -75,6 +77,7 @@ export default {
|
|||
components: {
|
||||
AuthenticatedImage,
|
||||
BaseLayout,
|
||||
GuardedButton,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
|
|
@ -116,6 +119,9 @@ export default {
|
|||
...mapActions(["fetchItemByHandle", "deleteInventoryItem", "fetchGroupMemberships"]),
|
||||
async loadItem() {
|
||||
this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {}
|
||||
},
|
||||
tryDeleteItem() {
|
||||
this.deleteInventoryItem(this.item).then(() => this.$router.push(ownerOverviewRoute(this.item)))
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
|
|||
|
|
@ -53,11 +53,11 @@
|
|||
<div class="text-center">
|
||||
<p class="mb-0 text-muted">
|
||||
Don’t have an account?
|
||||
<router-link to="/register">Sign up</router-link>
|
||||
<router-link :to="{name: 'register'}">Sign up</router-link>
|
||||
</p>
|
||||
<p class="mb-0 text-muted">
|
||||
Connect with existing account?
|
||||
<router-link to="/pairing">Pairing</router-link>
|
||||
<router-link :to="{name: 'pairing'}">Pairing</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -97,11 +97,11 @@
|
|||
<div class="text-center">
|
||||
<p class="mb-0 text-muted">
|
||||
Already have an account?
|
||||
<router-link to="/login">Login</router-link>
|
||||
<router-link :to="{name: 'login'}">Login</router-link>
|
||||
</p>
|
||||
<p class="mb-0 text-muted">
|
||||
Don’t have an account?
|
||||
<router-link to="/register">Sign up</router-link>
|
||||
<router-link :to="{name: 'register'}">Sign up</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -77,11 +77,11 @@
|
|||
<div class="text-center">
|
||||
<p class="mb-0 text-muted">
|
||||
Already have an account?
|
||||
<router-link to="/login">Login</router-link>
|
||||
<router-link :to="{name: 'login'}">Login</router-link>
|
||||
</p>
|
||||
<p class="mb-0 text-muted">
|
||||
Connect with existing account?
|
||||
<router-link to="/pairing">Pairing</router-link>
|
||||
<router-link :to="{name: 'pairing'}">Pairing</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ import {mapActions} from "vuex";
|
|||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import CameraScanner from "@/components/CameraScanner.vue";
|
||||
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+)$/;
|
||||
|
||||
|
|
@ -101,8 +101,8 @@ function classifyScanText(text) {
|
|||
const [, handle, kindLetter, id] = ownerMatch;
|
||||
const kind = kindLetter === "s" ? "storage_location" : "item";
|
||||
const to = kind === "storage_location"
|
||||
? `/storage-locations/${encodeHandleForUrl(handle)}/${id}`
|
||||
: `/inventory/${encodeHandleForUrl(handle)}/${id}`;
|
||||
? {name: 'locations-detail', params: {handle: encodeHandleForUrl(handle), id}}
|
||||
: {name: 'inventory-detail', params: {handle: encodeHandleForUrl(handle), id}};
|
||||
return {to, ownerQualifiedId: {handle, kind, id}};
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ export default {
|
|||
// user handle - one route shape, no owner-is-me special case.
|
||||
this.fetchSearchResults({query: this.query}).then((results) => {
|
||||
this.search_results = results.map(e => (
|
||||
{...e, route: `/inventory/${encodeHandleForUrl(e.owner)}/${e.id}`}))
|
||||
{...e, route: {name: 'inventory-detail', params: {handle: encodeHandleForUrl(e.owner), id: e.id}}}))
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -35,10 +35,11 @@
|
|||
<router-link class="list-group-item list-group-item-action" :to="{name: 'data'}"
|
||||
active-class="active">Your data
|
||||
</router-link>
|
||||
<a class="list-group-item list-group-item-action delete" @click="deleteAccount"
|
||||
href="#">
|
||||
<guarded-button tag="a" class="list-group-item list-group-item-action delete"
|
||||
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
|
||||
</a>
|
||||
</guarded-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -55,11 +56,12 @@
|
|||
|
||||
<script>
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
import {mapActions, mapGetters, mapMutations} from "vuex";
|
||||
|
||||
export default {
|
||||
name: 'Settings',
|
||||
components: {BaseLayout},
|
||||
components: {BaseLayout, GuardedButton},
|
||||
data() {
|
||||
return {
|
||||
deleteError: null,
|
||||
|
|
@ -74,10 +76,6 @@ export default {
|
|||
...mapMutations(['logout']),
|
||||
async deleteAccount() {
|
||||
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 {
|
||||
const servers = await this.getHomeServers();
|
||||
const response = await servers.delete(this.signAuth, '/api/v1/account/');
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ import BaseLayout from '@/components/BaseLayout.vue';
|
|||
import {
|
||||
decodeShortId, deserializeShortId, encodeShortId, serializeShortId,
|
||||
isDomainQualifiedShortId, decodeDomainQualifiedShortId
|
||||
} from '@/short-id';
|
||||
} from '@/lib/short-id';
|
||||
import {expandedRoute as buildExpandedRoute, NEEDS_IDMAP, domainQualifiedRoute} from '@/router';
|
||||
|
||||
const EXAMPLES = [
|
||||
|
|
|
|||
|
|
@ -20,7 +20,10 @@
|
|||
<div class="btn-group">
|
||||
<button class="btn" @click="fetchLocationsForOwner">Refresh</button>
|
||||
<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>
|
||||
</button>
|
||||
<button v-else @click="layout = 'grid'" class="btn">
|
||||
|
|
@ -55,28 +58,64 @@
|
|||
<span class="badge bg-secondary text-white">{{ location.visibility_policy }}</span>
|
||||
</td>
|
||||
<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>
|
||||
</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>
|
||||
</a>
|
||||
</guarded-button>
|
||||
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)">
|
||||
<b-icon-link></b-icon-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>
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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="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">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card w-100 d-flex flex-column">
|
||||
<div class="card-body d-flex flex-column flex-grow-1">
|
||||
<h5 class="card-title mb-0">
|
||||
<router-link :to="locationRoute(location)">
|
||||
{{ location.name }}
|
||||
|
|
@ -90,19 +129,23 @@
|
|||
<div class="card-text" v-if="location.description">
|
||||
<small>{{ location.description }}</small>
|
||||
</div>
|
||||
<div class="btn-group mt-2">
|
||||
<button class="btn btn-danger btn-sm"
|
||||
@click="tryDeleteLocation(location)">Delete
|
||||
</button>
|
||||
<router-link :to="`${locationRoute(location)}/edit`"
|
||||
class="btn btn-primary btn-sm">Edit
|
||||
<div class="btn-group mt-auto">
|
||||
<guarded-button class="btn btn-danger btn-sm"
|
||||
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 :to="locationEditRoute(location)"
|
||||
class="btn btn-primary btn-sm" title="Edit">
|
||||
<b-icon-pencil-square></b-icon-pencil-square>
|
||||
</router-link>
|
||||
<router-link v-if="shortIdLink(location)" :to="shortIdLink(location)"
|
||||
class="btn btn-secondary btn-sm">
|
||||
<b-icon-link></b-icon-link>
|
||||
</router-link>
|
||||
<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>
|
||||
</router-link>
|
||||
</div>
|
||||
|
|
@ -124,12 +167,14 @@
|
|||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import * as BIcons from "bootstrap-icons-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";
|
||||
|
||||
export default {
|
||||
name: "StorageLocation",
|
||||
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: {
|
||||
type: String,
|
||||
default: null
|
||||
|
|
@ -137,6 +182,8 @@ export default {
|
|||
},
|
||||
components: {
|
||||
BaseLayout,
|
||||
GuardedButton,
|
||||
TreeView,
|
||||
...BIcons
|
||||
},
|
||||
computed: {
|
||||
|
|
@ -163,17 +210,31 @@ export default {
|
|||
return this.owner ? decodeHandleFromUrl(this.owner) : this.user
|
||||
},
|
||||
set(value) {
|
||||
const path = value === this.user ? '/storage-location' : `/storage-location/${encodeHandleForUrl(value)}`
|
||||
this.$router.replace(path)
|
||||
const params = value === this.user ? {} : {owner: encodeHandleForUrl(value)}
|
||||
this.$router.replace({name: 'locations', params})
|
||||
}
|
||||
},
|
||||
locations() {
|
||||
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() {
|
||||
return this.selectedOwner === this.user
|
||||
? '/storage-locations/new'
|
||||
: `/storage-locations/new/${encodeHandleForUrl(this.selectedOwner)}`
|
||||
const params = this.selectedOwner === this.user ? {} : {group: encodeHandleForUrl(this.selectedOwner)}
|
||||
return {name: 'locations-new', params}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -190,7 +251,10 @@ export default {
|
|||
})
|
||||
},
|
||||
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) {
|
||||
if (location.owner_group) {
|
||||
|
|
@ -208,7 +272,7 @@ export default {
|
|||
// printLinkFor. See docs/implementation.md#print-link-shape.
|
||||
printLinkFor(location) {
|
||||
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: {
|
||||
|
|
@ -230,11 +294,41 @@ export default {
|
|||
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%;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.unguarded.btn-danger,
|
||||
.unguarded.btn-danger:hover,
|
||||
.unguarded.btn-danger:focus {
|
||||
background-color: orange;
|
||||
border-color: orange;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -34,17 +34,18 @@
|
|||
</div>
|
||||
</div>
|
||||
<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>
|
||||
Edit
|
||||
</button>
|
||||
<button type="submit" class="btn btn-danger"
|
||||
@click="deleteStorageLocation(location).then(() => $router.push(ownerLocationOverviewRoute(location)))">
|
||||
<guarded-button class="btn btn-danger"
|
||||
:confirm-message='`Are you sure you want to delete "${location.name}"? This cannot be undone.`'
|
||||
@confirm="tryDeleteLocation">
|
||||
<b-icon-trash></b-icon-trash>
|
||||
Delete
|
||||
</button>
|
||||
</guarded-button>
|
||||
<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>
|
||||
Print label
|
||||
</button>
|
||||
|
|
@ -58,6 +59,7 @@
|
|||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
import {mapActions, mapGetters, mapState} from "vuex";
|
||||
import {decodeHandleFromUrl, ownerLocationOverviewRoute} from "@/router";
|
||||
|
||||
|
|
@ -65,6 +67,7 @@ export default {
|
|||
name: "StorageLocationDetail",
|
||||
components: {
|
||||
BaseLayout,
|
||||
GuardedButton,
|
||||
...BIcons
|
||||
},
|
||||
props: {
|
||||
|
|
@ -103,6 +106,9 @@ export default {
|
|||
...mapActions(["fetchStorageLocationByHandle", "deleteStorageLocation", "fetchGroupMemberships"]),
|
||||
async loadLocation() {
|
||||
this.location = await this.fetchStorageLocationByHandle({handle: this.decodedHandle, id: this.id}) || {}
|
||||
},
|
||||
tryDeleteLocation() {
|
||||
this.deleteStorageLocation(this.location).then(() => this.$router.push(ownerLocationOverviewRoute(this.location)))
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export default {
|
|||
...BIcons
|
||||
},
|
||||
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: {
|
||||
type: String,
|
||||
default: null
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<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 class="breadcrumb-item active" aria-current="page">{{ workflowDefinition?.title || workflowInstance?.title || 'Loading...' }}</li>
|
||||
</ol>
|
||||
|
|
@ -19,10 +19,12 @@
|
|||
<b-icon-arrow-left class="me-1"></b-icon-arrow-left>
|
||||
Back
|
||||
</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>
|
||||
Abort
|
||||
</button>
|
||||
</guarded-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -199,6 +201,7 @@
|
|||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
import { mapState, mapActions } from 'vuex';
|
||||
import { getWorkflow, getWorkflowComponent } from '@/workflows.js';
|
||||
|
||||
|
|
@ -206,7 +209,8 @@ export default {
|
|||
name: 'WorkflowDetail',
|
||||
components: {
|
||||
...BIcons,
|
||||
BaseLayout
|
||||
BaseLayout,
|
||||
GuardedButton
|
||||
},
|
||||
props: {
|
||||
id: {
|
||||
|
|
|
|||
|
|
@ -54,11 +54,12 @@
|
|||
:disabled="loading">
|
||||
<b-icon-eye></b-icon-eye>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@click="abortWorkflowInstance(workflow)"
|
||||
<guarded-button class="btn btn-sm btn-outline-danger"
|
||||
:confirm-message='`Are you sure you want to abort "${getWorkflowDisplayName(workflow)}"?`'
|
||||
@confirm="abortWorkflowInstance(workflow)"
|
||||
:disabled="loading">
|
||||
<b-icon-x-circle></b-icon-x-circle>
|
||||
</button>
|
||||
</guarded-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
|
@ -132,6 +133,7 @@
|
|||
<script>
|
||||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import BaseLayout from "@/components/BaseLayout.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
import {mapState, mapActions} from 'vuex';
|
||||
import {getAllWorkflows, getWorkflow, buildWorkflowApiPayload} from '@/workflows.js';
|
||||
|
||||
|
|
@ -139,7 +141,8 @@ export default {
|
|||
name: 'Workflows',
|
||||
components: {
|
||||
...BIcons,
|
||||
BaseLayout
|
||||
BaseLayout,
|
||||
GuardedButton
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -264,19 +267,17 @@ export default {
|
|||
},
|
||||
async abortWorkflowInstance(workflow) {
|
||||
const displayName = this.getWorkflowDisplayName(workflow);
|
||||
if (confirm(`Are you sure you want to abort "${displayName}"?`)) {
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
await this.deleteWorkflow(workflow.id);
|
||||
await this.loadActiveWorkflows();
|
||||
console.log('Workflow aborted successfully:', displayName);
|
||||
} catch (error) {
|
||||
console.error('Error aborting workflow:', error);
|
||||
this.error = `Failed to abort ${displayName}`;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
try {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
await this.deleteWorkflow(workflow.id);
|
||||
await this.loadActiveWorkflows();
|
||||
console.log('Workflow aborted successfully:', displayName);
|
||||
} catch (error) {
|
||||
console.error('Error aborting workflow:', error);
|
||||
this.error = `Failed to abort ${displayName}`;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,9 +32,11 @@
|
|||
<fs-file-source @input="uploadPicture">
|
||||
<span class="btn btn-primary"><i class="fas fa-upload"></i> Upload</span>
|
||||
</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
|
||||
</button>
|
||||
</guarded-button>
|
||||
</div>
|
||||
<small>For best results, use an image at least 128px by
|
||||
128px in .jpg format</small>
|
||||
|
|
@ -107,10 +109,11 @@
|
|||
import {mapActions, mapState} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
|
||||
export default {
|
||||
name: 'Account',
|
||||
components: {AuthenticatedImage, FsFileSource},
|
||||
components: {AuthenticatedImage, FsFileSource, GuardedButton},
|
||||
computed: {
|
||||
...mapState(['user', 'user_profile']),
|
||||
profilePictureSrc() {
|
||||
|
|
|
|||
|
|
@ -37,9 +37,11 @@
|
|||
<div class="mb-3">
|
||||
<label class="form-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
|
||||
</button>
|
||||
</guarded-button>
|
||||
<br>
|
||||
<small>Delete all your data including photos, videos,
|
||||
comments, profile information and more</small>
|
||||
|
|
@ -78,12 +80,15 @@
|
|||
<script>
|
||||
import {mapActions, mapGetters} from 'vuex';
|
||||
import router from "@/router";
|
||||
import GuardedButton from "@/components/GuardedButton.vue";
|
||||
|
||||
//import VueQrcode from '@chenfengyuan/vue-qrcode';
|
||||
|
||||
export default {
|
||||
name: 'Data',
|
||||
components: {},
|
||||
components: {
|
||||
GuardedButton
|
||||
},
|
||||
data: () => ({
|
||||
selectedFile: null,
|
||||
localUserIdentityRecord: null,
|
||||
|
|
@ -106,10 +111,6 @@ export default {
|
|||
async deleteData() {
|
||||
this.deleteError = 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 {
|
||||
const servers = await this.getHomeServers();
|
||||
const response = await servers.delete(this.signAuth, '/api/v1/account_data/');
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import StorageOptimizationWorkflow from '@/components/workflow/workflows/Storage
|
|||
import MaintenanceScheduleWorkflow from '@/components/workflow/workflows/MaintenanceScheduleWorkflow.vue';
|
||||
import ExpiryCheckWorkflow from '@/components/workflow/workflows/ExpiryCheckWorkflow.vue';
|
||||
import BackupRestoreWorkflow from '@/components/workflow/workflows/BackupRestoreWorkflow.vue';
|
||||
//import BulkLabelPrintWorkflow from '@/components/workflow/workflows/BulkLabelPrintWorkflow.vue';
|
||||
|
||||
const workflows = [
|
||||
{...FotoFirstBulkImportWorkflow.meta, component: FotoFirstBulkImportWorkflow},
|
||||
|
|
@ -15,6 +16,7 @@ const workflows = [
|
|||
{...MaintenanceScheduleWorkflow.meta, component: MaintenanceScheduleWorkflow},
|
||||
{...ExpiryCheckWorkflow.meta, component: ExpiryCheckWorkflow},
|
||||
{...BackupRestoreWorkflow.meta, component: BackupRestoreWorkflow},
|
||||
// {...BulkLabelPrintWorkflow.meta, component: BulkLabelPrintWorkflow},
|
||||
];
|
||||
|
||||
export function getAllWorkflows() {
|
||||
|
|
|
|||
BIN
testdata/avatars/avatar-2.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
testdata/avatars/avatar-3.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
testdata/avatars/avatar-4.png
vendored
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
testdata/avatars/avatar-5.png
vendored
Normal file
|
After Width: | Height: | Size: 1 MiB |
BIN
testdata/avatars/avatar-6.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
testdata/avatars/avatar.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
151
testdata/generate_testdata.py
vendored
|
|
@ -8,6 +8,7 @@ who are friends with each other.
|
|||
import csv
|
||||
import io
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import random
|
||||
import zipfile
|
||||
|
|
@ -15,6 +16,10 @@ from hashlib import sha256
|
|||
from nacl.signing import SigningKey
|
||||
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
|
||||
# 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
|
||||
|
||||
|
||||
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():
|
||||
"""Generate a signing key pair and return (hex_private_key, hex_public_key)."""
|
||||
signing_key = SigningKey.generate()
|
||||
|
|
@ -258,6 +288,18 @@ def generate_keypair():
|
|||
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):
|
||||
"""Generate a realistic locations.csv with varied storage locations and parent-child relationships."""
|
||||
locations = random.sample(LOCATIONS_DATABASE, min(count, len(LOCATIONS_DATABASE)))
|
||||
|
|
@ -341,7 +383,7 @@ def _quote_value_if_needed(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.
|
||||
|
||||
Args:
|
||||
|
|
@ -349,6 +391,12 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
|
|||
num_locations: Number of locations
|
||||
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
|
||||
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
|
||||
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)))
|
||||
|
||||
rows = []
|
||||
files = {}
|
||||
for i, item_tuple in enumerate(selected_items, 1):
|
||||
# Handle both old format (6 elements) and new format (7 elements with properties)
|
||||
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(
|
||||
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({
|
||||
'id': str(i),
|
||||
'name': name,
|
||||
|
|
@ -393,7 +452,7 @@ def generate_inventory_csv(count, num_locations, location_id_map, item_range=Non
|
|||
'storage_location': location,
|
||||
'tags': ', '.join(tags_for_item),
|
||||
'properties': properties_str,
|
||||
'files': '',
|
||||
'files': files_str,
|
||||
'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.writeheader()
|
||||
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():
|
||||
|
|
@ -422,7 +499,8 @@ def generate_sample_files():
|
|||
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
|
|
@ -434,6 +512,9 @@ def generate_export_zip(username, domain, friend_username, friend_domain, friend
|
|||
num_locations: Number of locations 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
|
||||
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_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)
|
||||
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)
|
||||
|
||||
for arcname, content in item_files.items():
|
||||
zip_file.writestr(arcname, content)
|
||||
|
||||
sample_files = generate_sample_files()
|
||||
for arcname, content in sample_files.items():
|
||||
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()
|
||||
|
||||
|
||||
|
|
@ -465,20 +558,30 @@ def main():
|
|||
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("\nGenerating keypairs...")
|
||||
user_a_private, user_a_public = generate_keypair()
|
||||
user_b_private, user_b_public = generate_keypair()
|
||||
print("\nLoading/generating keypairs...")
|
||||
user_a_key_path = os.path.join(script_dir, 'user-a.key')
|
||||
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" user-b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
|
||||
print(f" test_a@a.localhost: {user_a_private[:16]}...{user_a_private[-16:]}")
|
||||
print(f" test_b@b.localhost: {user_b_private[:16]}...{user_b_private[-16:]}")
|
||||
|
||||
print("\nWriting key files...")
|
||||
with open(os.path.join(script_dir, 'user-a.key'), 'w') as f:
|
||||
f.write(user_a_private)
|
||||
with open(os.path.join(script_dir, 'user-b.key'), 'w') as f:
|
||||
f.write(user_b_private)
|
||||
print(" ✓ user-a.key")
|
||||
print(" ✓ user-b.key")
|
||||
if user_a_existed:
|
||||
print(" ✓ user-a.key (reused existing)")
|
||||
else:
|
||||
with open(user_a_key_path, 'w') as f:
|
||||
f.write(user_a_private)
|
||||
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...")
|
||||
# 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 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', 'a.localhost', 'user-b', 'b.localhost', user_b_public,
|
||||
num_locations=12, num_items=175, item_range=user_a_item_range
|
||||
'test_a', 'a.localhost', 'test_b', 'b.localhost', user_b_public,
|
||||
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', 'b.localhost', 'user-a', 'a.localhost', user_a_public,
|
||||
num_locations=8, num_items=140, item_range=user_b_item_range
|
||||
'test_b', 'b.localhost', 'test_a', 'a.localhost', user_a_public,
|
||||
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:
|
||||
|
|
|
|||
BIN
testdata/imgs/1-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
testdata/imgs/1-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
testdata/imgs/1.png
vendored
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
testdata/imgs/10-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
testdata/imgs/10-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 361 KiB |
BIN
testdata/imgs/10.png
vendored
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
testdata/imgs/2-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
testdata/imgs/2-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 115 KiB |
BIN
testdata/imgs/2.png
vendored
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
testdata/imgs/3-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
testdata/imgs/3-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
testdata/imgs/3.png
vendored
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
testdata/imgs/4-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
testdata/imgs/4-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
testdata/imgs/4.png
vendored
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
testdata/imgs/5-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
testdata/imgs/5-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
testdata/imgs/5.png
vendored
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
testdata/imgs/6-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
testdata/imgs/6-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
testdata/imgs/6.png
vendored
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
testdata/imgs/7-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
testdata/imgs/7-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
testdata/imgs/7.png
vendored
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
testdata/imgs/8-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
testdata/imgs/8-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 164 KiB |
BIN
testdata/imgs/8.png
vendored
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
testdata/imgs/9-600x800.png
vendored
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
testdata/imgs/9-800x600.png
vendored
Normal file
|
After Width: | Height: | Size: 522 KiB |
BIN
testdata/imgs/9.png
vendored
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
testdata/imgs/unsplash-1.jpg
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
testdata/imgs/unsplash-2.jpg
vendored
Normal file
|
After Width: | Height: | Size: 888 KiB |
BIN
testdata/imgs/unsplash-3.jpg
vendored
Normal file
|
After Width: | Height: | Size: 970 KiB |
2
testdata/user-a.key
vendored
|
|
@ -1 +1 @@
|
|||
795e884532881938a1b03e37731443f4d1974327dafd5d508e0cb00cb0b161b6
|
||||
53da21dc8bf556e45b906bc9f1cd22688b29efb2e575c8fb8353199a93553d4b
|
||||
BIN
testdata/user-a.zip
vendored
2
testdata/user-b.key
vendored
|
|
@ -1 +1 @@
|
|||
d011fedc3ba733baef6aa7550bbee47db66c2ad3dbfb0f6dcc14c984866df5eb
|
||||
8ed48f39bd054d88124d7b2ae4529a3da692b131194bd26da2bf5ede612baea9
|
||||