This commit is contained in:
j3d1 2026-07-23 04:41:04 +02:00
parent 7c91661be2
commit 796fef81be
37 changed files with 3404 additions and 108 deletions

View file

@ -109,16 +109,21 @@ export default {
},
computed: {
...mapGetters(["inventory_items", "loaded_items"]),
...mapState(["user"]),
...mapState(["user", "storage_locations"]),
},
methods: {
...mapActions(["fetchInventoryItems", "deleteInventoryItem"]),
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations"]),
only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/"));
},
locationPath(item) {
const loc = this.storage_locations.find(loc => loc.id === item.storage_location)
return loc ? loc.path : null
},
},
async mounted() {
await this.fetchInventoryItems()
await this.fetchStorageLocations()
}
}
</script>

View file

@ -57,7 +57,7 @@
<script>
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapGetters} from "vuex";
import {mapActions, mapGetters, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
export default {
@ -75,15 +75,20 @@ export default {
},
computed: {
...mapGetters(["loaded_items"]),
...mapState(["storage_locations"]),
item() {
return this.loaded_items.find(item => item.id === parseInt(this.id)) || {}
},
location() {
return this.storage_locations.find(loc => loc.id === this.item.storage_location) || null
}
},
methods: {
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchFilesByItem"]),
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchFilesByItem", "fetchStorageLocations"]),
},
async mounted() {
await this.fetchInventoryItems()
await this.fetchStorageLocations()
}
}
</script>

View file

@ -43,7 +43,7 @@
<label for="storage_location" class="form-label">Storage Location</label>
<select class="form-select" id="storage_location" name="storage_location"
v-model="item.storage_location">
<option value="">No storage location</option>
<option :value="null">No storage location</option>
<option v-for="location in storage_locations" :value="location.id"
:key="location.id">
{{ location.path }}
@ -103,12 +103,13 @@ export default {
owned_quantity: 0,
image: "",
files: [],
storage_location: null,
...this.inventory_items.find(item => item.id === parseInt(this.id))
}
}
},
methods: {
...mapActions(["fetchInventoryItems", "updateInventoryItem", "fetchInfo"]),
...mapActions(["fetchInventoryItems", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
changeFiles(files) {
this.inventory_items.find(item => item.id === parseInt(this.id)).files = files
},
@ -116,6 +117,7 @@ export default {
async mounted() {
await this.fetchInfo();
await this.fetchInventoryItems();
await this.fetchStorageLocations();
}
}
</script>

View file

@ -42,8 +42,8 @@
<div class="mb-3">
<label for="storage_location" class="form-label">Storage Location</label>
<select class="form-select" id="storage_location" name="storage_location"
v-model="item.storage_location_id">
<option value="">No storage location</option>
v-model="item.storage_location">
<option :value="null">No storage location</option>
<option v-for="location in storage_locations" :value="location.id"
:key="location.id">
{{ location.path }}
@ -95,18 +95,20 @@ export default {
image: "",
tags: [],
properties: [],
files: []
files: [],
storage_location: null
}
}
},
methods: {
...mapActions(['createInventoryItem', 'fetchInfo'])
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations'])
},
computed: {
...mapState(["availability_policies", "storage_locations"]),
},
async mounted() {
await this.fetchInfo();
await this.fetchStorageLocations();
}
}
</script>

View file

