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

@ -11,6 +11,8 @@ from rest_framework.response import Response
from authentication.models import ToolshedUser
from authentication.signature_auth import SignatureAuthenticationLocal
from files.models import File
from files.serializers import FileSerializer
from hostadmin.models import Domain
router = routers.SimpleRouter()
@ -53,15 +55,43 @@ class UserViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsAdminUser]
@api_view(['GET'])
@api_view(['GET', 'PATCH'])
@permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal])
def getUserInfo(request):
user = request.user
if request.method == 'PATCH':
old_file = user.profile_picture
if 'profile_picture' in request.data:
profile_picture = request.data.get('profile_picture')
if profile_picture is None:
user.profile_picture = None
elif type(profile_picture) == dict:
serializer = FileSerializer(data=profile_picture)
if not serializer.is_valid():
return Response(serializer.errors, status=400)
user.profile_picture = serializer.save()
else:
return Response({'profile_picture': 'Must be null or an object with data and mime_type.'}, status=400)
elif 'profile_picture_id' in request.data:
profile_picture_id = request.data.get('profile_picture_id')
if profile_picture_id is None:
user.profile_picture = None
else:
try:
user.profile_picture = File.objects.get(id=profile_picture_id)
except File.DoesNotExist:
return Response({'profile_picture_id': 'File does not exist.'}, status=400)
user.save()
if old_file and old_file != user.profile_picture and old_file.connected_items.count() == 0 and old_file.profile_picture_users.count() == 0:
old_file.delete()
return Response({
'username': user.username,
'domain': user.domain,
'email': user.email
'email': user.email,
'profile_picture': FileSerializer(user.profile_picture).data if user.profile_picture else None,
})

View file

@ -0,0 +1,20 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('files', '0001_initial'),
('authentication', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='toolsheduser',
name='profile_picture',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='profile_picture_users', to='files.file'),
),
]

View file

@ -86,6 +86,8 @@ class ToolshedUser(AbstractUser):
domain = models.CharField(max_length=255, default='localhost')
private_key = models.CharField(max_length=255)
public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user')
profile_picture = models.ForeignKey('files.File', on_delete=models.SET_NULL, null=True, blank=True,
related_name='profile_picture_users')
objects = ToolshedUserManager()
class Meta:

View file

@ -1,4 +1,5 @@
import json
import base64
from django.test import Client, RequestFactory
from nacl.encoding import HexEncoder
@ -6,6 +7,7 @@ from nacl.signing import SigningKey
from authentication.models import ToolshedUser, KnownIdentity
from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase
from files.models import File
class AuthorizationTestCase(ToolshedTestCase):
@ -240,6 +242,7 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com')
self.assertEqual(reply.json()['email'], 'test1@abc.de')
self.assertIsNone(reply.json()['profile_picture'])
def test_user_info2(self):
target = "/auth/user/"
@ -249,6 +252,50 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com')
self.assertIsNone(reply.json()['profile_picture'])
def test_user_info_patch_profile_picture(self):
content = base64.b64encode(b'user-profile-image').decode('utf-8')
reply = self.client.patch('/auth/user/', self.f['local_user1'], {
'profile_picture': {
'data': content,
'mime_type': 'image/png'
}
})
self.assertEqual(reply.status_code, 200)
self.assertTrue(reply.json()['profile_picture'])
self.assertEqual(reply.json()['profile_picture']['mime_type'], 'image/png')
self.assertEqual(File.objects.count(), 1)
self.f['local_user1'].refresh_from_db()
self.assertIsNotNone(self.f['local_user1'].profile_picture)
def test_user_info_patch_profile_picture_clear(self):
encoded_content = base64.b64encode(b'user-profile-image').decode('utf-8')
test_file = File.objects.create(mime_type='image/png', data=encoded_content)
self.f['local_user1'].profile_picture = test_file
self.f['local_user1'].save()
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': None})
self.assertEqual(reply.status_code, 200)
self.assertIsNone(reply.json()['profile_picture'])
self.f['local_user1'].refresh_from_db()
self.assertIsNone(self.f['local_user1'].profile_picture)
self.assertFalse(File.objects.filter(id=test_file.id).exists())
def test_user_info_patch_profile_picture_invalid(self):
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture': 'invalid'})
self.assertEqual(reply.status_code, 400)
def test_user_info_patch_profile_picture_id(self):
encoded_content = base64.b64encode(b'user-profile-image-by-id').decode('utf-8')
test_file = File.objects.create(mime_type='image/jpeg', data=encoded_content)
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': test_file.id})
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['profile_picture']['id'], test_file.id)
def test_user_info_patch_profile_picture_id_not_found(self):
reply = self.client.patch('/auth/user/', self.f['local_user1'], {'profile_picture_id': 999999})
self.assertEqual(reply.status_code, 400)
def test_user_info_fail(self):
reply = self.anonymous_client.get('/auth/user/')

View file

