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.models import ToolshedUser
from authentication.signature_auth import SignatureAuthenticationLocal from authentication.signature_auth import SignatureAuthenticationLocal
from files.models import File
from files.serializers import FileSerializer
from hostadmin.models import Domain from hostadmin.models import Domain
router = routers.SimpleRouter() router = routers.SimpleRouter()
@ -53,15 +55,43 @@ class UserViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated, IsAdminUser] permission_classes = [IsAuthenticated, IsAdminUser]
@api_view(['GET']) @api_view(['GET', 'PATCH'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@authentication_classes([SignatureAuthenticationLocal]) @authentication_classes([SignatureAuthenticationLocal])
def getUserInfo(request): def getUserInfo(request):
user = request.user 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({ return Response({
'username': user.username, 'username': user.username,
'domain': user.domain, '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') domain = models.CharField(max_length=255, default='localhost')
private_key = models.CharField(max_length=255) private_key = models.CharField(max_length=255)
public_identity = models.ForeignKey(KnownIdentity, on_delete=models.CASCADE, related_name='user') 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() objects = ToolshedUserManager()
class Meta: class Meta:

View file

@ -1,4 +1,5 @@
import json import json
import base64
from django.test import Client, RequestFactory from django.test import Client, RequestFactory
from nacl.encoding import HexEncoder from nacl.encoding import HexEncoder
@ -6,6 +7,7 @@ from nacl.signing import SigningKey
from authentication.models import ToolshedUser, KnownIdentity from authentication.models import ToolshedUser, KnownIdentity
from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase from authentication.tests import UserTestMixin, SignatureAuthClient, DummyExternalUser, ToolshedTestCase
from files.models import File
class AuthorizationTestCase(ToolshedTestCase): class AuthorizationTestCase(ToolshedTestCase):
@ -240,6 +242,7 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.json()['username'], 'testuser1') self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com') self.assertEqual(reply.json()['domain'], 'example.com')
self.assertEqual(reply.json()['email'], 'test1@abc.de') self.assertEqual(reply.json()['email'], 'test1@abc.de')
self.assertIsNone(reply.json()['profile_picture'])
def test_user_info2(self): def test_user_info2(self):
target = "/auth/user/" target = "/auth/user/"
@ -249,6 +252,50 @@ class UserApiTestCase(UserTestMixin, ToolshedTestCase):
self.assertEqual(reply.status_code, 200) self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['username'], 'testuser1') self.assertEqual(reply.json()['username'], 'testuser1')
self.assertEqual(reply.json()['domain'], 'example.com') 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): def test_user_info_fail(self):
reply = self.anonymous_client.get('/auth/user/') reply = self.anonymous_client.get('/auth/user/')

View file

@ -1,5 +1,7 @@
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import path 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 drf_yasg.utils import swagger_auto_schema
from rest_framework import status from rest_framework import status
from rest_framework.decorators import api_view, permission_classes, authentication_classes 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 rest_framework.response import Response
from authentication.signature_auth import SignatureAuthentication from authentication.signature_auth import SignatureAuthentication
from backend import settings
from files.models import File from files.models import File
@ -17,7 +18,10 @@ from files.models import File
@authentication_classes([SignatureAuthentication]) @authentication_classes([SignatureAuthentication])
def media_urls(request, hash_path): def media_urls(request, hash_path):
try: 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) file=hash_path)
if settings.SERVE_X_ACCEL_REDIRECT: if settings.SERVE_X_ACCEL_REDIRECT:

View file

@ -1,7 +1,7 @@
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.core.files.storage import DefaultStorage from django.core.files.storage import DefaultStorage
from django.db import IntegrityError, transaction 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 authentication.tests import SignatureAuthClient, ToolshedTestCase, UserTestMixin
from toolshed.tests import InventoryTestMixin from toolshed.tests import InventoryTestMixin
from nacl.hash import sha256 from nacl.hash import sha256
@ -165,3 +165,33 @@ class MediaUrlTestCase(FilesTestMixin, UserTestMixin, InventoryTestMixin, Toolsh
self.f['ext_user1']) self.f['ext_user1'])
self.assertEqual(reply.status_code, 404) self.assertEqual(reply.status_code, 404)
self.assertTrue('X-Accel-Redirect' not in reply.headers) 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') tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
properties = models.ManyToManyField(Property, through='ItemProperty') properties = models.ManyToManyField(Property, through='ItemProperty')
files = models.ManyToManyField(File, related_name='connected_items') 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') related_name='inventory_items')
def clean(self): def clean(self):

View file

@ -1,6 +1,6 @@
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin from files.tests import FilesTestMixin
from toolshed.models import InventoryItem, Category from toolshed.models import InventoryItem, Category, StorageLocation
from toolshed.tests import InventoryTestMixin, LocationTestMixin from toolshed.tests import InventoryTestMixin, LocationTestMixin
client = SignatureAuthClient() client = SignatureAuthClient()
@ -69,3 +69,63 @@ class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin,
self.assertEqual(reply.json()[3]['description'], None) self.assertEqual(reply.json()[3]['description'], None)
self.assertEqual(reply.json()[3]['category'], 'cat1') self.assertEqual(reply.json()[3]['category'], 'cat1')
self.assertEqual(reply.json()[3]['path'], 'loc1/loc4') 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" "a.localhost"
] ]

View file

@ -14,7 +14,7 @@ http {
} }
upstream dns { upstream dns {
server dns:8053; server toolshed-dns:8053;
} }
server { server {
@ -106,17 +106,35 @@ http {
# DoH server # DoH server
server { server {
listen 5353 ssl; listen 5353 ssl;
server_name localhost; server_name localhost 127.0.0.3;
ssl_certificate /etc/nginx/nginx.crt; ssl_certificate /etc/nginx/nginx.crt;
ssl_certificate_key /etc/nginx/nginx.key; ssl_certificate_key /etc/nginx/nginx.key;
location /dns-query { # Ensure CORS headers are present even when nginx generates 5xx responses.
proxy_pass http://dns; add_header 'Access-Control-Allow-Origin' '*' always;
# allow any origin add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always;
add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Headers' 'Accept, Content-Type, Origin, User-Agent' always;
add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS'; 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 - ./dev/zone.json:/dns/zone.json
expose: expose:
- 8053 - 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 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. 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 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. server is used to direct the frontend to the correct backend instance.

View file

@ -1,15 +1,8 @@
<template> <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> </template>
<style scoped>
img {
max-width: 190px;
height: 107px;
object-fit: contain;
}
</style>
<script> <script>
import {mapActions, mapGetters} from "vuex"; import {mapActions, mapGetters} from "vuex";
@ -18,36 +11,102 @@ export default {
props: { props: {
src: { src: {
type: String, type: String,
required: true required: false,
default: null
}, },
owner: { owner: {
type: String, type: String,
required: true 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() { data() {
return { return {
image_data: "", image_data: "",
servers: [] servers: [],
lastRequestId: 0
} }
}, },
computed: { computed: {
...mapGetters(["signAuth"]) ...mapGetters(["signAuth"])
}, },
methods: { methods: {
...mapActions(["getFriendServers"]) ...mapActions(["getFriendServers"]),
}, async loadImage() {
async mounted() { const requestId = ++this.lastRequestId;
if (!this.src) {
if (requestId === this.lastRequestId) {
this.image_data = this.fallbackSrc;
}
return;
}
try { try {
this.servers = await this.getFriendServers({username: this.owner}); this.servers = await this.getFriendServers({username: this.owner});
const response = await this.servers.getRaw(this.signAuth, this.src); 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()) const base64 = btoa(new Uint8Array(await response.arrayBuffer())
.reduce((data, byte) => data + String.fromCharCode(byte), "")); .reduce((data, byte) => data + String.fromCharCode(byte), ""));
if (requestId === this.lastRequestId) {
this.image_data = "data:" + mime_type + ";base64," + base64; 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> </script>

View file

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

View file

@ -1,9 +1,9 @@
<template> <template>
<div class="d-inline-block"> <div class="d-inline-block">
<label for="files"> <label :for="inputId">
<slot></slot> <slot></slot>
</label> </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> </div>
</template> </template>
@ -19,9 +19,17 @@ export default {
...BIcons ...BIcons
}, },
emits: ["input"], emits: ["input"],
data() {
return {
inputId: `files-${this._uid}`
};
},
methods: { methods: {
loadFiles() { loadFiles() {
const files = document.getElementById("files").files; const files = this.$refs.fileInput?.files;
if (!files || files.length === 0) {
return;
}
const jobs = [...files].map((file) => { const jobs = [...files].map((file) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
var reader = new FileReader(); var reader = new FileReader();
@ -52,6 +60,10 @@ export default {
}); });
Promise.all(jobs).then((files) => { Promise.all(jobs).then((files) => {
this.$emit("input", 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 Email from '@/views/settings/Email.vue';
import Notifications from '@/views/settings/Notifications.vue'; import Notifications from '@/views/settings/Notifications.vue';
import Data from '@/views/settings/Data.vue'; import Data from '@/views/settings/Data.vue';
import Preferences from '@/views/settings/Preferences.vue';
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { 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: 'notifications/', name: 'notifications', component: Notifications, meta: {requiresAuth: true}
}, { }, {
path: 'data/', name: 'data', component: Data, meta: {requiresAuth: true} path: 'data/', name: 'data', component: Data, meta: {requiresAuth: true}
}, {
path: 'preferences/', name: 'preferences', component: Preferences, meta: {requiresAuth: true}
}] }]
}, { }, {
path: '/storage-location', path: '/storage-location',

View file

@ -7,12 +7,65 @@ import {parseIdentityRecord, serializeIdentityRecord} from "@/identity";
//import sharedStatePlugin from "@/../extras/shared-state-plugin"; //import sharedStatePlugin from "@/../extras/shared-state-plugin";
//import persistentStatePlugin from "@/../extras/persistent-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({ export default createStore({
state: { state: {
local_loaded: false, local_loaded: false,
last_load: {}, last_load: {},
user: null, user: null,
user_profile: null,
token: null, token: null,
keypair: null, keypair: null,
remember: false, remember: false,
@ -31,6 +84,10 @@ export default createStore({
domains: [], domains: [],
storage_locations: [], storage_locations: [],
active_workflows: [], active_workflows: [],
preferenceDefinitions: [],
accountPreferences: {},
devicePreferences: {},
preferencesLoaded: false,
}, },
mutations: { mutations: {
setInventoryItems(state, {url, items}) { setInventoryItems(state, {url, items}) {
@ -69,11 +126,42 @@ export default createStore({
setActiveWorkflows(state, workflows) { setActiveWorkflows(state, workflows) {
state.active_workflows = 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) { setUser(state, user) {
state.user = user; state.user = user;
if (state.remember) if (state.remember)
localStorage.setItem('user', user); localStorage.setItem('user', user);
}, },
setUserProfile(state, profile) {
state.user_profile = profile;
},
setToken(state, token) { setToken(state, token) {
state.token = token; state.token = token;
if (state.remember) if (state.remember)
@ -110,6 +198,7 @@ export default createStore({
}, },
logout(state) { logout(state) {
state.user = null; state.user = null;
state.user_profile = null;
state.token = null; state.token = null;
state.keypair = null; state.keypair = null;
localStorage.removeItem('user'); localStorage.removeItem('user');
@ -136,6 +225,33 @@ export default createStore({
} }
}, },
actions: { 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}) { async login({commit, dispatch, state, getters}, {username, password, remember}) {
commit('setRemember', remember); commit('setRemember', remember);
const data = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) 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); commit('setKey', data.key);
const s = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors)) const s = await dispatch('lookupServer', {username}).then(servers => new ServerSet(servers, state.unreachable_neighbors))
commit('setHomeServers', s) commit('setHomeServers', s)
await dispatch('fetchUserProfile', {force: true})
return true; return true;
} else { } else {
return false; 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}) { async lookupServer({state}, {username}) {
const domain = username.split('@')[1] const domain = username.split('@')[1]
const request = '_toolshed-server._tcp.' + domain + '.' const request = '_toolshed-server._tcp.' + domain + '.'
@ -510,5 +648,18 @@ export default createStore({
time: Date.now() - 1000 * 60 * 60 * 24 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: { computed: {
...mapGetters(["inventory_items", "loaded_items"]), ...mapGetters(["inventory_items", "loaded_items"]),
...mapState(["user"]), ...mapState(["user", "storage_locations"]),
}, },
methods: { methods: {
...mapActions(["fetchInventoryItems", "deleteInventoryItem"]), ...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations"]),
only_images(files) { only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/")); 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() { async mounted() {
await this.fetchInventoryItems() await this.fetchInventoryItems()
await this.fetchStorageLocations()
} }
} }
</script> </script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,13 +23,18 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="text-center"> <div class="text-center">
<img alt="Charles Hall" <authenticated-image :src="profilePictureSrc" :owner="user"
src="/assets/img/avatars/avatar.png" :refresh-key="profilePictureRefreshKey"
class="rounded-circle img-responsive mt-2" width="128" :alt="user" :title="user"
height="128"/> img-class="rounded-circle img-responsive mt-2"
:width="128" :height="128" fit="cover"/>
<div class="mt-2"> <div class="mt-2">
<span class="btn btn-primary"><i <fs-file-source @input="uploadPicture">
class="fas fa-upload"></i> Upload</span> <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> </div>
<small>For best results, use an image at least 128px by <small>For best results, use an image at least 128px by
128px in .jpg format</small> 128px in .jpg format</small>
@ -99,10 +104,38 @@
</template> </template>
<script> <script>
import {mapActions, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import FsFileSource from "@/components/inputs/FsFileSource.vue";
export default { export default {
name: 'Account', 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> </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>