@ -12,14 +12,16 @@
<h5 class="card-title mb-0">Profile Details</h5>
</div>
<div class="card-body text-center">
<img src="/assets/img/avatars/avatar.png"
alt="Christina Mason" class="img-fluid rounded-circle mb-2" width="128"
height="128"/>
<authenticated-image :src="profilePictureSrc" :owner="userHandle"
:alt="profile.username || userHandle"
:title="profile.username || userHandle"
img-class="img-fluid rounded-circle mb-2"
:width="128" :height="128" fit="cover"/>
<h5 class="card-title mb-0">
{{ user.username }}
{{ profile.username }}
</h5>
<div class="text-muted mb-2">
{{ user.email }}
{{ profile.email }}
</div>
<div>
@ -28,12 +30,12 @@
data-feather="message-square"></span> Message</a>
</div>
</div>
<!--{% if user.bio %}-->
<!--{% if profile.bio %}-->
<hr class="my-0"/>
<div class="card-body">
<h5 class="h6 card-title">Bio</h5>
<div class="text-muted mb-2">
{{ user.bio }}
{{ profile.bio }}
</div>
</div>
<!--{% endif %}-->
@ -54,9 +56,9 @@
<div class="card-body">
<h5 class="h6 card-title">About</h5>
<ul class="list-unstyled mb-0">
<!--{% if user.location %}-->
<!--{% if profile.location %}-->
<li class="mb-1"><span data-feather="home" class="feather-sm mr-1"></span> Lives
in <a href="#">{{ user.location }}</a></li>
in <a href="#">{{ profile.location }}</a></li>
<!--{% endif %}-->
<li class="mb-1"><span data-feather="briefcase" class="feather-sm mr-1"></span>
@ -230,24 +232,33 @@
<script>
import BaseLayout from "@/components/BaseLayout.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import {mapActions, mapState} from "vuex";
export default {
name: 'Profile',
data() {
return {
user: {
name: 'John Doe',
avatar: '/static/assets/img/avatars/avatar.png',
cover: '/static/assets/img/photos/unsplash-1.jpg',
occupation: 'Frontend Developer',
company: 'Facebook Inc.',
email: 'foo@bar.com',
phone: '+12 345 678 001',
address: 'Boulevard of Broken Dreams, 1234',
}
components: {BaseLayout, AuthenticatedImage},
computed: {
...mapState(['user', 'user_profile']),
userHandle() {
return this.user;
},
profile() {
return this.user_profile || {
username: this.user,
email: ''
};
},
profilePictureSrc() {
return this.user_profile?.profile_picture?.name || null;
}
},
components: {BaseLayout},
methods: {
...mapActions(['fetchUserProfile'])
},
mounted() {
this.fetchUserProfile();
}
}
</script>

View file

@ -25,6 +25,9 @@
<router-link class="list-group-item list-group-item-action"
:to="{name: 'notifications'}" active-class="active">Web notifications
</router-link>
<router-link class="list-group-item list-group-item-action" :to="{name: 'preferences'}"
active-class="active">Preferences
</router-link>
<router-link class="list-group-item list-group-item-action" :to="{name: 'data'}"
active-class="active">Your data
</router-link>

View file

@ -202,6 +202,7 @@ import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import { mapState, mapActions } from 'vuex';
import { workflowRegistry } from '@/workflows.js';
import { getStepComponent, hasStepComponent } from '@/components/workflow/ComponentRegistry.js';
export default {
name: 'WorkflowDetail',
@ -256,15 +257,11 @@ export default {
},
stepComponent() {
// Return step-specific component if it exists
// This allows for custom UI per workflow type and step
// Return step-specific component if it exists using the component registry
const workflowType = this.workflowInstance?.workflow_type;
if (workflowType) {
// Try to load a step-specific component
// e.g., FotoFirstImportStep1, BulkImportStep2, etc.
const componentName = `${workflowType}Step${this.currentStep}`;
// This would require registering step components
return null; // For now, use default content
console.log('Determining step component for workflow type:', this.workflowInstance, 'and step:', this.currentStep);
if (workflowType && this.currentStep) {
return getStepComponent(workflowType, this.currentStep);
}
return null;
},
@ -282,6 +279,19 @@ export default {
canNavigatePrev() {
// Check if we can navigate to the previous step
return this.currentStepIndex > 0;
},
hasCustomComponent() {
// Check if the current step has a custom component defined
return this.stepComponent !== null;
},
componentStatusClass() {
return this.hasCustomComponent ? 'bg-light border-success' : 'bg-light border-muted';
},
componentStatusText() {
return this.hasCustomComponent ? 'Using custom component' : 'Using default view';
}
},
@ -453,6 +463,12 @@ export default {
formatDateTime(dateString) {
if (!dateString) return 'N/A';
return new Date(dateString).toLocaleString();
},
hasStepComponent(stepNumber) {
// Check if a specific step has a custom component available
const workflowType = this.workflowInstance?.workflow_type;
return workflowType ? hasStepComponent(workflowType, stepNumber) : false;
}
}
}
@ -498,4 +514,3 @@ export default {
min-width: 120px;
}
</style>

View file

@ -23,13 +23,18 @@
</div>
<div class="col-md-4">
<div class="text-center">
<img alt="Charles Hall"
src="/assets/img/avatars/avatar.png"
class="rounded-circle img-responsive mt-2" width="128"
height="128"/>
<authenticated-image :src="profilePictureSrc" :owner="user"
:refresh-key="profilePictureRefreshKey"
:alt="user" :title="user"
img-class="rounded-circle img-responsive mt-2"
:width="128" :height="128" fit="cover"/>
<div class="mt-2">
<span class="btn btn-primary"><i
class="fas fa-upload"></i> Upload</span>
<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">
Remove
</button>
</div>
<small>For best results, use an image at least 128px by
128px in .jpg format</small>
@ -99,10 +104,38 @@
</template>
<script>
import {mapActions, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import FsFileSource from "@/components/inputs/FsFileSource.vue";
export default {
name: 'Account',
components: {}
components: {AuthenticatedImage, FsFileSource},
computed: {
...mapState(['user', 'user_profile']),
profilePictureSrc() {
return this.user_profile?.profile_picture?.name || null;
},
profilePictureRefreshKey() {
return this.user_profile?.profile_picture?.id || 'none';
}
},
methods: {
...mapActions(['fetchUserProfile', 'updateUserProfilePicture']),
async uploadPicture(files) {
const image = files.find(file => file.mime_type?.startsWith('image/'))
if (!image) {
return;
}
await this.updateUserProfilePicture({file: image});
},
async removePicture() {
await this.updateUserProfilePicture({file: null});
}
},
mounted() {
this.fetchUserProfile();
}
}
</script>

View file

@ -0,0 +1,234 @@
<template>
<div class="card">
<div class="card-body">
<h5 class="card-title">Preferences</h5>
<div v-if="!preferencesLoaded" class="text-center py-4 text-muted">
Loading preferences...
</div>
<div v-else>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th style="width: 26%">Preference</th>
<th style="width: 17%">Device</th>
<th style="width: 17%">Account</th>
<th style="width: 17%">Default</th>
<th style="width: 23%">Effective</th>
</tr>
</thead>
<tbody>
<tr v-for="schema in schemas" :key="schema.key">
<td>
<div class="font-weight-bold">{{ schema.label }}</div>
<small v-if="schema.description" class="text-muted">{{ schema.description }}</small>
</td>
<td>
<PreferenceInput
:id="`device-${schema.key}`"
:schema="schema"
:value="effectiveDeviceValues[schema.key]"
@input="updateDevicePreference(schema.key, $event)"
@clear="clearDevicePreference(schema.key)"
/>
</td>
<td>
<PreferenceInput
:id="`account-${schema.key}`"
:schema="schema"
:value="effectiveAccountValues[schema.key]"
@input="updateAccountPreference(schema.key, $event)"
@clear="clearAccountPreference(schema.key)"
/>
</td>
<td>
<PreferenceInput
:id="`default-${schema.key}`"
:schema="schema"
:value="schema.default"
:disabled="true"
/>
</td>
<td>
<div class="font-weight-bold">
{{ formatEffectiveValue(schema, getPreference(schema.key)) }}
<span
v-if="hasChanges && formatEffectiveValue(schema, getEffectiveValueWithPendingChanges(schema.key)) !== formatEffectiveValue(schema, getPreference(schema.key))"
class="text-warning ml-1"
>
-> {{ formatEffectiveValue(schema, getEffectiveValueWithPendingChanges(schema.key)) }}
</span>
</div>
<small class="text-muted">Source: {{ getPreferenceSource(schema.key) }}</small>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex pt-3 border-top mt-3">
<button @click="savePreferences" :disabled="!hasChanges || saving" class="btn btn-primary mr-2">
{{ saving ? 'Saving...' : 'Save changes' }}
</button>
<button @click="reloadPreferences" :disabled="!hasChanges || saving" class="btn btn-secondary">
Discard changes
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapGetters, mapState} from 'vuex'
import PreferenceInput from '@/components/inputs/PreferenceInput.vue'
export default {
name: 'Preferences',
components: {PreferenceInput},
data() {
return {
saving: false,
accountChanges: {},
deviceChanges: {},
}
},
computed: {
...mapState(['preferencesLoaded', 'preferenceDefinitions', 'devicePreferences', 'accountPreferences']),
...mapGetters(['getPreference']),
schemas() {
return this.preferenceDefinitions.map((pref) => ({
...pref,
label: pref.label || this.formatLabel(pref.key),
description: pref.description || '',
}))
},
hasChanges() {
return Object.keys(this.accountChanges).length > 0 || Object.keys(this.deviceChanges).length > 0
},
effectiveDeviceValues() {
return {...this.devicePreferences, ...this.deviceChanges}
},
effectiveAccountValues() {
return {...this.accountPreferences, ...this.accountChanges}
},
},
methods: {
...mapActions([
'loadUserPreferences',
'setAccountPreference',
'resetAccountPreference',
'setDevicePreference',
'resetDevicePreference',
]),
formatLabel(key) {
const parts = key.split('.')
const name = parts[parts.length - 1] || ''
return name
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
},
getEffectiveValueWithPendingChanges(key) {
if (Object.prototype.hasOwnProperty.call(this.deviceChanges, key)) {
if (this.deviceChanges[key] !== null) {
return this.deviceChanges[key]
}
if (Object.prototype.hasOwnProperty.call(this.accountChanges, key) && this.accountChanges[key] !== null) {
return this.accountChanges[key]
}
if (Object.prototype.hasOwnProperty.call(this.accountPreferences, key) && this.accountPreferences[key] !== null) {
return this.accountPreferences[key]
}
} else if (Object.prototype.hasOwnProperty.call(this.accountChanges, key)) {
if (this.accountChanges[key] !== null) {
return this.accountChanges[key]
}
} else {
return this.getPreference(key)
}
const definition = this.preferenceDefinitions.find((p) => p.key === key)
return definition ? definition.default : null
},
getPreferenceSource(key) {
if (Object.prototype.hasOwnProperty.call(this.deviceChanges, key)) {
return this.deviceChanges[key] !== null
? 'Device'
: Object.prototype.hasOwnProperty.call(this.accountPreferences, key) && this.accountPreferences[key] !== null
? 'Account'
: 'Default'
}
if (Object.prototype.hasOwnProperty.call(this.accountChanges, key)) {
return this.accountChanges[key] !== null ? 'Account' : 'Default'
}
if (Object.prototype.hasOwnProperty.call(this.devicePreferences, key) && this.devicePreferences[key] !== null) {
return 'Device'
}
if (Object.prototype.hasOwnProperty.call(this.accountPreferences, key) && this.accountPreferences[key] !== null) {
return 'Account'
}
return 'Default'
},
updateDevicePreference(key, value) {
this.deviceChanges = {...this.deviceChanges, [key]: value}
},
updateAccountPreference(key, value) {
this.accountChanges = {...this.accountChanges, [key]: value}
},
clearDevicePreference(key) {
this.deviceChanges = {...this.deviceChanges, [key]: null}
},
clearAccountPreference(key) {
this.accountChanges = {...this.accountChanges, [key]: null}
},
reloadPreferences() {
this.accountChanges = {}
this.deviceChanges = {}
},
formatEffectiveValue(schema, value) {
if (value === null || value === undefined) {
return '(not set)'
}
if (schema.type === 'boolean') {
return value ? 'Yes' : 'No'
}
if (schema.type === 'json') {
return typeof value === 'object' ? JSON.stringify(value) : String(value)
}
return String(value)
},
async savePreferences() {
this.saving = true
try {
for (const [key, value] of Object.entries(this.accountChanges)) {
if (value === null) {
await this.resetAccountPreference(key)
} else {
await this.setAccountPreference({key, value})
}
}
for (const [key, value] of Object.entries(this.deviceChanges)) {
if (value === null) {
await this.resetDevicePreference(key)
} else {
await this.setDevicePreference({key, value})
}
}
this.accountChanges = {}
this.deviceChanges = {}
} catch (error) {
console.error('Error saving preferences:', error)
} finally {
this.saving = false
}
},
},
mounted() {
this.loadUserPreferences()
},
}
</script>