@ -1,5 +1,7 @@
from django.http import HttpResponse
from django.urls import path
from django.db.models import Q
from django.conf import settings
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, authentication_classes
@ -7,7 +9,6 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from authentication.signature_auth import SignatureAuthentication
from backend import settings
from files.models import File
@ -17,7 +18,10 @@ from files.models import File
@authentication_classes([SignatureAuthentication])
def media_urls(request, hash_path):
try:
file = File.objects.filter(connected_items__owner__in=request.user.friends_or_self()).distinct().get(
file = File.objects.filter(
Q(connected_items__owner__in=request.user.friends_or_self()) |
Q(profile_picture_users__in=request.user.friends_or_self())
).distinct().get(
file=hash_path)
if settings.SERVE_X_ACCEL_REDIRECT:

View file

@ -1,7 +1,7 @@
from django.core.files.base import ContentFile
from django.core.files.storage import DefaultStorage
from django.db import IntegrityError, transaction
from django.test import Client
from django.test import Client, override_settings
from authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
from toolshed.tests import InventoryTestMixin
from nacl.hash import sha256
@ -165,3 +165,33 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
self.assertTrue('X-Accel-Redirect' not in reply.headers)
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
def test_profile_picture_url(self):
self.f['local_user1'].profile_picture = self.f['test_file3']
self.f['local_user1'].save()
reply = client.get(
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
@override_settings(SERVE_X_ACCEL_REDIRECT=True)
def test_profile_picture_url_friend(self):
self.f['local_user1'].profile_picture = self.f['test_file3']
self.f['local_user1'].save()
reply = client.get(
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
def test_profile_picture_url_not_friend(self):
self.f['local_user1'].profile_picture = self.f['test_file3']
self.f['local_user1'].save()
reply = client.get(
f"/media/{self.f['hash3'][:2]}/{self.f['hash3'][2:4]}/{self.f['hash3'][4:6]}/{self.f['hash3'][6:]}",
self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)

View file

@ -0,0 +1,19 @@
# Generated by Django 4.2.2 on 2026-07-23 02:02
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('toolshed', '0007_workflowinstance'),
]
operations = [
migrations.AlterField(
model_name='inventoryitem',
name='storage_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inventory_items', to='toolshed.storagelocation'),
),
]

View file

@ -90,7 +90,7 @@ class InventoryItem(SoftDeleteModel):
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
properties = models.ManyToManyField(Property, through='ItemProperty')
files = models.ManyToManyField(File, related_name='connected_items')
storage_location = models.ForeignKey('StorageLocation', on_delete=models.CASCADE, null=True, blank=True,
storage_location = models.ForeignKey('StorageLocation', on_delete=models.SET_NULL, null=True, blank=True,
related_name='inventory_items')
def clean(self):

View file

@ -1,6 +1,6 @@
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin
from toolshed.models import InventoryItem, Category
from toolshed.models import InventoryItem, Category, StorageLocation
from toolshed.tests import InventoryTestMixin, LocationTestMixin
client = SignatureAuthClient()
@ -69,3 +69,63 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
self.assertEqual(reply.json()[3]['description'], None)
self.assertEqual(reply.json()[3]['category'], 'cat1')
self.assertEqual(reply.json()[3]['path'], 'loc1/loc4')
def test_post_new_location(self):
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
'name': 'loc5',
'description': 'a new location',
})
self.assertEqual(reply.status_code, 201)
self.assertEqual(StorageLocation.objects.count(), 5)
location = StorageLocation.objects.get(name='loc5')
self.assertEqual(location.description, 'a new location')
self.assertEqual(location.owner, self.f['local_user1'])
self.assertEqual(location.parent, None)
self.assertEqual(reply.json()['path'], 'loc5')
def test_post_new_nested_location(self):
reply = client.post('/api/storage_locations/', self.f['local_user1'], {
'name': 'loc5',
'parent': self.f['loc3'].id,
})
self.assertEqual(reply.status_code, 201)
location = StorageLocation.objects.get(name='loc5')
self.assertEqual(location.parent, self.f['loc3'])
self.assertEqual(reply.json()['path'], 'loc1/loc3/loc5')
def test_patch_location(self):
reply = client.patch('/api/storage_locations/' + str(self.f['loc2'].id) + '/', self.f['local_user1'], {
'name': 'loc2-renamed',
'parent': self.f['loc1'].id,
})
self.assertEqual(reply.status_code, 200)
location = StorageLocation.objects.get(id=self.f['loc2'].id)
self.assertEqual(location.name, 'loc2-renamed')
self.assertEqual(location.parent, self.f['loc1'])
self.assertEqual(reply.json()['path'], 'loc1/loc2-renamed')
def test_delete_location(self):
reply = client.delete('/api/storage_locations/' + str(self.f['loc4'].id) + '/', self.f['local_user1'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(StorageLocation.objects.count(), 3)
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc4'].id).count(), 0)
def test_delete_location_with_items_sets_null(self):
item = InventoryItem.objects.create(
owner=self.f['local_user1'], name='located_item', storage_location=self.f['loc3'])
reply = client.delete('/api/storage_locations/' + str(self.f['loc3'].id) + '/', self.f['local_user1'])
self.assertEqual(reply.status_code, 204)
item.refresh_from_db()
self.assertIsNone(item.storage_location)
self.assertEqual(InventoryItem.objects.filter(id=item.id).count(), 1)
def test_locations_are_owner_scoped(self):
reply = client.get('/api/storage_locations/', self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_cannot_delete_other_users_location(self):
reply = client.delete('/api/storage_locations/' + str(self.f['loc1'].id) + '/', self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
self.assertEqual(StorageLocation.objects.filter(id=self.f['loc1'].id).count(), 1)

View file

@ -1,19 +1,3 @@
[
[
{
"speed": 1,
"position": 1
},
{
"speed": 1,
"position": 1
},
{
"speed": 1,
"position": 1
}
]
arr[1].speed
"a.localhost"
]

View file

@ -14,7 +14,7 @@ http {
}
upstream dns {
server dns:8053;
server toolshed-dns:8053;
}
server {
@ -106,17 +106,35 @@ http {
# DoH server
server {
listen 5353 ssl;
server_name localhost;
server_name localhost 127.0.0.3;
ssl_certificate /etc/nginx/nginx.crt;
ssl_certificate_key /etc/nginx/nginx.key;
location /dns-query {
proxy_pass http://dns;
# allow any origin
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS';
# Ensure CORS headers are present even when nginx generates 5xx responses.
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
add_header 'Access-Control-Expose-Headers' 'Content-Type' always;
error_page 500 502 503 504 = @doh_error;
location /dns-query {
if ($request_method = OPTIONS) {
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
add_header 'Access-Control-Max-Age' 86400 always;
add_header 'Content-Length' 0;
add_header 'Content-Type' 'text/plain; charset=utf-8';
return 204;
}
proxy_pass http://dns;
}
location @doh_error {
default_type text/plain;
return 502 'DoH upstream unavailable';
}
}
}

View file

@ -78,3 +78,7 @@ services:
- ./dev/zone.json:/dns/zone.json
expose:
- 8053
networks:
default:
aliases:
- toolshed-dns

View file

@ -97,6 +97,12 @@ Start the fullstack application:
docker-compose -f deploy/docker-compose.override.yml up --build
```
Run backend tests in Docker:
``` bash
docker compose -f deploy/docker-compose.override.yml run --rm backend-a bash -lc "python configure.py && python manage.py test"
```
This will start an instance of the frontend and wiki, a limited DoH (DNS over HTTPS) server and **two** instances of the backend.
The two backend instances are set up to use the domains `a.localhost` and `b.localhost`, the local DoH
server is used to direct the frontend to the correct backend instance.

View file

@ -1,15 +1,8 @@
<template>
<img :src="image_data" :title="owner + ':' + src"/>
<img :src="image_data" :title="title || (owner + ':' + src)" :alt="alt" :class="imgClass"
:width="width" :height="height" :style="{objectFit: fit}"/>
</template>
<style scoped>
img {
max-width: 190px;
height: 107px;
object-fit: contain;
}
</style>
<script>
import {mapActions, mapGetters} from "vuex";
@ -18,36 +11,102 @@ export default {
props: {
src: {
type: String,
required: true
required: false,
default: null
},
owner: {
type: String,
required: true
},
title: {
type: String,
default: ''
},
alt: {
type: String,
default: 'image'
},
imgClass: {
type: String,
default: ''
},
fallbackSrc: {
type: String,
default: '/assets/img/avatars/avatar.png'
},
width: {
type: [String, Number],
default: null
},
height: {
type: [String, Number],
default: null
},
fit: {
type: String,
default: 'contain'
},
refreshKey: {
type: [String, Number],
default: null
}
},
data() {
return {
image_data: "",
servers: []
servers: [],
lastRequestId: 0
}
},
computed: {
...mapGetters(["signAuth"])
},
methods: {
...mapActions(["getFriendServers"])
},
async mounted() {
...mapActions(["getFriendServers"]),
async loadImage() {
const requestId = ++this.lastRequestId;
if (!this.src) {
if (requestId === this.lastRequestId) {
this.image_data = this.fallbackSrc;
}
return;
}
try {
this.servers = await this.getFriendServers({username: this.owner});
const response = await this.servers.getRaw(this.signAuth, this.src);
const mime_type = response.headers.get("content-type");
if (requestId !== this.lastRequestId) {
return;
}
if (!response || !response.ok) {
this.image_data = this.fallbackSrc;
return;
}
const mime_type = response.headers.get("content-type") || 'image/png';
const base64 = btoa(new Uint8Array(await response.arrayBuffer())
.reduce((data, byte) => data + String.fromCharCode(byte), ""));
if (requestId === this.lastRequestId) {
this.image_data = "data:" + mime_type + ";base64," + base64;
} catch (e) {
console.log(e);
}
} catch (_e) {
if (requestId === this.lastRequestId) {
this.image_data = this.fallbackSrc;
}
}
}
},
watch: {
src() {
this.loadImage();
},
owner() {
this.loadImage();
},
refreshKey() {
this.loadImage();
}
},
mounted() {
this.loadImage();
}
}
</script>

View file

@ -6,8 +6,8 @@
</a>
<a class="nav-link dropdown-toggle d-none d-sm-inline-block" href="#" @click="toggleDropdown">
<img src="/assets/img/avatars/avatar.png" class="avatar img-fluid rounded mr-1"
:alt="username" :title="username">
<authenticated-image :src="profilePictureSrc" :owner="username" :alt="username" :title="username"
img-class="avatar img-fluid rounded mr-1" :width="36" :height="36" fit="cover"/>
</a>
@ -35,14 +35,17 @@
<script>
import * as BIcons from 'bootstrap-icons-vue';
import {mapMutations, mapState} from "vuex";
import {mapActions, mapMutations, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
export default {
name: "UserDropdown",
components: {
AuthenticatedImage,
...BIcons
},
methods: {
...mapActions(['fetchUserProfile']),
...mapMutations(['logout']),
toggleDropdown() {
closeAllDropdowns();
@ -50,10 +53,18 @@ export default {
}
},
computed: {
...mapState(['user']),
...mapState(['user', 'user_profile']),
username() {
return this.user;
},
profilePictureSrc() {
return this.user_profile?.profile_picture?.name || null;
}
},
mounted() {
if (!this.user_profile) {
this.fetchUserProfile();
}
}
}
</script>

View file

@ -1,9 +1,9 @@
<template>
<div class="d-inline-block">
<label for="files">
<label :for="inputId">
<slot></slot>
</label>
<input type="file" multiple id="files" @change="loadFiles" class="d-none">
<input ref="fileInput" type="file" multiple :id="inputId" @change="loadFiles" class="d-none">
</div>
</template>
@ -19,9 +19,17 @@ export default {
...BIcons
},
emits: ["input"],
data() {
return {
inputId: `files-${this._uid}`
};
},
methods: {
loadFiles() {
const files = document.getElementById("files").files;
const files = this.$refs.fileInput?.files;
if (!files || files.length === 0) {
return;
}
const jobs = [...files].map((file) => {
return new Promise((resolve, reject) => {
var reader = new FileReader();
@ -52,6 +60,10 @@ export default {
});
Promise.all(jobs).then((files) => {
this.$emit("input", files)
// Allow selecting the same file again to trigger change.
if (this.$refs.fileInput) {
this.$refs.fileInput.value = '';
}
})
}
},

View file

@ -0,0 +1,168 @@
<template>
<div class="input-group input-group-sm">
<div v-if="inputType === 'checkbox'" class="input-group-prepend">
<div :class="['input-group-text', 'bg-transparent', showBorder ? 'border-secondary' : 'border-0']">
<input
:id="id"
ref="checkboxInput"
type="checkbox"
autocomplete="off"
:checked="isChecked"
:disabled="disabled"
@change="handleCheckboxChange"
>
</div>
<input type="text" class="form-control" value="" disabled style="display: none">
</div>
<select
v-else-if="inputType === 'select'"
:id="id"
class="form-control form-control-sm"
:value="displayValue"
:disabled="disabled"
@change="handleSelectChange"
>
<option value="">{{ disabled ? '-' : '(not set)' }}</option>
<option v-for="option in schema.options || []" :key="option" :value="option">{{ option }}</option>
</select>
<input
v-else-if="inputType === 'number'"
:id="id"
type="number"
class="form-control form-control-sm"
:value="displayValue"
:disabled="disabled"
:min="schema.validation_rules ? schema.validation_rules.min : undefined"
:max="schema.validation_rules ? schema.validation_rules.max : undefined"
:placeholder="disabled ? '' : '(not set)'"
@input="handleNumberInput"
>
<input
v-else-if="inputType === 'text'"
:id="id"
type="text"
class="form-control form-control-sm"
:value="displayValue"
:disabled="disabled"
:placeholder="disabled ? '' : '(not set)'"
@input="handleTextInput"
>
<textarea
v-else-if="inputType === 'json'"
:id="id"
class="form-control form-control-sm"
rows="3"
style="font-family: monospace; font-size: 0.8rem"
:value="jsonDisplayValue"
:disabled="disabled"
:placeholder="disabled ? '' : '(not set)'"
@input="handleJsonInput"
></textarea>
<div v-if="showClear" class="input-group-append">
<button class="btn btn-outline-secondary" type="button" title="Clear preference" @click="handleClear">
x
</button>
</div>
</div>
</template>
<script>
export default {
name: 'PreferenceInput',
props: {
id: {type: String, required: true},
schema: {type: Object, required: true},
value: {default: null},
disabled: {type: Boolean, default: false},
},
emits: ['input', 'clear'],
computed: {
inputType() {
const typeMapping = {
boolean: 'checkbox',
integer: 'number',
float: 'number',
enum: 'select',
string: 'text',
json: 'json',
}
return typeMapping[this.schema.type] || 'text'
},
isSet() {
return this.value !== null && this.value !== undefined
},
isChecked() {
return this.isSet ? Boolean(this.value) : false
},
showBorder() {
return this.inputType === 'checkbox' && this.isSet && !this.disabled
},
showClear() {
return !this.disabled && this.isSet
},
displayValue() {
return this.value ?? ''
},
jsonDisplayValue() {
if (this.value === null || this.value === undefined) {
return ''
}
return typeof this.value === 'object' ? JSON.stringify(this.value, null, 2) : this.value
},
},
mounted() {
this.updateCheckboxIndeterminate()
},
updated() {
this.updateCheckboxIndeterminate()
},
methods: {
updateCheckboxIndeterminate() {
if (this.inputType !== 'checkbox') {
return
}
const input = this.$refs.checkboxInput
if (input) {
input.indeterminate = !this.isSet
}
},
handleCheckboxChange(e) {
this.$emit('input', e.target.checked)
},
handleSelectChange(e) {
const val = e.target.value
this.$emit('input', val === '' ? null : val)
},
handleNumberInput(e) {
const raw = e.target.value
const val = raw === '' ? null : parseFloat(raw)
this.$emit('input', Number.isNaN(val) ? null : val)
},
handleTextInput(e) {
const val = e.target.value
this.$emit('input', val === '' ? null : val)
},
handleJsonInput(e) {
const val = e.target.value.trim()
if (val === '') {
this.$emit('input', null)
return
}
try {
this.$emit('input', JSON.parse(val))
} catch {
this.$emit('input', val)
}
},
handleClear() {
this.$emit('clear')
},
},
}
</script>

View file

@ -0,0 +1,110 @@
/**
* Workflow Step Component Registry
*
* This module handles the dynamic loading and registration of workflow step components.
* It provides a centralized way to map workflow types and steps to their corresponding Vue components.
*/
// Import all step components
import FotoFirstStep1 from './steps/FotoFirstStep1.vue';
import FotoFirstStep2 from './steps/FotoFirstStep2.vue';
import FotoFirstStep3 from './steps/FotoFirstStep3.vue';
import FotoFirstStep4 from './steps/FotoFirstStep4.vue';
import BulkImportStep1 from './steps/BulkImportStep1.vue';
/**
* Component registry mapping workflow types and steps to components
* Format: 'workflowType-step' -> Component
*/
const componentRegistry = {
// Foto First Import Workflow components
'foto-first-bulk-import-1': FotoFirstStep1,
'foto-first-bulk-import-2': FotoFirstStep2,
'foto-first-bulk-import-3': FotoFirstStep3,
'foto-first-bulk-import-4': FotoFirstStep4,
// Bulk Item Import Workflow components
'import-items-1': BulkImportStep1,
// Additional steps can be added as needed
// 'import-items-2': BulkImportStep2,
// 'import-items-3': BulkImportStep3,
// ... etc
};
/**
* Get a step component for a given workflow type and step
* @param {string} workflowType - The workflow type identifier
* @param {string|number} step - The step identifier
* @returns {Object|null} Vue component or null if not found
*/
export function getStepComponent(workflowType, step) {
const componentKey = `${workflowType}-${step}`;
return componentRegistry[componentKey] || null;
}
/**
* Register a new step component
* @param {string} workflowType - The workflow type identifier
* @param {string|number} step - The step identifier
* @param {Object} component - The Vue component
*/
export function registerStepComponent(workflowType, step, component) {
const componentKey = `${workflowType}-${step}`;
componentRegistry[componentKey] = component;
}
/**
* Get all registered components for a workflow type
* @param {string} workflowType - The workflow type identifier
* @returns {Object} Object with step numbers as keys and components as values
*/
export function getWorkflowComponents(workflowType) {
const workflowComponents = {};
Object.keys(componentRegistry).forEach(key => {
if (key.startsWith(`${workflowType}-`)) {
const step = key.replace(`${workflowType}-`, '');
workflowComponents[step] = componentRegistry[key];
}
});
return workflowComponents;
}
/**
* Check if a step component exists for a workflow type and step
* @param {string} workflowType - The workflow type identifier
* @param {string|number} step - The step identifier
* @returns {boolean} True if component exists, false otherwise
*/
export function hasStepComponent(workflowType, step) {
const componentKey = `${workflowType}-${step}`;
return componentKey in componentRegistry;
}
/**
* Get all registered workflow types
* @returns {Array<string>} Array of workflow type identifiers
*/
export function getRegisteredWorkflowTypes() {
const workflowTypes = new Set();
Object.keys(componentRegistry).forEach(key => {
const parts = key.split('-');
if (parts.length >= 2) {
// Reconstruct workflow type (everything except the last part which is the step)
const workflowType = parts.slice(0, -1).join('-');
workflowTypes.add(workflowType);
}
});
return Array.from(workflowTypes);
}
export default {
getStepComponent,
registerStepComponent,
getWorkflowComponents,
hasStepComponent,
getRegisteredWorkflowTypes
};

View file

@ -0,0 +1,193 @@
/**
* Workflow Step Component Example Usage
*
* This file demonstrates how to use the workflow step component dispatching system
* and provides examples for developers who want to create new workflow steps.
*/
import {
getStepComponent,
registerStepComponent,
hasStepComponent,
getWorkflowComponents,
getRegisteredWorkflowTypes
} from './ComponentRegistry.js';
/**
* Example: Creating and registering a new workflow step component
*/
// 1. Create your step component (example)
const ExampleWorkflowStep1 = {
name: 'ExampleWorkflowStep1',
props: {
workflowInstance: { type: Object, required: true },
step: { type: String, required: true },
payload: { type: Object, default: () => ({}) }
},
template: `
<div class="example-step">
<h4>Example Workflow - Step 1</h4>
<p>This is a custom workflow step component.</p>
<button @click="$emit('next')" class="btn btn-primary">
Next Step
</button>
</div>
`
};
// 2. Register the component
registerStepComponent('example-workflow', '1', ExampleWorkflowStep1);
/**
* Example: Using the component registry programmatically
*/
export function demonstrateComponentRegistry() {
console.log('=== Workflow Component Registry Demo ===');
// Check if a component exists
console.log('Has foto-first step 1:', hasStepComponent('foto-first-bulk-import', '1'));
console.log('Has non-existent step:', hasStepComponent('non-existent', '999'));
// Get a specific component
const step1Component = getStepComponent('foto-first-bulk-import', '1');
console.log('Retrieved component:', step1Component?.name);
// Get all components for a workflow
const fotoFirstComponents = getWorkflowComponents('foto-first-bulk-import');
console.log('Foto First components:', Object.keys(fotoFirstComponents));
// Get all registered workflow types
const workflowTypes = getRegisteredWorkflowTypes();
console.log('Registered workflow types:', workflowTypes);
return {
availableWorkflows: workflowTypes,
fotoFirstSteps: Object.keys(fotoFirstComponents),
totalComponents: workflowTypes.reduce((total, type) => {
return total + Object.keys(getWorkflowComponents(type)).length;
}, 0)
};
}
/**
* Example: Dynamic component loading in a Vue component
*/
export const WorkflowStepLoader = {
name: 'WorkflowStepLoader',
props: {
workflowType: { type: String, required: true },
currentStep: { type: [String, Number], required: true },
workflowInstance: { type: Object, required: true },
payload: { type: Object, default: () => ({}) }
},
computed: {
stepComponent() {
return getStepComponent(this.workflowType, this.currentStep);
},
hasStepComponent() {
return hasStepComponent(this.workflowType, this.currentStep);
}
},
template: `
<div class="workflow-step-loader">
<!-- Custom step component if available -->
<component
v-if="stepComponent"
:is="stepComponent"
:workflow-instance="workflowInstance"
:step="currentStep.toString()"
:payload="payload"
@update="$emit('update', $event)"
@next="$emit('next')"
@prev="$emit('prev')"
@complete="$emit('complete')"
/>
<!-- Fallback content if no custom component -->
<div v-else class="default-step-content">
<h5>{{ workflowType }} - Step {{ currentStep }}</h5>
<p class="text-muted">No custom component found for this step.</p>
<div class="d-flex justify-content-between">
<button @click="$emit('prev')" class="btn btn-outline-secondary">
Previous
</button>
<button @click="$emit('next')" class="btn btn-primary">
Next
</button>
</div>
</div>
</div>
`
};
/**
* Development utilities for workflow components
*/
export const WorkflowDevUtils = {
/**
* List all available workflow steps
*/
listAllSteps() {
const workflowTypes = getRegisteredWorkflowTypes();
const allSteps = {};
workflowTypes.forEach(type => {
allSteps[type] = Object.keys(getWorkflowComponents(type));
});
return allSteps;
},
/**
* Validate workflow step coverage
*/
validateWorkflowCoverage(workflowDefinitions) {
const results = {};
Object.entries(workflowDefinitions).forEach(([type, definition]) => {
const requiredSteps = definition.getStepDefinitions().map(s => s.step.toString());
const availableSteps = Object.keys(getWorkflowComponents(type));
results[type] = {
required: requiredSteps,
available: availableSteps,
missing: requiredSteps.filter(step => !availableSteps.includes(step)),
coverage: (availableSteps.length / requiredSteps.length) * 100
};
});
return results;
},
/**
* Generate component registry report
*/
generateReport() {
const workflowTypes = getRegisteredWorkflowTypes();
const report = {
totalWorkflowTypes: workflowTypes.length,
totalComponents: 0,
workflows: {}
};
workflowTypes.forEach(type => {
const components = getWorkflowComponents(type);
const stepCount = Object.keys(components).length;
report.totalComponents += stepCount;
report.workflows[type] = {
steps: stepCount,
stepNumbers: Object.keys(components).sort((a, b) => parseInt(a) - parseInt(b))
};
});
return report;
}
};
export default {
demonstrateComponentRegistry,
WorkflowStepLoader,
WorkflowDevUtils
};

View file

@ -0,0 +1,146 @@
# Workflow Step Component Dispatching System
## Overview
This system enables dynamic dispatching of Vue.js components based on workflow type and current step in the WorkflowDetail view. It provides a flexible, extensible architecture for creating custom step-specific user interfaces for different workflow types.
## Architecture
### 1. Component Registry (`/components/workflow/ComponentRegistry.js`)
The central registry that maps workflow types and steps to their corresponding Vue components using the format: `workflowType-step` → Component.
**Key Functions:**
- `getStepComponent(workflowType, step)` - Retrieves a step component
- `registerStepComponent(workflowType, step, component)` - Registers new components
- `hasStepComponent(workflowType, step)` - Checks component existence
- `getWorkflowComponents(workflowType)` - Gets all components for a workflow
- `getRegisteredWorkflowTypes()` - Lists all registered workflow types
### 2. Step Components (`/components/workflow/steps/`)
Individual Vue components that handle specific workflow steps:
#### Foto First Import Workflow
- **FotoFirstStep1.vue** - Photo capture/upload with camera and file upload support
- **FotoFirstStep2.vue** - Image processing with compression and optimization
- **FotoFirstStep3.vue** - Item details entry with form-based data collection
- **FotoFirstStep4.vue** - Import completion with summary and finalization
#### Bulk Import Workflow
- **BulkImportStep1.vue** - File upload with CSV/Excel support and column mapping
### 3. Dynamic Component Loading in WorkflowDetail.vue
The `stepComponent` computed property now uses the registry:
```javascript
stepComponent() {
const workflowType = this.workflowInstance?.workflow_type;
if (workflowType && this.currentStep) {
return getStepComponent(workflowType, this.currentStep);
}
return null;
}
```
## How Content Dispatching Works
### 1. **Workflow Active State Management**
- Components connect to Vuex store's `active_workflows` state
- `loadWorkflowInstance()` fetches and finds specific workflow instances
- State determines which workflow type and step are active
### 2. **Dynamic Component Resolution**
- System looks up components using workflow type + step combination
- Registry returns the appropriate Vue component or null
- Vue's `<component :is="stepComponent">` renders the resolved component
### 3. **Component Communication**
- Step components receive props: `workflowInstance`, `step`, `payload`
- Components emit events: `@update`, `@next`, `@prev`, `@complete`
- Parent WorkflowDetail handles state updates and navigation
### 4. **Workflow Active Classes**
- `isStepCurrent(stepNumber)` - Identifies active step
- `isStepCompleted(stepNumber)` - Tracks completed steps
- `getStepClass(stepNumber)` - Applies appropriate CSS classes:
- `step-indicator-completed bg-success` - Completed steps
- `step-indicator-current bg-primary text-white` - Current step
- `step-indicator-pending bg-light border` - Pending steps
## Component Features
### FotoFirstStep1 (Photo Capture)
- **Camera Integration**: Uses `navigator.mediaDevices.getUserMedia()`
- **File Upload**: Drag-and-drop and file selection
- **Image Preview**: Real-time photo gallery
- **Data Persistence**: Photos saved to workflow payload
### FotoFirstStep2 (Image Processing)
- **Batch Processing**: Processes multiple images sequentially
- **Image Optimization**: Compression and resizing
- **Progress Tracking**: Visual progress indicators
- **Processing Options**: Configurable compression and dimensions
### FotoFirstStep3 (Item Details)
- **Item-by-Item Entry**: Navigate through captured photos
- **Comprehensive Forms**: Name, category, quantity, location, pricing
- **Progress Tracking**: Shows completion status
- **Data Validation**: Required field validation
- **Edit Support**: Ability to modify previously entered items
### FotoFirstStep4 (Completion)
- **Import Summary**: Statistics and breakdowns
- **Data Review**: Grid and list view of items
- **Final Options**: QR codes, notifications, reports
- **Completion Workflow**: Final import execution
### BulkImportStep1 (File Upload)
- **File Type Support**: CSV and Excel files
- **Drag-and-Drop**: Modern file upload interface
- **Column Mapping**: Automatic and manual field mapping
- **Data Preview**: Shows first 5 rows of data
- **Template Download**: Provides sample CSV template
- **File Analysis**: Validates data structure and content
## Extensibility
### Adding New Workflow Types
1. Create step components in `/components/workflow/steps/`
2. Import components in `ComponentRegistry.js`
3. Add mappings to `componentRegistry` object
4. Define workflow in `workflows.js`
### Adding New Steps
1. Create component: `WorkflowTypeStepN.vue`
2. Import and register in ComponentRegistry
3. Update workflow step definitions
4. Component automatically dispatched when step is reached
## Benefits
1. **Modularity**: Each step is an independent, reusable component
2. **Flexibility**: Easy to create workflow-specific UI experiences
3. **Maintainability**: Clear separation of concerns
4. **Extensibility**: Simple process to add new workflows and steps
5. **Type Safety**: Registry provides centralized component management
6. **Performance**: Components only loaded when needed
7. **Consistency**: Standardized props and events across all step components
## Usage Example
```javascript
// Register a new step component
registerStepComponent('custom-workflow', '1', CustomStep1Component);
// Check if component exists
if (hasStepComponent('foto-first-bulk-import', '1')) {
// Component is available
}
// Get all components for a workflow
const components = getWorkflowComponents('import-items');
```
This system provides a robust foundation for building complex, multi-step workflows with rich, interactive user interfaces while maintaining clean separation between workflow logic and presentation components.

View file

@ -0,0 +1,480 @@
<template>
<div class="bulk-import-step-1">
<div class="step-header mb-4">
<h4 class="mb-2">File Upload</h4>
<p class="text-muted">Upload your CSV or Excel file containing item data for bulk import.</p>
</div>
<!-- File Upload Area -->
<div class="upload-section mb-4">
<div class="card">
<div class="card-body">
<div v-if="!uploadedFile" class="upload-dropzone text-center py-5"
@dragover.prevent
@dragenter.prevent
@drop.prevent="handleFileDrop"
:class="{ 'dragover': isDragOver }"
@dragenter="isDragOver = true"
@dragleave="isDragOver = false">
<b-icon-cloud-upload class="text-primary mb-3" style="font-size: 4rem;"></b-icon-cloud-upload>
<h5 class="mb-3">Upload Your Data File</h5>
<p class="text-muted mb-4">
Drag and drop your CSV or Excel file here, or click to browse
</p>
<input
type="file"
ref="fileInput"
accept=".csv,.xlsx,.xls"
@change="handleFileSelect"
class="d-none"
/>
<div class="mb-3">
<button class="btn btn-primary btn-lg me-2" @click="$refs.fileInput.click()">
<b-icon-folder-open class="me-2"></b-icon-folder-open>
Choose File
</button>
<button class="btn btn-outline-info" @click="downloadTemplate">
<b-icon-download class="me-2"></b-icon-download>
Download Template
</button>
</div>
<div class="supported-formats">
<small class="text-muted">
Supported formats: CSV (.csv), Excel (.xlsx, .xls)
</small>
</div>
</div>
<!-- File Info Display -->
<div v-else class="file-info">
<div class="d-flex align-items-center justify-content-between mb-3">
<div class="d-flex align-items-center">
<b-icon-file-earmark-spreadsheet class="text-success me-3" style="font-size: 2rem;"></b-icon-file-earmark-spreadsheet>
<div>
<h6 class="mb-1">{{ uploadedFile.name }}</h6>
<small class="text-muted">
{{ formatFileSize(uploadedFile.size) }}
{{ getFileType(uploadedFile.name) }}
Uploaded {{ formatDateTime(uploadTime) }}
</small>
</div>
</div>
<button class="btn btn-outline-danger btn-sm" @click="removeFile">
<b-icon-trash></b-icon-trash>
Remove
</button>
</div>
<!-- File Analysis Results -->
<div v-if="fileAnalysis" class="file-analysis">
<div class="row">
<div class="col-md-3 mb-2">
<div class="text-center">
<h4 class="text-primary mb-1">{{ fileAnalysis.totalRows }}</h4>
<small class="text-muted">Total Rows</small>
</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-center">
<h4 class="text-info mb-1">{{ fileAnalysis.totalColumns }}</h4>
<small class="text-muted">Columns</small>
</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-center">
<h4 class="text-success mb-1">{{ fileAnalysis.validRows }}</h4>
<small class="text-muted">Valid Rows</small>
</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-center">
<h4 class="text-warning mb-1">{{ fileAnalysis.errorRows }}</h4>
<small class="text-muted">Issues Found</small>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- File Processing Status -->
<div v-if="processing" class="processing-status mb-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="spinner-border text-primary me-3" role="status"></div>
<div>
<h6 class="mb-1">Processing file...</h6>
<p class="text-muted mb-0">{{ processingStatus }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Column Mapping -->
<div v-if="fileAnalysis && !processing" class="column-mapping mb-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Column Mapping</h6>
<small class="text-muted">Map your file columns to system fields</small>
</div>
<div class="card-body">
<div class="row">
<div v-for="field in requiredFields" :key="field.key" class="col-md-6 mb-3">
<label class="form-label">
{{ field.label }}
<span v-if="field.required" class="text-danger">*</span>
</label>
<select class="form-select" v-model="columnMapping[field.key]">
<option value="">Select column...</option>
<option v-for="column in detectedColumns" :key="column" :value="column">
{{ column }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Data Preview -->
<div v-if="previewData.length > 0" class="data-preview mb-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Data Preview</h6>
<small class="text-muted">First 5 rows of your data</small>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th v-for="column in detectedColumns" :key="column">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in previewData.slice(0, 5)" :key="index">
<td v-for="column in detectedColumns" :key="column">
{{ row[column] || '-' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Navigation -->
<div class="step-navigation d-flex justify-content-between">
<div></div>
<button
class="btn btn-primary"
@click="proceedToNext"
:disabled="!canProceed"
>
Next: Parse Data
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
</div>
</div>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
export default {
name: 'BulkImportStep1',
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
data() {
return {
uploadedFile: null,
uploadTime: null,
processing: false,
processingStatus: '',
isDragOver: false,
fileAnalysis: null,
detectedColumns: [],
previewData: [],
columnMapping: {},
requiredFields: [
{ key: 'name', label: 'Item Name', required: true },
{ key: 'category', label: 'Category', required: false },
{ key: 'quantity', label: 'Quantity', required: false },
{ key: 'unit', label: 'Unit', required: false },
{ key: 'description', label: 'Description', required: false },
{ key: 'location', label: 'Storage Location', required: false },
{ key: 'purchase_price', label: 'Purchase Price', required: false },
{ key: 'estimated_value', label: 'Estimated Value', required: false }
]
}
},
computed: {
canProceed() {
return this.uploadedFile && this.fileAnalysis && this.columnMapping.name;
}
},
mounted() {
// Load existing data if resuming
if (this.payload.uploaded_file) {
this.uploadedFile = this.payload.uploaded_file;
this.uploadTime = this.payload.upload_time;
this.fileAnalysis = this.payload.file_analysis;
this.detectedColumns = this.payload.detected_columns || [];
this.previewData = this.payload.preview_data || [];
this.columnMapping = this.payload.column_mapping || {};
}
},
methods: {
handleFileDrop(event) {
this.isDragOver = false;
const files = event.dataTransfer.files;
if (files.length > 0) {
this.processFile(files[0]);
}
},
handleFileSelect(event) {
const files = event.target.files;
if (files.length > 0) {
this.processFile(files[0]);
}
},
async processFile(file) {
if (!this.isValidFileType(file)) {
alert('Please upload a CSV or Excel file (.csv, .xlsx, .xls)');
return;
}
this.uploadedFile = file;
this.uploadTime = new Date().toISOString();
this.processing = true;
this.processingStatus = 'Reading file...';
try {
// Simulate file processing
await this.analyzeFile(file);
this.processingStatus = 'Analyzing data structure...';
await new Promise(resolve => setTimeout(resolve, 1000));
this.processingStatus = 'Generating preview...';
await new Promise(resolve => setTimeout(resolve, 500));
this.updatePayload();
} catch (error) {
console.error('Error processing file:', error);
alert('Error processing file. Please try again.');
this.removeFile();
} finally {
this.processing = false;
this.processingStatus = '';
}
},
async analyzeFile(file) {
// This is a simplified version - in reality, you'd use a library like Papa Parse for CSV
// or SheetJS for Excel files
if (file.name.endsWith('.csv')) {
const text = await file.text();
const lines = text.split('\n').filter(line => line.trim());
if (lines.length > 0) {
// Parse CSV header
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
this.detectedColumns = headers;
// Parse preview data
this.previewData = lines.slice(1, 6).map(line => {
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
const row = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
return row;
});
this.fileAnalysis = {
totalRows: lines.length - 1, // Exclude header
totalColumns: headers.length,
validRows: lines.length - 1, // Simplified - assume all valid for demo
errorRows: 0
};
}
} else {
// For Excel files, you'd use a library like SheetJS
// This is a mock implementation
this.detectedColumns = ['Name', 'Category', 'Quantity', 'Unit', 'Description'];
this.previewData = [
{ Name: 'Sample Item 1', Category: 'Tools', Quantity: '1', Unit: 'piece', Description: 'Sample description' },
{ Name: 'Sample Item 2', Category: 'Hardware', Quantity: '5', Unit: 'box', Description: 'Another sample' }
];
this.fileAnalysis = {
totalRows: 100,
totalColumns: 5,
validRows: 98,
errorRows: 2
};
}
// Auto-map columns based on common names
this.autoMapColumns();
},
autoMapColumns() {
const mapping = {};
this.detectedColumns.forEach(column => {
const lowerColumn = column.toLowerCase();
if (lowerColumn.includes('name') || lowerColumn.includes('item')) {
mapping.name = column;
} else if (lowerColumn.includes('category') || lowerColumn.includes('type')) {
mapping.category = column;
} else if (lowerColumn.includes('quantity') || lowerColumn.includes('qty')) {
mapping.quantity = column;
} else if (lowerColumn.includes('unit')) {
mapping.unit = column;
} else if (lowerColumn.includes('description') || lowerColumn.includes('desc')) {
mapping.description = column;
} else if (lowerColumn.includes('location') || lowerColumn.includes('storage')) {
mapping.location = column;
} else if (lowerColumn.includes('price') && lowerColumn.includes('purchase')) {
mapping.purchase_price = column;
} else if (lowerColumn.includes('value') || lowerColumn.includes('price')) {
mapping.estimated_value = column;
}
});
this.columnMapping = mapping;
},
isValidFileType(file) {
const validTypes = ['.csv', '.xlsx', '.xls'];
return validTypes.some(type => file.name.toLowerCase().endsWith(type));
},
getFileType(filename) {
if (filename.endsWith('.csv')) return 'CSV';
if (filename.endsWith('.xlsx') || filename.endsWith('.xls')) return 'Excel';
return 'Unknown';
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
formatDateTime(dateString) {
return new Date(dateString).toLocaleString();
},
removeFile() {
this.uploadedFile = null;
this.uploadTime = null;
this.fileAnalysis = null;
this.detectedColumns = [];
this.previewData = [];
this.columnMapping = {};
this.updatePayload();
},
downloadTemplate() {
// Create a sample CSV template
const headers = ['Name', 'Category', 'Quantity', 'Unit', 'Description', 'Location', 'Purchase Price', 'Estimated Value'];
const sampleData = [
['Hammer', 'Tools', '1', 'piece', 'Claw hammer for general use', 'Toolbox A', '25.99', '30.00'],
['Screws', 'Hardware', '100', 'pack', 'Wood screws 2 inch', 'Storage Bin 3', '12.50', '15.00']
];
const csvContent = [headers, ...sampleData]
.map(row => row.map(field => `"${field}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'inventory_import_template.csv';
a.click();
URL.revokeObjectURL(url);
},
updatePayload() {
this.$emit('update', {
uploaded_file: this.uploadedFile,
upload_time: this.uploadTime,
file_analysis: this.fileAnalysis,
detected_columns: this.detectedColumns,
preview_data: this.previewData,
column_mapping: this.columnMapping
});
},
proceedToNext() {
if (!this.canProceed) {
alert('Please upload a file and map the required Name column before proceeding.');
return;
}
this.updatePayload();
this.$emit('next');
}
}
}
</script>
<style scoped>
.upload-dropzone {
border: 2px dashed #dee2e6;
border-radius: 8px;
transition: all 0.3s ease;
cursor: pointer;
}
.upload-dropzone:hover,
.upload-dropzone.dragover {
border-color: #007bff;
background-color: #f8f9ff;
}
.file-info {
padding: 1rem;
background: #f8f9fa;
border-radius: 8px;
}
.file-analysis {
background: white;
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
}
</style>

View file

@ -0,0 +1,274 @@
<template>
<div class="foto-first-step-1">
<div class="step-header mb-4">
<h4 class="mb-2">Photo Capture</h4>
<p class="text-muted">Capture or upload item photos to begin the import process.</p>
</div>
<div class="upload-area mb-4">
<div class="row">
<!-- Camera Capture -->
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<b-icon-camera class="text-primary mb-3" style="font-size: 3rem;"></b-icon-camera>
<h6>Camera Capture</h6>
<p class="text-muted small">Use your device camera to capture photos</p>
<button class="btn btn-primary" @click="startCamera" :disabled="loading">
<b-icon-camera class="me-1"></b-icon-camera>
Start Camera
</button>
</div>
</div>
</div>
<!-- File Upload -->
<div class="col-md-6 mb-3">
<div class="card h-100">
<div class="card-body text-center">
<b-icon-upload class="text-success mb-3" style="font-size: 3rem;"></b-icon-upload>
<h6>File Upload</h6>
<p class="text-muted small">Upload photos from your device</p>
<input
type="file"
ref="fileInput"
multiple
accept="image/*"
@change="handleFileUpload"
class="d-none"
/>
<button class="btn btn-success" @click="$refs.fileInput.click()" :disabled="loading">
<b-icon-upload class="me-1"></b-icon-upload>
Upload Photos
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Camera Preview -->
<div v-if="showCamera" class="camera-section mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Camera Preview</h6>
<button class="btn btn-sm btn-outline-secondary" @click="stopCamera">
<b-icon-x></b-icon-x>
</button>
</div>
<div class="card-body">
<div class="camera-container text-center">
<video ref="video" autoplay muted class="camera-preview mb-3"></video>
<div>
<button class="btn btn-primary me-2" @click="capturePhoto" :disabled="!cameraReady">
<b-icon-camera class="me-1"></b-icon-camera>
Capture Photo
</button>
<button class="btn btn-outline-secondary" @click="stopCamera">
<b-icon-stop class="me-1"></b-icon-stop>
Stop Camera
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Photo Gallery -->
<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">
<b-icon-trash class="me-1"></b-icon-trash>
Clear All
</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">
<div class="card">
<img :src="photo.preview" class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">Photo {{ index + 1 }}</small>
<button class="btn btn-sm btn-outline-danger" @click="removePhoto(index)">
<b-icon-trash></b-icon-trash>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Navigation -->
<div class="step-navigation d-flex justify-content-between">
<div></div>
<button
class="btn btn-primary"
@click="proceedToNext"
:disabled="photos.length === 0 || loading"
>
Next: Process Images
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
</div>
</div>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
export default {
name: 'FotoFirstStep1',
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
data() {
return {
loading: false,
showCamera: false,
cameraReady: false,
photos: [],
stream: null
}
},
mounted() {
// Load existing photos from payload
if (this.payload.photos) {
this.photos = [...this.payload.photos];
}
},
beforeDestroy() {
this.stopCamera();
},
methods: {
async startCamera() {
try {
this.loading = true;
this.stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' }
});
this.$refs.video.srcObject = this.stream;
this.showCamera = true;
this.cameraReady = true;
} catch (error) {
console.error('Error accessing camera:', error);
alert('Could not access camera. Please check permissions or use file upload instead.');
} finally {
this.loading = false;
}
},
stopCamera() {
if (this.stream) {
this.stream.getTracks().forEach(track => track.stop());
this.stream = null;
}
this.showCamera = false;
this.cameraReady = false;
},
capturePhoto() {
if (!this.cameraReady) return;
const canvas = document.createElement('canvas');
const video = this.$refs.video;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
canvas.toBlob(blob => {
const photo = {
file: blob,
preview: URL.createObjectURL(blob),
name: `camera-photo-${Date.now()}.jpg`,
timestamp: new Date().toISOString()
};
this.photos.push(photo);
this.updatePayload();
}, 'image/jpeg', 0.8);
},
handleFileUpload(event) {
const files = Array.from(event.target.files);
files.forEach(file => {
if (file.type.startsWith('image/')) {
const photo = {
file: file,
preview: URL.createObjectURL(file),
name: file.name,
timestamp: new Date().toISOString()
};
this.photos.push(photo);
}
});
this.updatePayload();
event.target.value = '';
},
removePhoto(index) {
URL.revokeObjectURL(this.photos[index].preview);
this.photos.splice(index, 1);
this.updatePayload();
},
clearAllPhotos() {
if (confirm('Are you sure you want to remove all photos?')) {
this.photos.forEach(photo => URL.revokeObjectURL(photo.preview));
this.photos = [];
this.updatePayload();
}
},
updatePayload() {
this.$emit('update', { photos: this.photos });
},
proceedToNext() {
this.updatePayload();
this.$emit('next');
}
}
}
</script>
<style scoped>
.camera-preview {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
}
.photo-thumbnail {
height: 150px;
object-fit: cover;
}
.upload-area .card {
transition: transform 0.2s ease-in-out;
}
.upload-area .card:hover {
transform: translateY(-2px);
}
.camera-container {
position: relative;
}
</style>

View file

@ -0,0 +1,360 @@
<template>
<div class="foto-first-step-2">
<div class="step-header mb-4">
<h4 class="mb-2">Image Processing</h4>
<p class="text-muted">Processing and optimizing your captured images...</p>
</div>
<!-- Processing Status -->
<div class="processing-status mb-4">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Processing Progress</h6>
<span class="badge bg-primary">{{ processedCount }}/{{ totalPhotos }}</span>
</div>
<div class="progress mb-3" style="height: 12px;">
<div
class="progress-bar progress-bar-striped progress-bar-animated"
:style="{ width: progressPercentage + '%' }"
:class="{ 'bg-success': isComplete, 'bg-primary': !isComplete }"
></div>
</div>
<div class="processing-details">
<div v-if="currentlyProcessing" class="d-flex align-items-center text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<span>Processing: {{ currentlyProcessing }}</span>
</div>
<div v-else-if="isComplete" class="d-flex align-items-center text-success">
<b-icon-check-circle class="me-2"></b-icon-check-circle>
<span>All images processed successfully!</span>
</div>
</div>
</div>
</div>
</div>
<!-- Processing Options -->
<div class="processing-options mb-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Processing Options</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="form-check mb-2">
<input
class="form-check-input"
type="checkbox"
id="autoRotate"
v-model="processingOptions.auto_rotate"
:disabled="processing"
>
<label class="form-check-label" for="autoRotate">
Auto-rotate images based on EXIF data
</label>
</div>
<div class="form-check mb-2">
<input
class="form-check-input"
type="checkbox"
id="compress"
v-model="processingOptions.compress"
:disabled="processing"
>
<label class="form-check-label" for="compress">
Compress images for optimal storage
</label>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label class="form-label">Max Width (px)</label>
<input
type="number"
class="form-control"
v-model.number="processingOptions.max_width"
:disabled="processing"
min="480"
max="4096"
>
</div>
<div class="mb-3">
<label class="form-label">Max Height (px)</label>
<input
type="number"
class="form-control"
v-model.number="processingOptions.max_height"
:disabled="processing"
min="480"
max="4096"
>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Processed Images Preview -->
<div v-if="processedImages.length > 0" class="processed-images mb-4">
<h6 class="mb-3">Processed Images</h6>
<div class="row">
<div v-for="(image, index) in processedImages" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card">
<img :src="image.processedUrl" class="card-img-top processed-thumbnail" :alt="`Processed ${index + 1}`">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<small class="text-muted">{{ image.name }}</small>
<span class="badge bg-success">
<b-icon-check></b-icon-check>
</span>
</div>
<div class="processing-info">
<small class="text-muted d-block">
{{ formatFileSize(image.originalSize) }} {{ formatFileSize(image.processedSize) }}
</small>
<small class="text-success">
{{ Math.round(((image.originalSize - image.processedSize) / image.originalSize) * 100) }}% reduced
</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Navigation -->
<div class="step-navigation d-flex justify-content-between">
<button class="btn btn-outline-secondary" @click="$emit('prev')" :disabled="processing">
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<div class="d-flex gap-2">
<button
v-if="!processing && !isComplete"
class="btn btn-primary"
@click="startProcessing"
>
<b-icon-gear class="me-1"></b-icon-gear>
Start Processing
</button>
<button
v-if="isComplete"
class="btn btn-success"
@click="proceedToNext"
>
Next: Enter Item Details
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
</div>
</div>
</div>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
export default {
name: 'FotoFirstStep2',
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
data() {
return {
processing: false,
processedCount: 0,
currentlyProcessing: null,
processedImages: [],
processingOptions: {
auto_rotate: true,
compress: true,
max_width: 1920,
max_height: 1080
}
}
},
computed: {
totalPhotos() {
return this.payload.photos?.length || 0;
},
progressPercentage() {
return this.totalPhotos > 0 ? (this.processedCount / this.totalPhotos) * 100 : 0;
},
isComplete() {
return this.processedCount === this.totalPhotos && this.totalPhotos > 0;
}
},
mounted() {
// Load processing options from payload
if (this.payload.processing_options) {
this.processingOptions = { ...this.processingOptions, ...this.payload.processing_options };
}
// Load processed images if they exist
if (this.payload.processed_images) {
this.processedImages = [...this.payload.processed_images];
this.processedCount = this.processedImages.length;
}
},
methods: {
async startProcessing() {
if (!this.payload.photos || this.payload.photos.length === 0) {
alert('No photos to process. Please go back and add photos first.');
return;
}
this.processing = true;
this.processedCount = 0;
this.processedImages = [];
try {
for (let i = 0; i < this.payload.photos.length; i++) {
const photo = this.payload.photos[i];
this.currentlyProcessing = photo.name;
const processedImage = await this.processImage(photo);
this.processedImages.push(processedImage);
this.processedCount++;
// Small delay to show progress
await new Promise(resolve => setTimeout(resolve, 500));
}
this.currentlyProcessing = null;
this.updatePayload();
} catch (error) {
console.error('Error processing images:', error);
alert('Error processing images. Please try again.');
} finally {
this.processing = false;
}
},
async processImage(photo) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate new dimensions
let { width, height } = this.calculateDimensions(
img.width,
img.height,
this.processingOptions.max_width,
this.processingOptions.max_height
);
canvas.width = width;
canvas.height = height;
// Draw and compress
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(blob => {
const processedImage = {
name: photo.name,
originalSize: photo.file.size,
processedSize: blob.size,
processedUrl: URL.createObjectURL(blob),
processedFile: blob,
timestamp: new Date().toISOString()
};
resolve(processedImage);
}, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95);
};
img.src = photo.preview;
});
},
calculateDimensions(originalWidth, originalHeight, maxWidth, maxHeight) {
let width = originalWidth;
let height = originalHeight;
// Scale down if needed
if (width > maxWidth) {
height = (height * maxWidth) / width;
width = maxWidth;
}
if (height > maxHeight) {
width = (width * maxHeight) / height;
height = maxHeight;
}
return { width: Math.round(width), height: Math.round(height) };
},
formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
updatePayload() {
this.$emit('update', {
processing_options: this.processingOptions,
processed_images: this.processedImages
});
},
proceedToNext() {
this.updatePayload();
this.$emit('next');
}
},
beforeDestroy() {
// Clean up object URLs
this.processedImages.forEach(image => {
if (image.processedUrl && image.processedUrl.startsWith('blob:')) {
URL.revokeObjectURL(image.processedUrl);
}
});
}
}
</script>
<style scoped>
.processed-thumbnail {
height: 120px;
object-fit: cover;
}
.processing-info {
font-size: 0.75rem;
}
.progress-bar-animated {
animation: progress-bar-stripes 1s linear infinite;
}
@keyframes progress-bar-stripes {
0% {
background-position: 1rem 0;
}
100% {
background-position: 0 0;
}
}
</style>

View file

@ -0,0 +1,419 @@
<template>
<div class="foto-first-step-3">
<div class="step-header mb-4">
<h4 class="mb-2">Item Details Entry</h4>
<p class="text-muted">Enter details for each photographed item to complete the inventory import.</p>
</div>
<!-- Progress Indicator -->
<div class="progress-indicator mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0">Item Progress</h6>
<span class="badge bg-info">{{ currentItemIndex + 1 }} of {{ totalItems }}</span>
</div>
<div class="progress mb-2" style="height: 8px;">
<div
class="progress-bar bg-info"
:style="{ width: itemProgressPercentage + '%' }"
></div>
</div>
</div>
<!-- Current Item Display -->
<div v-if="currentItem" class="current-item mb-4">
<div class="row">
<!-- Image Preview -->
<div class="col-md-4">
<div class="card">
<img :src="currentItem.processedUrl || currentItem.preview" class="card-img-top item-image" alt="Current item">
<div class="card-body p-2">
<small class="text-muted">{{ currentItem.name }}</small>
</div>
</div>
</div>
<!-- Item Details Form -->
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Item Details</h6>
</div>
<div class="card-body">
<form @submit.prevent="saveCurrentItem">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Item Name *</label>
<input
type="text"
class="form-control"
v-model="currentItemDetails.name"
required
placeholder="Enter item name"
>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Category</label>
<select class="form-select" v-model="currentItemDetails.category">
<option value="">Select category...</option>
<option value="tools">Tools</option>
<option value="electronics">Electronics</option>
<option value="hardware">Hardware</option>
<option value="materials">Materials</option>
<option value="other">Other</option>
</select>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Quantity</label>
<input
type="number"
class="form-control"
v-model.number="currentItemDetails.quantity"
min="1"
placeholder="1"
>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Unit</label>
<select class="form-select" v-model="currentItemDetails.unit">
<option value="piece">Piece</option>
<option value="set">Set</option>
<option value="box">Box</option>
<option value="pack">Pack</option>
<option value="meter">Meter</option>
<option value="kilogram">Kilogram</option>
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Condition</label>
<select class="form-select" v-model="currentItemDetails.condition">
<option value="new">New</option>
<option value="excellent">Excellent</option>
<option value="good">Good</option>
<option value="fair">Fair</option>
<option value="poor">Poor</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea
class="form-control"
rows="3"
v-model="currentItemDetails.description"
placeholder="Optional description or notes"
></textarea>
</div>
<div class="mb-3">
<label class="form-label">Storage Location</label>
<input
type="text"
class="form-control"
v-model="currentItemDetails.location"
placeholder="e.g., Shelf A, Drawer 3, etc."
>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Purchase Price</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input
type="number"
class="form-control"
v-model.number="currentItemDetails.purchase_price"
step="0.01"
min="0"
placeholder="0.00"
>
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Estimated Value</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input
type="number"
class="form-control"
v-model.number="currentItemDetails.estimated_value"
step="0.01"
min="0"
placeholder="0.00"
>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Item Navigation -->
<div class="item-navigation mb-4">
<div class="d-flex justify-content-between align-items-center">
<button
class="btn btn-outline-secondary"
@click="previousItem"
:disabled="currentItemIndex === 0"
>
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous Item
</button>
<div class="btn-group">
<button class="btn btn-primary" @click="saveCurrentItem">
<b-icon-check class="me-1"></b-icon-check>
Save Item
</button>
<button class="btn btn-outline-primary" @click="skipCurrentItem">
<b-icon-skip-forward class="me-1"></b-icon-skip-forward>
Skip
</button>
</div>
<button
class="btn btn-outline-secondary"
@click="nextItem"
:disabled="currentItemIndex >= totalItems - 1"
>
Next Item
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
</div>
</div>
<!-- Completed Items Summary -->
<div v-if="completedItems.length > 0" class="completed-items mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Completed Items ({{ completedItems.length }})</h6>
<button class="btn btn-sm btn-outline-info" @click="showCompleted = !showCompleted">
<b-icon-eye v-if="!showCompleted"></b-icon-eye>
<b-icon-eye-slash v-else></b-icon-eye-slash>
{{ showCompleted ? 'Hide' : 'Show' }}
</button>
</div>
<div v-if="showCompleted" class="card-body">
<div class="row">
<div v-for="(item, index) in completedItems" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-2">
<div class="d-flex align-items-center">
<img :src="item.image.processedUrl || item.image.preview" class="completed-item-thumb me-2" alt="Item">
<div class="flex-grow-1">
<div class="fw-bold small">{{ item.details.name }}</div>
<div class="text-muted small">{{ item.details.category || 'No category' }}</div>
</div>
<button class="btn btn-sm btn-outline-secondary" @click="editItem(index)">
<b-icon-pencil></b-icon-pencil>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Navigation -->
<div class="step-navigation d-flex justify-content-between">
<button class="btn btn-outline-secondary" @click="$emit('prev')">
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Previous
</button>
<button
class="btn btn-success"
@click="proceedToNext"
:disabled="completedItems.length === 0"
>
Next: Complete Import ({{ completedItems.length }} items)
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
</button>
</div>
</div>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
export default {
name: 'FotoFirstStep3',
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
data() {
return {
currentItemIndex: 0,
currentItemDetails: this.getDefaultItemDetails(),
completedItems: [],
showCompleted: false
}
},
computed: {
availableItems() {
return this.payload.processed_images || this.payload.photos || [];
},
totalItems() {
return this.availableItems.length;
},
currentItem() {
return this.availableItems[this.currentItemIndex] || null;
},
itemProgressPercentage() {
return this.totalItems > 0 ? (this.completedItems.length / this.totalItems) * 100 : 0;
}
},
mounted() {
// Load existing completed items
if (this.payload.completed_items) {
this.completedItems = [...this.payload.completed_items];
}
// Load current item details if resuming
if (this.payload.current_item_details) {
this.currentItemDetails = { ...this.payload.current_item_details };
}
// Load current item index if resuming
if (this.payload.current_item_index !== undefined) {
this.currentItemIndex = this.payload.current_item_index;
}
},
methods: {
getDefaultItemDetails() {
return {
name: '',
category: '',
quantity: 1,
unit: 'piece',
condition: 'good',
description: '',
location: '',
purchase_price: null,
estimated_value: null
};
},
saveCurrentItem() {
if (!this.currentItemDetails.name.trim()) {
alert('Please enter an item name before saving.');
return;
}
const itemData = {
image: this.currentItem,
details: { ...this.currentItemDetails },
saved_at: new Date().toISOString()
};
// Check if we're editing an existing item
const existingIndex = this.completedItems.findIndex(item =>
item.image === this.currentItem
);
if (existingIndex >= 0) {
this.completedItems.splice(existingIndex, 1, itemData);
} else {
this.completedItems.push(itemData);
}
this.nextItem();
this.updatePayload();
},
skipCurrentItem() {
this.nextItem();
},
nextItem() {
if (this.currentItemIndex < this.totalItems - 1) {
this.currentItemIndex++;
this.currentItemDetails = this.getDefaultItemDetails();
}
},
previousItem() {
if (this.currentItemIndex > 0) {
this.currentItemIndex--;
// Load details if this item was already completed
const existingItem = this.completedItems.find(item =>
item.image === this.currentItem
);
if (existingItem) {
this.currentItemDetails = { ...existingItem.details };
} else {
this.currentItemDetails = this.getDefaultItemDetails();
}
}
},
editItem(index) {
const item = this.completedItems[index];
// Find the item index in available items
const itemIndex = this.availableItems.findIndex(img => img === item.image);
if (itemIndex >= 0) {
this.currentItemIndex = itemIndex;
this.currentItemDetails = { ...item.details };
}
},
updatePayload() {
this.$emit('update', {
completed_items: this.completedItems,
current_item_details: this.currentItemDetails,
current_item_index: this.currentItemIndex
});
},
proceedToNext() {
if (this.completedItems.length === 0) {
alert('Please complete at least one item before proceeding.');
return;
}
this.updatePayload();
this.$emit('next');
}
}
}
</script>
<style scoped>
.item-image {
height: 300px;
object-fit: cover;
}
.completed-item-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
}
.item-navigation {
background: #f8f9fa;
padding: 1rem;
border-radius: 8px;
}
</style>

