stash
This commit is contained in:
parent
3b494dfa37
commit
0f51f6e33f
14 changed files with 534 additions and 356 deletions
|
|
@ -30,14 +30,20 @@ export default {
|
|||
const jobs = [...files].map((file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
var reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
reader.onload = async () => {
|
||||
const buffer = reader.result;
|
||||
if (!(buffer instanceof ArrayBuffer)) {
|
||||
console.log(buffer)
|
||||
reject("Not an ArrayBuffer");
|
||||
return;
|
||||
}
|
||||
const data = new Uint8Array(buffer);
|
||||
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), "");
|
||||
// SHA-256 via Web Crypto - must match the backend's own content hash
|
||||
// (files/models.py, hashlib.sha256) so a hash computed here can later be
|
||||
// used to identify the same File row server-side without a mismatch.
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
var base64 = btoa(
|
||||
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -53,14 +53,20 @@ export default {
|
|||
return new Promise((resolve, reject) => {
|
||||
let reader = new FileReader();
|
||||
reader.readAsArrayBuffer(file)
|
||||
reader.onloadend = () => {
|
||||
reader.onloadend = async () => {
|
||||
const buffer = reader.result;
|
||||
if (!(buffer instanceof ArrayBuffer)) {
|
||||
console.log(buffer)
|
||||
reject("Not an ArrayBuffer");
|
||||
return;
|
||||
}
|
||||
const data = new Uint8Array(buffer);
|
||||
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), "");
|
||||
// SHA-256 via Web Crypto - must match the backend's own content hash
|
||||
// (files/models.py, hashlib.sha256) so a hash computed here can later
|
||||
// be used to identify the same File row server-side without a mismatch.
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
var base64 = btoa(
|
||||
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -33,14 +33,20 @@ export default {
|
|||
const jobs = [...files].map((file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
var reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
reader.onload = async () => {
|
||||
const buffer = reader.result;
|
||||
if (!(buffer instanceof ArrayBuffer)) {
|
||||
console.log(buffer)
|
||||
reject("Not an ArrayBuffer");
|
||||
return;
|
||||
}
|
||||
const data = new Uint8Array(buffer);
|
||||
const hash = nacl.crypto_hash(data).reduce((a, b) => a + b.toString(16).padStart(2, "0"), "");
|
||||
// SHA-256 via Web Crypto - must match the backend's own content hash
|
||||
// (files/models.py, hashlib.sha256) so a hash computed here can later be
|
||||
// used to identify the same File row server-side without a mismatch.
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
var base64 = btoa(
|
||||
data.reduce((a, b) => a + String.fromCharCode(b), '')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -171,11 +171,17 @@ export default {
|
|||
this.dataImage = undefined;
|
||||
this.open();
|
||||
},
|
||||
save() {
|
||||
async save() {
|
||||
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"), "");
|
||||
// SHA-256 via Web Crypto - must match the backend's own content hash (files/models.py,
|
||||
// hashlib.sha256) so a hash computed here can later be used to identify the same File
|
||||
// row server-side without a mismatch.
|
||||
const bytes = Uint8Array.from(raw_data, c => c.charCodeAt(0));
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', bytes);
|
||||
const hash = Array.from(new Uint8Array(hashBuffer))
|
||||
.map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
const image = {
|
||||
name: hash.slice(0, 12) + ".jpg",
|
||||
size: raw_data.length,
|
||||
|
|
|
|||
|
|
@ -3,127 +3,35 @@
|
|||
<!-- Step 1: Photo Capture -->
|
||||
<div v-if="step === '1'" class="foto-first-step-1">
|
||||
|
||||
<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="loadingCamera">
|
||||
<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="loadingCamera">
|
||||
<b-icon-upload class="me-1"></b-icon-upload>
|
||||
Upload Photos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<drag-drop-file-source @input="addFiles">
|
||||
<ul>
|
||||
<li v-for="file in without_images(staged_files)" :key="file.id">
|
||||
{{ file.name }}
|
||||
</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<div style="position: relative;">
|
||||
<div class="image-list">
|
||||
<deletable-wrapper v-for="file in only_images(staged_files).filter(file => file.owner)"
|
||||
:key="file.id"
|
||||
@delete="deleteFile(file)">
|
||||
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
|
||||
</deletable-wrapper>
|
||||
<deletable-wrapper v-for="file in only_images(staged_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">
|
||||
<div class="img-thumbnail btn btn-outline-primary">
|
||||
<b-icon-upload></b-icon-upload>
|
||||
</div>
|
||||
<div class="staging-area mb-4">
|
||||
<drag-drop-file-source @input="addStagedFiles">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<b-icon-upload class="text-primary mb-2" style="font-size: 2.5rem;"></b-icon-upload>
|
||||
<p class="text-muted small mb-3">Drag and drop photos here, or add them below</p>
|
||||
<div class="d-flex justify-content-center gap-2">
|
||||
<fs-file-source @input="addStagedFiles">
|
||||
<span class="btn btn-outline-success">
|
||||
<b-icon-upload class="me-1"></b-icon-upload>
|
||||
Upload Files
|
||||
</span>
|
||||
</fs-file-source>
|
||||
<camera-file-source @input="addFiles">
|
||||
<div class="img-thumbnail btn btn-outline-primary">
|
||||
<b-icon-camera></b-icon-camera>
|
||||
</div>
|
||||
<camera-file-source @input="addStagedFiles">
|
||||
<span class="btn btn-outline-primary">
|
||||
<b-icon-camera class="me-1"></b-icon-camera>
|
||||
Camera
|
||||
</span>
|
||||
</camera-file-source>
|
||||
<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 @input="addStagedFiles">
|
||||
<span class="btn btn-outline-primary">
|
||||
<b-icon-camera-video class="me-1"></b-icon-camera-video>
|
||||
Webcam
|
||||
</span>
|
||||
</webcam-file-source>
|
||||
<label class="img-thumbnail btn btn-outline-primary" for="file-dropdown">
|
||||
<b-icon-plus></b-icon-plus>
|
||||
</label>
|
||||
</div>
|
||||
<input type="checkbox" id="file-dropdown" class="invisible-input">
|
||||
<div class="dropdown-menu" v-if="only_images([]).length > 0">
|
||||
<div class="image-list">
|
||||
<span v-for="file in only_images([])" :key="file.id" @click="addExistingFiles([file])"
|
||||
style="cursor: pointer;">
|
||||
<authenticated-image :src="file.name" :owner="file.owner" class="img-thumbnail"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</drag-drop-file-source>
|
||||
</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>
|
||||
</drag-drop-file-source>
|
||||
</div>
|
||||
|
||||
<!-- Photo Gallery -->
|
||||
|
|
@ -138,7 +46,15 @@
|
|||
<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="photo-thumb-wrap">
|
||||
<transition name="photo-wipe">
|
||||
<img v-if="photo.dataUrl && !photo.uploaded" key="local" :src="photo.dataUrl"
|
||||
class="card-img-top photo-thumbnail" :alt="`Photo ${index + 1}`">
|
||||
<authenticated-image v-else key="remote" :src="thumbnailPathForHash(photo.hash)"
|
||||
:owner="user" img-class="card-img-top photo-thumbnail"
|
||||
:alt="`Photo ${index + 1}`"/>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">Photo {{ index + 1 }}</small>
|
||||
|
|
@ -158,7 +74,7 @@
|
|||
<button
|
||||
class="btn btn-primary"
|
||||
@click="proceedFromStep1"
|
||||
:disabled="photos.length === 0 || loadingCamera"
|
||||
:disabled="photos.length === 0"
|
||||
>
|
||||
Next: Process Images
|
||||
<b-icon-chevron-right class="ms-1"></b-icon-chevron-right>
|
||||
|
|
@ -353,7 +269,7 @@
|
|||
<!-- Image Preview -->
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<img :src="currentItem.processedUrl || currentItem.preview" class="card-img-top item-image"
|
||||
<img :src="currentItem.processedUrl || currentItem.dataUrl" class="card-img-top item-image"
|
||||
alt="Current item">
|
||||
<div class="card-body p-2">
|
||||
<small class="text-muted">{{ currentItem.name }}</small>
|
||||
|
|
@ -534,7 +450,7 @@
|
|||
<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"
|
||||
<img :src="item.image.processedUrl || item.image.dataUrl"
|
||||
class="completed-item-thumb me-2" alt="Item">
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-bold small">{{ item.details.name }}</div>
|
||||
|
|
@ -724,7 +640,7 @@
|
|||
<div v-for="(item, index) in completedItems" :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"
|
||||
<img :src="item.image.processedUrl || item.image.dataUrl"
|
||||
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>
|
||||
|
|
@ -756,7 +672,7 @@
|
|||
<tbody>
|
||||
<tr v-for="(item, index) in completedItems" :key="index">
|
||||
<td>
|
||||
<img :src="item.image.processedUrl || item.image.preview"
|
||||
<img :src="item.image.processedUrl || item.image.dataUrl"
|
||||
class="list-item-thumb" :alt="item.details.name">
|
||||
</td>
|
||||
<td class="fw-bold">{{ item.details.name }}</td>
|
||||
|
|
@ -823,7 +739,6 @@
|
|||
import * as BIcons from "bootstrap-icons-vue";
|
||||
import {mapActions, mapState} from "vuex";
|
||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||
import DeletableWrapper from "@/components/DeletableWrapper.vue";
|
||||
import DragDropFileSource from "@/components/inputs/DragDropFileSource.vue";
|
||||
import CameraFileSource from "@/components/inputs/CameraFileSource.vue";
|
||||
import FsFileSource from "@/components/inputs/FsFileSource.vue";
|
||||
|
|
@ -846,7 +761,6 @@ export default {
|
|||
],
|
||||
getInitialPayload() {
|
||||
return {
|
||||
photos: [],
|
||||
processing_options: {
|
||||
auto_rotate: true,
|
||||
compress: true,
|
||||
|
|
@ -859,7 +773,6 @@ export default {
|
|||
components: {
|
||||
WebcamFileSource,
|
||||
AuthenticatedImage,
|
||||
DeletableWrapper,
|
||||
DragDropFileSource,
|
||||
CameraFileSource,
|
||||
FsFileSource,
|
||||
|
|
@ -881,13 +794,8 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
staged_files: [],
|
||||
// Step 1: photo capture
|
||||
loadingCamera: false,
|
||||
showCamera: false,
|
||||
cameraReady: false,
|
||||
photos: [],
|
||||
stream: null,
|
||||
|
||||
// Step 2: image processing
|
||||
processing: false,
|
||||
|
|
@ -919,6 +827,7 @@ export default {
|
|||
}
|
||||
},
|
||||
computed: {
|
||||
...mapState(['user']),
|
||||
// Step 1/2
|
||||
totalPhotos() {
|
||||
return this.photos.length;
|
||||
|
|
@ -972,7 +881,6 @@ export default {
|
|||
this.loadFromPayload();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopCamera();
|
||||
this.processedImages.forEach(image => {
|
||||
if (image.processedUrl && image.processedUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(image.processedUrl);
|
||||
|
|
@ -980,9 +888,22 @@ export default {
|
|||
});
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['stageFile']),
|
||||
...mapActions(['stageFile', 'unstageFile']),
|
||||
loadFromPayload() {
|
||||
if (this.payload.photos) this.photos = [...this.payload.photos];
|
||||
// `photos`' durable state is the WorkflowInstance.staged_files relation itself (kept
|
||||
// in sync directly by stageFile()/unstageFile(), not by writing to payload) - so it's
|
||||
// seeded from the prop, not from payload. Entries restored this way have no local
|
||||
// bytes yet (this session never uploaded them), so `dataUrl` stays null - the gallery
|
||||
// falls back to fetching a thumbnail by hash via AuthenticatedImage (see below).
|
||||
this.photos = (this.workflowInstance.staged_files || []).map(hash => ({
|
||||
hash,
|
||||
name: null,
|
||||
size: null,
|
||||
mime_type: null,
|
||||
dataUrl: null,
|
||||
uploaded: true,
|
||||
timestamp: null
|
||||
}));
|
||||
if (this.payload.processing_options) {
|
||||
this.processingOptions = {...this.processingOptions, ...this.payload.processing_options};
|
||||
}
|
||||
|
|
@ -997,92 +918,78 @@ export default {
|
|||
},
|
||||
|
||||
// --- Step 1: Photo capture ---
|
||||
async startCamera() {
|
||||
try {
|
||||
this.loadingCamera = 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.loadingCamera = false;
|
||||
}
|
||||
thumbnailPathForHash(hash, size = 256) {
|
||||
// files/media_urls.py's thumbnail_urls generates (and disk-caches) a resized JPEG
|
||||
// on first request - a gallery card only needs a small image, not the full-size
|
||||
// original. Looked up by the derived storage path, mirroring hash_upload()
|
||||
// (files/models.py) - matches how FileSerializer.name already builds file URLs
|
||||
// elsewhere in the app (e.g. AuthenticatedImage's `src` for item files).
|
||||
return `/media/${size}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash.slice(4, 6)}/${hash.slice(6)}/`;
|
||||
},
|
||||
|
||||
stopCamera() {
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
this.stream = null;
|
||||
}
|
||||
this.showCamera = false;
|
||||
this.cameraReady = false;
|
||||
},
|
||||
async addStagedFiles(files) {
|
||||
const new_files = files.filter(file => !this.photos.find(photo => photo.hash === file.hash));
|
||||
if (new_files.length === 0) return;
|
||||
|
||||
capturePhoto() {
|
||||
if (!this.cameraReady) return;
|
||||
const staged = new_files.map(file => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime_type: file.mime_type,
|
||||
hash: file.hash, // SHA-256, same algorithm the backend hashes File content with
|
||||
data: file.data,
|
||||
dataUrl: `data:${file.mime_type};base64,${file.data}`,
|
||||
uploaded: false,
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
this.photos.push(...staged);
|
||||
|
||||
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.updatePhotosPayload();
|
||||
}, '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);
|
||||
// Persist each photo server-side right away, keyed to this workflow instance, so it
|
||||
// survives a reload or a switch to another device. WorkflowInstance.staged_files is
|
||||
// the durable record of this - nothing about photos needs to go into payload too.
|
||||
await Promise.all(staged.map(async ({hash, data, mime_type}) => {
|
||||
try {
|
||||
await this.stageFile({
|
||||
lifetime_id: this.workflowInstance.id,
|
||||
file: {data, mime_type}
|
||||
});
|
||||
// Once persisted, the gallery can show the server-fetched thumbnail instead
|
||||
// of the local dataUrl (kept around for step 2's client-side processing).
|
||||
// Re-lookup by hash rather than mutating the closed-over `photo` object -
|
||||
// that reference predates this.photos.push() above, so it's the raw object,
|
||||
// not the reactive proxy Vue tracks; writing to it wouldn't trigger a
|
||||
// re-render.
|
||||
const photo = this.photos.find(p => p.hash === hash);
|
||||
if (photo) photo.uploaded = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to stage photo:', error);
|
||||
this.photos = this.photos.filter(p => p.hash !== hash);
|
||||
}
|
||||
});
|
||||
this.updatePhotosPayload();
|
||||
event.target.value = '';
|
||||
}));
|
||||
},
|
||||
|
||||
removePhoto(index) {
|
||||
URL.revokeObjectURL(this.photos[index].preview);
|
||||
this.photos.splice(index, 1);
|
||||
this.updatePhotosPayload();
|
||||
},
|
||||
|
||||
clearAllPhotos() {
|
||||
if (confirm('Are you sure you want to remove all photos?')) {
|
||||
this.photos.forEach(photo => URL.revokeObjectURL(photo.preview));
|
||||
this.photos = [];
|
||||
this.updatePhotosPayload();
|
||||
async removePhoto(index) {
|
||||
const [photo] = this.photos.splice(index, 1);
|
||||
if (photo) {
|
||||
try {
|
||||
await this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash});
|
||||
} catch (error) {
|
||||
console.error('Failed to unstage photo:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updatePhotosPayload() {
|
||||
this.$emit('update', {photos: this.photos});
|
||||
async clearAllPhotos() {
|
||||
if (confirm('Are you sure you want to remove all photos?')) {
|
||||
const removed = this.photos;
|
||||
this.photos = [];
|
||||
await Promise.all(removed.map(photo =>
|
||||
this.unstageFile({lifetime_id: this.workflowInstance.id, file_hash: photo.hash})
|
||||
.catch(error => console.error('Failed to unstage photo:', error))
|
||||
));
|
||||
}
|
||||
},
|
||||
|
||||
proceedFromStep1() {
|
||||
this.updatePhotosPayload();
|
||||
this.$emit('next');
|
||||
},
|
||||
|
||||
|
|
@ -1144,7 +1051,7 @@ export default {
|
|||
canvas.toBlob(blob => {
|
||||
const processedImage = {
|
||||
name: photo.name,
|
||||
originalSize: photo.file.size,
|
||||
originalSize: photo.size,
|
||||
processedSize: blob.size,
|
||||
processedUrl: URL.createObjectURL(blob),
|
||||
processedFile: blob,
|
||||
|
|
@ -1153,7 +1060,7 @@ export default {
|
|||
resolve(processedImage);
|
||||
}, 'image/jpeg', this.processingOptions.compress ? 0.8 : 0.95);
|
||||
};
|
||||
img.src = photo.preview;
|
||||
img.src = photo.dataUrl;
|
||||
});
|
||||
},
|
||||
|
||||
|
|
@ -1335,84 +1242,65 @@ export default {
|
|||
category_breakdown: this.categoryBreakdown
|
||||
}
|
||||
});
|
||||
},
|
||||
async uploadFiles(files) {
|
||||
const jobs = files.map(async file => {
|
||||
return await this.stageFile({
|
||||
file: file,
|
||||
item_id: this.item_id
|
||||
});
|
||||
});
|
||||
return await Promise.all(jobs);
|
||||
},
|
||||
addFiles(files) {
|
||||
console.log("add files", files);
|
||||
const new_files = files.filter(file => !this.staged_files.find(f => f.hash === file.hash));
|
||||
if (new_files.length === 0) {
|
||||
console.log("no new files");
|
||||
return;
|
||||
}
|
||||
if (!this.create) {
|
||||
this.uploadFiles(new_files).then((uploaded) => {
|
||||
this.$emit("change", [...this.staged_files, ...uploaded]);
|
||||
})
|
||||
} else {
|
||||
this.$emit("change", [...this.staged_files, ...new_files]);
|
||||
}
|
||||
|
||||
},
|
||||
addExistingFiles(files) {
|
||||
console.log("add existing files", files);
|
||||
const new_files = files.filter(file => !this.staged_files.find(f => f.id === file.id));
|
||||
if (new_files.length === 0) {
|
||||
console.log("no new files");
|
||||
return;
|
||||
}
|
||||
this.$emit("change", [...this.staged_files, ...new_files]);
|
||||
},
|
||||
deleteFile(file) {
|
||||
this.deleteItemFile({item_id: this.item_id, file_id: file.id}).then(() => {
|
||||
this.$emit("change", this.staged_files.filter(f => f.id !== file.id));
|
||||
});
|
||||
},
|
||||
deleteTempFile(file) {
|
||||
this.$emit("change", this.staged_files.filter(f => f.hash !== file.hash));
|
||||
},
|
||||
only_images(files) {
|
||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||
},
|
||||
without_images(files) {
|
||||
return files.filter(file => !file.mime_type.startsWith("image/"));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.camera-preview {
|
||||
max-width: 100%;
|
||||
max-height: 400px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.photo-thumbnail,
|
||||
.item-thumb {
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.upload-area .card {
|
||||
/* Stacks the local dataUrl preview and the server-fetched AuthenticatedImage on top of each
|
||||
other during their crossfade, instead of one disappearing before the other lays out. */
|
||||
.photo-thumb-wrap {
|
||||
position: relative;
|
||||
height: 150px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-thumb-wrap .photo-thumbnail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.photo-wipe-enter-active,
|
||||
.photo-wipe-leave-active {
|
||||
transition: clip-path 0.5s ease, opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.photo-wipe-enter-from {
|
||||
clip-path: inset(0 100% 0 0);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.photo-wipe-enter-to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-wipe-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.photo-wipe-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.staging-area .card {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.upload-area .card:hover {
|
||||
.staging-area .card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.camera-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.processed-thumbnail {
|
||||
height: 120px;
|
||||
object-fit: cover;
|
||||
|
|
@ -1464,45 +1352,4 @@ export default {
|
|||
border: 2px solid #28a745;
|
||||
background: linear-gradient(135deg, #f8fff8 0%, #e8f5e8 100%);
|
||||
}
|
||||
|
||||
.img-thumbnail {
|
||||
width: 95px;
|
||||
height: 54px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.img-thumbnail svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.invisible-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#file-dropdown:checked ~ .dropdown-menu {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dropdown-menu:hover {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#file-dropdown:checked ~ label {
|
||||
color: #fff;
|
||||
background-color: var(--bs-primary);
|
||||
border-color: var(--bs-primary);
|
||||
}
|
||||
|
||||
#file-dropdown:checked ~ label:hover {
|
||||
color: var(--bs-primary);
|
||||
background-color: initial;
|
||||
}
|
||||
</style>
|
||||
|
||||
</style>
|
||||
|
|
@ -491,9 +491,20 @@ export default createStore({
|
|||
async stageFile({state, dispatch, getters}, {lifetime_id, file}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.post(getters.signAuth, '/api/staged_files/' + lifetime_id + '/', file)
|
||||
if (data.hash) {
|
||||
return data.hash
|
||||
}
|
||||
},
|
||||
async unstageFile({state, dispatch, getters}, {lifetime_id, file_hash}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
await servers.delete(getters.signAuth, '/api/staged_files/' + lifetime_id + '/' + file_hash + '/')
|
||||
},
|
||||
async commitStagedFile({state, dispatch, getters}, {item_id, file_hash}) {
|
||||
const servers = await dispatch('getHomeServers')
|
||||
const data = await servers.post(getters.signAuth, '/api/item_files/' + item_id + '/', {file_hash})
|
||||
if (data.name) {
|
||||
data.owner = state.user
|
||||
//state.files.push(data)
|
||||
state.files.push(data)
|
||||
return data
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue