This commit is contained in:
j3d1 2023-11-29 23:42:04 +01:00
parent fa7d5f40a9
commit a1da771507
12 changed files with 476 additions and 38 deletions

View file

@ -1,5 +1,5 @@
<template> <template>
<img :src="image_data" :alt="owner + ':' + src"/> <img :src="image_data" :title="owner + ':' + src"/>
</template> </template>
<style scoped> <style scoped>

View file

@ -12,10 +12,16 @@
</ul> </ul>
<hr> <hr>
<div style="position: relative;"> <div style="position: relative;">
<authenticated-image v-for="file in only_images(item_files).filter(file => file.owner)" :key="file.id" <div class="image-list">
:owner="file.owner" :src="file.name" class="img-thumbnail"/> <deletable-wrapper v-for="file in only_images(item_files).filter(file => file.owner)" :key="file.id"
<img v-for="file in only_images(item_files).filter(file => file.data)" :key="file.id" :alt="file.name" @delete="deleteFile(file)">
:src="'data:' + file.mime_type + ';base64,' + file.data" class="img-thumbnail border-info"> <authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
</deletable-wrapper>
<deletable-wrapper v-for="file in only_images(item_files).filter(file => file.data)" :key="file.id"
@delete="deleteTempFile(file)">
<img :alt="file.name" :src="'data:' + file.mime_type + ';base64,' + file.data"
class="img-thumbnail border-info">
</deletable-wrapper>
<fs-file-source @input="addFiles"> <fs-file-source @input="addFiles">
<div class="img-thumbnail btn btn-outline-primary"> <div class="img-thumbnail btn btn-outline-primary">
<b-icon-upload></b-icon-upload> <b-icon-upload></b-icon-upload>
@ -26,14 +32,28 @@
<b-icon-camera></b-icon-camera> <b-icon-camera></b-icon-camera>
</div> </div>
</camera-file-source> </camera-file-source>
<input type="checkbox" id="file-dropdown" class="invisible-input"> <webcam-file-source @input="addFiles">
<div class="img-thumbnail btn btn-outline-primary">
<b-icon-camera-video></b-icon-camera-video>
</div>
</webcam-file-source>
<webcam-file-source-legacy @input="addFiles">
<div class="img-thumbnail btn btn-outline-primary">
<b-icon-camera-video></b-icon-camera-video>
</div>
</webcam-file-source-legacy>
<label class="img-thumbnail btn btn-outline-primary" for="file-dropdown"> <label class="img-thumbnail btn btn-outline-primary" for="file-dropdown">
<b-icon-plus></b-icon-plus> <b-icon-plus></b-icon-plus>
</label> </label>
<div class="dropdown-menu"> </div>
<authenticated-image v-for="file in only_images(files)" :key="file.id" :src="file.name" <input type="checkbox" id="file-dropdown" class="invisible-input">
:owner="file.owner" <div class="dropdown-menu" v-if="only_images(files).length > 0">
class="img-thumbnail"/> <div class="image-list">
<span v-for="file in only_images(files)" :key="file.id" @click="addExistingFiles([file])"
style="cursor: pointer;">
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
</span>
</div>
</div> </div>
</div> </div>
</drag-drop-file-source> </drag-drop-file-source>
@ -51,6 +71,12 @@
height: 100%; height: 100%;
} }
.image-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.invisible-input { .invisible-input {
display: none; display: none;
} }
@ -59,11 +85,16 @@
display: block; display: block;
} }
.dropdown-menu:hover {
display: block;
}
#file-dropdown:checked ~ label { #file-dropdown:checked ~ label {
color: #fff; color: #fff;
background-color: var(--bs-primary); background-color: var(--bs-primary);
border-color: var(--bs-primary); border-color: var(--bs-primary);
} }
#file-dropdown:checked ~ label:hover { #file-dropdown:checked ~ label:hover {
color: var(--bs-primary); color: var(--bs-primary);
background-color: initial; background-color: initial;
@ -78,12 +109,18 @@ import DragDropFileSource from "@/components/DragDropFileSource.vue";
import FsFileSource from "@/components/FsFileSource.vue"; import FsFileSource from "@/components/FsFileSource.vue";
import CameraFileSource from "@/components/CameraFileSource.vue"; import CameraFileSource from "@/components/CameraFileSource.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue"; import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import DeletableWrapper from "@/components/DeletableWrapper.vue";
import WebcamFileSource from "@/components/WebcamFileSource.vue";
import WebcamFileSourceLegacy from "@/components/WebcamFileSourceLegacy.vue";
export default { export default {
name: "CombinedFileField", name: "CombinedFileField",
components: { components: {
WebcamFileSource,
WebcamFileSourceLegacy,
...BIcons, ...BIcons,
AuthenticatedImage, AuthenticatedImage,
DeletableWrapper,
DragDropFileSource, DragDropFileSource,
CameraFileSource, CameraFileSource,
FsFileSource FsFileSource
@ -106,7 +143,7 @@ export default {
...mapState(["files"]), ...mapState(["files"]),
}, },
methods: { methods: {
...mapActions(["fetchFiles", "pushFile"]), ...mapActions(["fetchFiles", "pushFile", "deleteItemFile"]),
async uploadFiles(files) { async uploadFiles(files) {
const jobs = files.map(async file => { const jobs = files.map(async file => {
return await this.pushFile({ return await this.pushFile({
@ -114,22 +151,41 @@ export default {
item_id: this.item_id item_id: this.item_id
}); });
}); });
const responses = await Promise.all(jobs); return await Promise.all(jobs);
return responses;
}, },
addFiles(files) { addFiles(files) {
const newfiles = files.filter(file => !this.item_files.find(f => f.hash === file.hash)); console.log("add files", files);
if (newfiles.length === 0) { const new_files = files.filter(file => !this.item_files.find(f => f.hash === file.hash));
if (new_files.length === 0) {
console.log("no new files"); console.log("no new files");
return; return;
} }
if (!this.create) { if (!this.create) {
this.uploadFiles(newfiles).then((uploaded) => { this.uploadFiles(new_files).then((uploaded) => {
this.$emit("change", [...this.item_files, ...uploaded]); this.$emit("change", [...this.item_files, ...uploaded]);
}) })
} else {
this.$emit("change", [...this.item_files, ...new_files]);
} }
}, },
addExistingFiles(files) {
console.log("add existing files", files);
const new_files = files.filter(file => !this.item_files.find(f => f.id === file.id));
if (new_files.length === 0) {
console.log("no new files");
return;
}
this.$emit("change", [...this.item_files, ...new_files]);
},
deleteFile(file) {
this.deleteItemFile({item_id: this.item_id, file_id: file.id}).then(() => {
this.$emit("change", this.item_files.filter(f => f.id !== file.id));
});
},
deleteTempFile(file) {
this.$emit("change", this.item_files.filter(f => f.hash !== file.hash));
},
only_images(files) { only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/")); return files.filter(file => file.mime_type.startsWith("image/"));
}, },

View file

@ -0,0 +1,36 @@
<template>
<span style="position: relative; display: inline-block;">
<div class="delete-button">
<button class="btn btn-danger btn-sm" @click="triggerDelete">
<b-icon-trash></b-icon-trash>
</button>
</div>
<slot></slot>
</span>
</template>
<style scoped>
.delete-button {
position: absolute;
top: 0;
right: 0;
z-index: 1;
}
</style>
<script>
import * as BIcons from "bootstrap-icons-vue";
export default {
name: "DeletableWrapper",
components: {
...BIcons
},
emits: ["delete"],
methods: {
triggerDelete() {
this.$emit("delete");
}
}
}
</script>

View file

@ -21,6 +21,12 @@
<span class="align-middle">Friends</span> <span class="align-middle">Friends</span>
</router-link> </router-link>
</li> </li>
<li class="sidebar-item">
<router-link to="/files" class="sidebar-link">
<b-icon-folder class="bi-valign-middle"></b-icon-folder>
<span class="align-middle">Files</span>
</router-link>
</li>
<li class="sidebar-item"> <li class="sidebar-item">
<router-link to="/admin" class="sidebar-link"> <router-link to="/admin" class="sidebar-link">
<b-icon-gear class="bi-valign-middle"></b-icon-gear> <b-icon-gear class="bi-valign-middle"></b-icon-gear>

View file

@ -0,0 +1,74 @@
<template>
<div class="d-inline-block">
<label @click="show_modal = true">
<slot></slot>
</label>
<div class="modal">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<select v-model="selectedCamera" v-on:change="openStream" class="form-select position-relative w-75 mx-auto mt-4" aria-label="Select Camera Source">
<option disabled value="">Select Camera Source</option>
<option v-for="camera in availableCameras" :key="camera.deviceId" :value="camera">
{{ camera.label }}
</option>
</select>
<video
v-if="capturing"
ref="video"
class="img-fluid rounded mx-auto d-block mb-3 img-preview w-100"
>
Video stream not available.
</video>
<canvas ref="canvas" class="img-fluid d-none img-preview"/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
</style>
<script>
export default {
name: "WebcamFileSource",
data: () => ({
show_modal: false,
availableCameras: [],
selectedCamera: undefined,
capturing: false,
streaming: false,
stream: undefined,
dataImage: undefined
}),
methods: {
async openStream() {
if (!this.capturing) {
this.capturing = true;
this.streaming = false;
this.stream = await navigator.mediaDevices.getUserMedia({video: {deviceId: this.selectedCamera.deviceId}, audio: false});
const {video} = this.$refs;
video.srcObject = this.stream;
video.play();
video.addEventListener('canplay', () => {
this.streaming = true;
}, false);
}
},
async open() {
await navigator.mediaDevices.getUserMedia({video: true});
const devices = await navigator.mediaDevices.enumerateDevices();
this.availableCameras = devices.filter(device => device.kind === "videoinput");
if (this.availableCameras.length === 0) return;
if (this.availableCameras.length === 1) {
this.selectedCamera = this.availableCameras[0];
await this.openStream();
}
}
},
mounted() {
this.open();
}
}
</script>

View file

@ -0,0 +1,163 @@
<template>
<div class="d-inline-block">
<label @click="show_modal = true">
<slot></slot>
</label>
<div class="modal" :class="{'d-block': show_modal}" tabindex="-1">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content">
<img
v-if="!capturing"
class="img-fluid rounded mx-auto d-block mb-3"
:src="dataImage"
alt="Image not available."
/>
<video
v-if="capturing"
ref="video"
class="img-fluid rounded mx-auto d-block mb-3"
>
Video stream not available.
</video>
<canvas ref="canvas" class="img-fluid d-none img-preview"/>
<div class="row" v-if="capturing && !streaming">
<div class="spinner-grow text-danger mx-auto" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<div class="row m-auto">
<button v-if="!capturing" class="btn my-2 ml-auto btn-secondary" @click="openStream()">
<b-icon-camera></b-icon-camera>
</button>
<div v-if="capturing" class="btn-group my-2 ml-auto">
<button class="btn btn-success" @click="captureVideoImage()">
<b-icon-camera></b-icon-camera>&nbsp;Capture
</button>
<button
class="btn"
:class="(dataImage) ? 'btn-danger' : 'btn-secondary disabled'"
@click="(dataImage) && closeStream()"
>
<b-icon-stop-fill></b-icon-stop-fill>&nbsp;Abort
</button>
</div>
<select v-model="selectedCamera" class="form-select" aria-label="Select Camera Source"
@change="chooseDevice(selectedCamera)">
<option disabled value="">Select Camera Source</option>
<option v-for="camera in availableCameras" :key="camera.deviceId" :value="camera">
{{ camera.label }}
</option>
</select>
</div>
</div> </div>
</div>
</div>
</template>
<script>
import {mapMutations} from 'vuex';
import * as BIcons from "bootstrap-icons-vue";
export default {
name: 'WebcamFileSourceLegacy',
components: {
...BIcons
},
emits: ["input"],
data: () => ({
capturing: false,
streaming: false,
stream: undefined,
dataImage: undefined,
show_modal: false,
availableCameras: [],
selectedCamera: undefined,
}),
methods: {
async openStream() {
if (!this.capturing) {
//await navigator.mediaDevices.getUserMedia({video: true});
this.capturing = true;
this.streaming = false;
if (this.selectedCamera)
this.stream = await navigator.mediaDevices.getUserMedia({
video: {deviceId: this.selectedCamera.deviceId},
audio: false
});
else
this.stream = await navigator.mediaDevices.getUserMedia({
video: {facingMode: "environment"},
audio: false
})
const {video} = this.$refs;
video.srcObject = this.stream;
video.addEventListener('canplay', () => {
this.streaming = true;
}, false);
await video.play();
const devices = await navigator.mediaDevices.enumerateDevices();
this.availableCameras = devices.filter(device => device.kind === "videoinput");
}
},
captureVideoImage() {
const {video, canvas} = this.$refs;
const context = canvas.getContext('2d');
const {videoWidth, videoHeight} = video;
canvas.width = videoWidth;
canvas.height = videoHeight;
context.drawImage(video, 0, 0, videoWidth, videoHeight);
this.dataImage = canvas.toDataURL('image/jpeg', 0.5);
const mimeType = this.dataImage.split(';')[0].split(':')[1];
const data = this.dataImage.split(',')[1];
const raw_data = atob(data);
const hash = nacl.crypto_hash(raw_data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), "");
const image = {
name: hash.slice(0, 12) + ".jpg",
size: raw_data.length,
mime_type: mimeType,
data: data,
hash: hash,
};
this.show_modal = false;
this.$emit('input', [image]);
this.closeStream();
},
chooseDevice(device) {
this.closeStream();
this.selectedCamera = device;
this.openStream();
},
closeStream() {
if (this.capturing) {
this.stream.getTracks().forEach(s => s.stop());
this.capturing = false;
this.streaming = false;
}
},
},
mounted() {
this.openStream();
},
beforeDestroy() {
this.closeStream();
}
};
</script>
<style>
.camera-modal {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, .5);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>

View file

@ -12,6 +12,7 @@ import InventoryDetail from '@/views/InventoryDetail.vue';
import InventoryNew from '@/views/InventoryNew.vue'; import InventoryNew from '@/views/InventoryNew.vue';
import InventoryEdit from '@/views/InventoryEdit.vue'; import InventoryEdit from '@/views/InventoryEdit.vue';
import Admin from '@/views/Admin.vue'; import Admin from '@/views/Admin.vue';
import Files from '@/views/Files.vue';
const routes = [ const routes = [
@ -21,6 +22,7 @@ const routes = [
{path: '/inventory/:id/edit', component: InventoryEdit, meta: {requiresAuth: true}, props: true}, {path: '/inventory/:id/edit', component: InventoryEdit, meta: {requiresAuth: true}, props: true},
{path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}},
{path: '/friends', component: Friends, meta: {requiresAuth: true}}, {path: '/friends', component: Friends, meta: {requiresAuth: true}},
{path: '/files', component: Files, meta: {requiresAuth: true}},
{path: '/admin', component: Admin, meta: {requiresAuth: true}}, {path: '/admin', component: Admin, meta: {requiresAuth: true}},
{path: '/search/:query', component: Search, meta: {requiresAuth: true}, props: true}, {path: '/search/:query', component: Search, meta: {requiresAuth: true}, props: true},
{path: '/login', component: Login, meta: {requiresAuth: false}}, {path: '/login', component: Login, meta: {requiresAuth: false}},

View file

@ -26,6 +26,7 @@ export default createStore({
categories: [], categories: [],
availability_policies: [], availability_policies: [],
domains: [], domains: [],
storage_locations: [],
}, },
mutations: { mutations: {
setUser(state, user) { setUser(state, user) {
@ -79,6 +80,9 @@ export default createStore({
setDomains(state, domains) { setDomains(state, domains) {
state.domains = domains; state.domains = domains;
}, },
setStorageLocations(state, storage_locations) {
state.storage_locations = storage_locations;
},
setFiles(state, files) { setFiles(state, files) {
state.files = files; state.files = files;
}, },
@ -279,6 +283,16 @@ export default createStore({
return data return data
} }
}, },
async deleteFile({state, dispatch, getters}, {id}) {
const servers = await dispatch('getHomeServers')
await servers.delete(getters.signAuth, '/api/files/' + id + '/')
state.files = state.files.filter(file => file.id !== id)
},
async deleteItemFile({state, dispatch, getters}, {item_id, file_id}) {
const servers = await dispatch('getHomeServers')
await servers.delete(getters.signAuth, '/api/item_files/' + item_id + '/' + file_id + '/')
state.files = state.files.filter(file => file.id !== file_id)
},
async fetchTags({state, commit, dispatch, getters}) { async fetchTags({state, commit, dispatch, getters}) {
if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) { if (state.last_load.tags > Date.now() - 1000 * 60 * 60 * 24) {
return state.tags return state.tags
@ -319,6 +333,16 @@ export default createStore({
state.last_load.availability_policies = Date.now() state.last_load.availability_policies = Date.now()
return data return data
}, },
async fetchStorageLocations({state, commit, dispatch, getters}) {
if (state.last_load.storage_locations > Date.now() - 1000 * 60 * 60 * 24) {
return state.storage_locations
}
const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/api/storage_locations/')
commit('setStorageLocations', data)
state.last_load.storage_locations = Date.now()
return data
},
async fetchInfo({state, commit, dispatch, getters}) { async fetchInfo({state, commit, dispatch, getters}) {
const last_load_info = Math.min( const last_load_info = Math.min(
state.last_load.tags, state.last_load.tags,

View file

@ -67,6 +67,18 @@
</ul> </ul>
</div> </div>
</div> </div>
<div class="card">
<div class="card-header">
<h5 class="card-title">Storage Locations</h5>
</div>
<div class="card-body">
<ul>
<li v-for="location in storage_locations.sort()" :key="location.id">
{{ location.path }}
</li>
</ul>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -86,13 +98,14 @@ export default {
...BIcons ...BIcons
}, },
computed: { computed: {
...mapState(["tags", "properties", "categories", "availability_policies", "domains"]), ...mapState(["tags", "properties", "categories", "availability_policies", "domains", "storage_locations"])
}, },
methods: { methods: {
...mapActions(["fetchInfo"]), ...mapActions(["fetchInfo", "fetchStorageLocations"])
}, },
async mounted() { async mounted() {
await this.fetchInfo(); await this.fetchInfo();
await this.fetchStorageLocations();
} }
} }
</script> </script>

View file

@ -0,0 +1,71 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Files</h1>
<div class="card">
<div class="card-header">
<h5 class="card-title">Images</h5>
</div>
<div class="card-body">
<span v-for="files in only_images(files)" :key="files.id">
<deletable-wrapper @delete="deleteFile(files)">
<authenticated-image :src="files.name" :owner="files.owner"
class="img-thumbnail"/>
</deletable-wrapper>
</span>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="card-title">Other files</h5>
</div>
<div class="card-body">
<ul>
<li v-for="file in whithout_images(files)" :key="file.id">
{{ file.name }}
</li>
</ul>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import {mapActions, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import DeletableWrapper from "@/components/DeletableWrapper.vue";
export default {
name: "Files",
components: {
AuthenticatedImage,
BaseLayout,
DeletableWrapper,
...BIcons
},
computed: {
...mapState(["files"])
},
methods: {
...mapActions(["fetchFiles", "deleteFile"]),
only_images(files) {
return files.filter(file => file.mime_type.startsWith("image/"));
},
whithout_images(files) {
return files.filter(file => !file.mime_type.startsWith("image/"));
}
},
mounted() {
this.fetchFiles();
}
}
</script>
<style scoped>
</style>

View file

@ -41,7 +41,7 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="image" class="form-label">Image</label> <label for="image" class="form-label">Image</label>
<combined-file-field :item_files="item.files" :item_id="item.id" @change="addFiles"></combined-file-field> <combined-file-field :item_files="item.files" :item_id="item.id" @change="changeFiles"></combined-file-field>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<button type="submit" class="btn btn-primary" style="width: 100%" <button type="submit" class="btn btn-primary" style="width: 100%"

View file

@ -6,13 +6,6 @@
<div class="card"> <div class="card">
<div class="card-header">Create New Item</div> <div class="card-header">Create New Item</div>
<div class="card-body"> <div class="card-body">
<div class="mb-3">
<ul>
<li v-for="file in item.files" :key="file.name">
{{ file.name }} ({{ file.hash }})
</li>
</ul>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Name</label> <label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" <input type="text" class="form-control" id="name" name="name"