View file

@ -0,0 +1,376 @@
<template>
<div class="foto-first-step-4">
<div class="step-header mb-4">
<h4 class="mb-2">Import Completion</h4>
<p class="text-muted">Review and finalize your imported items.</p>
</div>
<!-- Import Summary -->
<div class="import-summary mb-4">
<div class="row">
<div class="col-md-3 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary mb-2">{{ totalItems }}</h3>
<p class="card-text text-muted mb-0">Items Imported</p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-success mb-2">{{ categorizedItems }}</h3>
<p class="card-text text-muted mb-0">With Categories</p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-info mb-2">{{ itemsWithLocation }}</h3>
<p class="card-text text-muted mb-0">With Locations</p>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-warning mb-2">${{ totalValue }}</h3>
<p class="card-text text-muted mb-0">Total Value</p>
</div>
</div>
</div>
</div>
</div>
<!-- Category Breakdown -->
<div class="category-breakdown mb-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Items by Category</h6>
</div>
<div class="card-body">
<div v-if="Object.keys(categoryBreakdown).length > 0" class="row">
<div v-for="(count, category) in categoryBreakdown" :key="category" class="col-sm-6 col-md-4 col-lg-3 mb-2">
<div class="d-flex justify-content-between align-items-center">
<span class="text-capitalize">{{ category || 'Uncategorized' }}</span>
<span class="badge bg-secondary">{{ count }}</span>
</div>
</div>
</div>
<div v-else class="text-muted text-center py-3">
No items to categorize
</div>
</div>
</div>
</div>
<!-- Import Options -->
<div class="import-options mb-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">Import Options</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="form-check mb-3">
<input
class="form-check-input"
type="checkbox"
id="generateQr"
v-model="importOptions.generate_qr_codes"
>
<label class="form-check-label" for="generateQr">
Generate QR codes for items
</label>
</div>
<div class="form-check mb-3">
<input
class="form-check-input"
type="checkbox"
id="sendNotification"
v-model="importOptions.send_notification"
>
<label class="form-check-label" for="sendNotification">
Send completion notification
</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check mb-3">
<input
class="form-check-input"
type="checkbox"
id="createReport"
v-model="importOptions.create_report"
>
<label class="form-check-label" for="createReport">
Create import report
</label>
</div>
<div class="form-check mb-3">
<input
class="form-check-input"
type="checkbox"
id="autoBackup"
v-model="importOptions.auto_backup"
>
<label class="form-check-label" for="autoBackup">
Auto-backup imported data
</label>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Item List -->
<div class="item-list mb-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Imported Items</h6>
<div class="btn-group btn-group-sm">
<button
class="btn"
:class="viewMode === 'grid' ? 'btn-primary' : 'btn-outline-primary'"
@click="viewMode = 'grid'"
>
<b-icon-grid></b-icon-grid>
</button>
<button
class="btn"
:class="viewMode === 'list' ? 'btn-primary' : 'btn-outline-primary'"
@click="viewMode = 'list'"
>
<b-icon-list></b-icon-list>
</button>
</div>
</div>
<div class="card-body">
<!-- Grid View -->
<div v-if="viewMode === 'grid'" class="row">
<div v-for="(item, index) in items" :key="index" class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card h-100">
<img :src="item.image.processedUrl || item.image.preview" class="card-img-top item-thumb" :alt="item.details.name">
<div class="card-body p-2">
<h6 class="card-title mb-1">{{ item.details.name }}</h6>
<p class="card-text small text-muted mb-1">{{ item.details.category || 'No category' }}</p>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted">Qty: {{ item.details.quantity }}</small>
<small v-if="item.details.estimated_value" class="text-success">${{ item.details.estimated_value }}</small>
</div>
</div>
</div>
</div>
</div>
<!-- List View -->
<div v-else class="table-responsive">
<table class="table table-sm">
<thead>
<tr>
<th>Image</th>
<th>Name</th>
<th>Category</th>
<th>Quantity</th>
<th>Location</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in items" :key="index">
<td>
<img :src="item.image.processedUrl || item.image.preview" class="list-item-thumb" :alt="item.details.name">
</td>
<td class="fw-bold">{{ item.details.name }}</td>
<td>
<span v-if="item.details.category" class="badge bg-light text-dark">{{ item.details.category }}</span>
<span v-else class="text-muted">-</span>
</td>
<td>{{ item.details.quantity }} {{ item.details.unit }}</td>
<td>{{ item.details.location || '-' }}</td>
<td>
<span v-if="item.details.estimated_value" class="text-success">${{ item.details.estimated_value }}</span>
<span v-else class="text-muted">-</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Final Action -->
<div class="final-action text-center">
<div class="card">
<div class="card-body py-4">
<b-icon-check-circle class="text-success mb-3" style="font-size: 3rem;"></b-icon-check-circle>
<h5 class="mb-3">Ready to Complete Import</h5>
<p class="text-muted mb-4">
All {{ totalItems }} items have been processed and are ready to be added to your inventory.
This action cannot be undone.
</p>
<div class="d-flex justify-content-center gap-3">
<button class="btn btn-outline-secondary" @click="$emit('prev')">
<b-icon-chevron-left class="me-1"></b-icon-chevron-left>
Go Back
</button>
<button
class="btn btn-success btn-lg"
@click="completeImport"
:disabled="importing"
>
<div v-if="importing" class="spinner-border spinner-border-sm me-2" role="status"></div>
<b-icon-check-circle v-else class="me-2"></b-icon-check-circle>
{{ importing ? 'Importing...' : 'Complete Import' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import * as BIcons from "bootstrap-icons-vue";
export default {
name: 'FotoFirstStep4',
components: {
...BIcons
},
props: {
workflowInstance: {
type: Object,
required: true
},
step: {
type: String,
required: true
},
payload: {
type: Object,
default: () => ({})
}
},
data() {
return {
importing: false,
viewMode: 'grid',
importOptions: {
generate_qr_codes: true,
send_notification: true,
create_report: true,
auto_backup: false
}
}
},
computed: {
items() {
return this.payload.completed_items || [];
},
totalItems() {
return this.items.length;
},
categorizedItems() {
return this.items.filter(item => item.details.category).length;
},
itemsWithLocation() {
return this.items.filter(item => item.details.location).length;
},
totalValue() {
return this.items.reduce((sum, item) => {
return sum + (item.details.estimated_value || 0);
}, 0).toFixed(2);
},
categoryBreakdown() {
const breakdown = {};
this.items.forEach(item => {
const category = item.details.category || 'uncategorized';
breakdown[category] = (breakdown[category] || 0) + 1;
});
return breakdown;
}
},
mounted() {
// Load import options from payload
if (this.payload.import_options) {
this.importOptions = { ...this.importOptions, ...this.payload.import_options };
}
},
methods: {
async completeImport() {
if (this.totalItems === 0) {
alert('No items to import. Please go back and add items.');
return;
}
const confirmed = confirm(
`Are you sure you want to import ${this.totalItems} items? This action cannot be undone.`
);
if (!confirmed) return;
try {
this.importing = true;
// Update payload with final options
this.updatePayload();
// Simulate import process
await new Promise(resolve => setTimeout(resolve, 2000));
// Complete the workflow
this.$emit('update', {
import_completed: true,
completion_timestamp: new Date().toISOString()
});
// Navigate to success or trigger workflow completion
this.$emit('complete');
} catch (error) {
console.error('Error completing import:', error);
alert('Error completing import. Please try again.');
} finally {
this.importing = false;
}
},
updatePayload() {
this.$emit('update', {
import_options: this.importOptions,
final_summary: {
total_items: this.totalItems,
categorized_items: this.categorizedItems,
items_with_location: this.itemsWithLocation,
total_value: parseFloat(this.totalValue),
category_breakdown: this.categoryBreakdown
}
});
}
}
}
</script>
<style scoped>
.item-thumb {
height: 120px;
object-fit: cover;
}
.list-item-thumb {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 4px;
}
.final-action .card {
border: 2px solid #28a745;
background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%);
}
</style>

View file

@ -28,6 +28,7 @@ import Privacy from '@/views/settings/Privacy.vue';
import Email from '@/views/settings/Email.vue';
import Notifications from '@/views/settings/Notifications.vue';
import Data from '@/views/settings/Data.vue';
import Preferences from '@/views/settings/Preferences.vue';
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
@ -86,6 +87,8 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: 'notifications/', name: 'notifications', component: Notifications, meta: {requiresAuth: true}
}, {
path: 'data/', name: 'data', component: Data, meta: {requiresAuth: true}
}, {
path: 'preferences/', name: 'preferences', component: Preferences, meta: {requiresAuth: true}
}]
}, {
path: '/storage-location',

View file

@ -7,12 +7,65 @@ import {parseIdentityRecord, serializeIdentityRecord} from "@/identity";
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
const defaultPreferenceDefinitions = [
{
key: 'ui.compact_mode',
type: 'boolean',
default: false,
label: 'Compact mode',
description: 'Show denser item rows and reduce spacing in lists.'
},
{
key: 'ui.default_search_scope',
type: 'enum',
options: ['inventory', 'friends', 'all'],
default: 'inventory',
label: 'Default search scope',
description: 'Choose where the global search starts.'
},
{
key: 'notifications.desktop_enabled',
type: 'boolean',
default: true,
label: 'Desktop notifications',
description: 'Enable in-browser notifications for important updates.'
},
{
key: 'files.max_upload_mb',
type: 'integer',
default: 25,
label: 'Default upload size limit (MB)',
description: 'Used as a prefill hint in upload dialogs.'
},
{
key: 'ui.experimental_flags',
type: 'json',
default: {},
label: 'Experimental flags',
description: 'Optional JSON toggles for feature previews.'
},
]
const parseStoredPreferences = (storageKey) => {
try {
const raw = localStorage.getItem(storageKey)
if (!raw) {
return {}
}
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch (_error) {
return {}
}
}
export default createStore({
state: {
local_loaded: false,
last_load: {},
user: null,
user_profile: null,
token: null,
keypair: null,
remember: false,
@ -31,6 +84,10 @@ export default createStore({
domains: [],
storage_locations: [],
active_workflows: [],
preferenceDefinitions: [],
accountPreferences: {},
devicePreferences: {},
preferencesLoaded: false,
},
mutations: {
setInventoryItems(state, {url, items}) {
@ -69,11 +126,42 @@ export default createStore({
setActiveWorkflows(state, workflows) {
state.active_workflows = workflows;
},
setPreferenceDefinitions(state, definitions) {
state.preferenceDefinitions = definitions;
},
setAccountPreferences(state, preferences) {
state.accountPreferences = preferences;
},
setDevicePreferences(state, preferences) {
state.devicePreferences = preferences;
},
setAccountPreference(state, {key, value}) {
state.accountPreferences = {...state.accountPreferences, [key]: value};
},
setDevicePreference(state, {key, value}) {
state.devicePreferences = {...state.devicePreferences, [key]: value};
},
deleteAccountPreference(state, key) {
const prefs = {...state.accountPreferences};
delete prefs[key];
state.accountPreferences = prefs;
},
deleteDevicePreference(state, key) {
const prefs = {...state.devicePreferences};
delete prefs[key];
state.devicePreferences = prefs;
},
setPreferencesLoaded(state, loaded) {
state.preferencesLoaded = loaded;
},
setUser(state, user) {
state.user = user;
if (state.remember)
localStorage.setItem('user', user);
},
setUserProfile(state, profile) {
state.user_profile = profile;
},
setToken(state, token) {
state.token = token;
if (state.remember)
@ -110,6 +198,7 @@ export default createStore({
},
logout(state) {
state.user = null;
state.user_profile = null;
state.token = null;
state.keypair = null;
localStorage.removeItem('user');
@ -136,6 +225,33 @@ export default createStore({
}
},
actions: {
async loadUserPreferences({state, commit}) {
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous')
const deviceKey = 'toolshed.preferences.device'
commit('setPreferenceDefinitions', defaultPreferenceDefinitions)
commit('setAccountPreferences', parseStoredPreferences(accountKey))
commit('setDevicePreferences', parseStoredPreferences(deviceKey))
commit('setPreferencesLoaded', true)
},
async setAccountPreference({state, commit}, {key, value}) {
commit('setAccountPreference', {key, value})
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous')
localStorage.setItem(accountKey, JSON.stringify(state.accountPreferences))
},
async resetAccountPreference({state, commit}, key) {
commit('deleteAccountPreference', key)
const accountKey = 'toolshed.preferences.account.' + (state.user || 'anonymous')
localStorage.setItem(accountKey, JSON.stringify(state.accountPreferences))
},
async setDevicePreference({state, commit}, {key, value}) {
commit('setDevicePreference', {key, value})
localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences))
},
async resetDevicePreference({state, commit}, key) {
commit('deleteDevicePreference', key)
localStorage.setItem('toolshed.preferences.device', JSON.stringify(state.devicePreferences))
},
async login({commit, dispatch, state, getters}, {username, password, remember}) {
commit('setRemember', remember);
const data = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
@ -146,11 +262,33 @@ export default createStore({
commit('setKey', data.key);
const s = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
commit('setHomeServers', s)
await dispatch('fetchUserProfile', {force: true})
return true;
} else {
return false;
}
},
async fetchUserProfile({state, commit, dispatch, getters}, {force = false} = {}) {
if (!force && state.user_profile && state.last_load.user_profile > Date.now() - 1000 * 60) {
return state.user_profile
}
const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/auth/user/')
commit('setUserProfile', data)
state.last_load.user_profile = Date.now()
return data
},
async updateUserProfilePicture({state, commit, dispatch, getters}, {file = null} = {}) {
const servers = await dispatch('getHomeServers')
const payload = file ? {profile_picture: {data: file.data, mime_type: file.mime_type}} : {profile_picture: null}
const data = await servers.patch(getters.signAuth, '/auth/user/', payload)
commit('setUserProfile', data)
state.last_load.user_profile = Date.now()
if (data.profile_picture) {
state.last_load.files = 0
}
return data
},
async lookupServer({state}, {username}) {
const domain = username.split('@')[1]
const request = '_toolshed-server._tcp.' + domain + '.'
@ -510,5 +648,18 @@ export default createStore({
time: Date.now() - 1000 * 60 * 60 * 24
}]
},
getPreference: (state) => (key, fallbackDefault = null) => {
if (Object.prototype.hasOwnProperty.call(state.devicePreferences, key) && state.devicePreferences[key] !== null) {
return state.devicePreferences[key]
}
if (Object.prototype.hasOwnProperty.call(state.accountPreferences, key) && state.accountPreferences[key] !== null) {
return state.accountPreferences[key]
}
const definition = state.preferenceDefinitions.find((pref) => pref.key === key)
if (definition) {
return definition.default
}
return fallbackDefault
},
}
})

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>