Files
chore/web/vue-app/src/components/ImagePicker.vue
2025-11-25 16:08:10 -05:00

433 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, defineProps, defineEmits, computed } from 'vue'
import { getCachedImageUrl } from '../common/imageCache'
const props = defineProps<{
modelValue?: string | null // selected image id or local-upload
imageType?: number // 1 or 2, default 1
}>()
const emit = defineEmits(['update:modelValue', 'add-image'])
const fileInput = ref<HTMLInputElement | null>(null)
const localImageUrl = ref<string | null>(null)
const showCamera = ref(false)
const cameraStream = ref<MediaStream | null>(null)
const cameraVideo = ref<HTMLVideoElement | null>(null)
const cameraError = ref<string | null>(null)
const capturedImageUrl = ref<string | null>(null)
const cameraFile = ref<File | null>(null)
const availableImages = ref<{ id: string; url: string }[]>([])
const loadingImages = ref(false)
const typeParam = computed(() => props.imageType ?? 1)
const selectImage = (id: string | undefined) => {
if (!id) {
console.warn('selectImage called with null id')
return
}
emit('update:modelValue', id)
}
const addFromLocal = () => {
fileInput.value?.click()
}
const onFileChange = (event: Event) => {
const files = (event.target as HTMLInputElement).files
if (files && files.length > 0) {
const file = files[0]
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
const url = URL.createObjectURL(file)
localImageUrl.value = url
const idx = availableImages.value.findIndex((img) => img.id === 'local-upload')
if (idx === -1) {
availableImages.value.unshift({ id: 'local-upload', url }) // <-- use unshift
} else {
availableImages.value[idx].url = url
}
emit('add-image', { id: 'local-upload', url, file })
emit('update:modelValue', 'local-upload')
}
}
onBeforeUnmount(() => {
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
})
const addFromCamera = async () => {
cameraError.value = null
capturedImageUrl.value = null
showCamera.value = true
await nextTick()
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true })
cameraStream.value = stream
if (cameraVideo.value) {
cameraVideo.value.srcObject = stream
await cameraVideo.value.play()
}
} catch (err) {
cameraError.value = 'Unable to access camera'
cameraStream.value = null
}
}
const takePhoto = async () => {
if (!cameraVideo.value) return
const canvas = document.createElement('canvas')
canvas.width = cameraVideo.value.videoWidth
canvas.height = cameraVideo.value.videoHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(cameraVideo.value, 0, 0, canvas.width, canvas.height)
const dataUrl = canvas.toDataURL('image/png')
capturedImageUrl.value = dataUrl
}
}
const confirmPhoto = async () => {
if (capturedImageUrl.value) {
if (localImageUrl.value) URL.revokeObjectURL(localImageUrl.value)
const img = new window.Image()
img.src = capturedImageUrl.value
await new Promise((resolve) => {
img.onload = resolve
})
let { width, height } = img
const maxDim = 512
if (width > maxDim || height > maxDim) {
if (width > height) {
height = Math.round((height * maxDim) / width)
width = maxDim
} else {
width = Math.round((width * maxDim) / height)
height = maxDim
}
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
ctx?.drawImage(img, 0, 0, width, height)
const blob: Blob = await new Promise((resolve) =>
canvas.toBlob((b) => resolve(b!), 'image/png'),
)
const url = URL.createObjectURL(blob)
localImageUrl.value = url
cameraFile.value = new File([blob], 'camera.png', { type: 'image/png' })
const idx = availableImages.value.findIndex((img) => img.id === 'local-upload')
if (idx === -1) {
availableImages.value.unshift({ id: 'local-upload', url }) // <-- use unshift
} else {
availableImages.value[idx].url = url
}
emit('add-image', { id: 'local-upload', url, file: cameraFile.value })
emit('update:modelValue', 'local-upload')
}
closeCamera()
}
const retakePhoto = async () => {
capturedImageUrl.value = null
cameraFile.value = null
await resumeCameraStream()
}
const closeCamera = () => {
showCamera.value = false
capturedImageUrl.value = null
if (cameraStream.value) {
cameraStream.value.getTracks().forEach((track) => track.stop())
cameraStream.value = null
}
}
const resumeCameraStream = async () => {
await nextTick()
if (cameraVideo.value && cameraStream.value) {
cameraVideo.value.srcObject = cameraStream.value
try {
await cameraVideo.value.play()
} catch (e) {}
}
}
// Fetch images on mount
onMounted(async () => {
loadingImages.value = true
try {
const resp = await fetch(`/api/image/list?type=${typeParam.value}`)
if (resp.ok) {
const data = await resp.json()
const ids = data.ids || []
// Fetch URLs for each image id using the cache
const urls = await Promise.all(
ids.map(async (id: string) => {
try {
const url = await getCachedImageUrl(id)
return { id, url }
} catch {
return null
}
}),
)
const images = urls.filter(Boolean) as { id: string; url: string }[]
// Move the selected image to the front if it exists
if (props.modelValue) {
const idx = images.findIndex((img) => img.id === props.modelValue)
if (idx > 0) {
const [selected] = images.splice(idx, 1)
images.unshift(selected)
}
}
availableImages.value = images
}
} catch (err) {
// Optionally handle error
} finally {
loadingImages.value = false
}
})
</script>
<template>
<div>
<div class="image-scroll">
<div v-if="loadingImages" class="loading-images">Loading images...</div>
<div v-else class="image-list">
<img
v-for="img in availableImages"
:key="img.id"
:src="img.url"
class="selectable-image"
:class="{ selected: modelValue === img.id }"
:alt="`Image ${img.id}`"
@click="selectImage(img.id)"
/>
</div>
</div>
<input
ref="fileInput"
type="file"
accept=".png,.jpg,.jpeg,.gif,image/png,image/jpeg,image/gif"
style="display: none"
@change="onFileChange"
/>
<div class="image-actions">
<button type="button" class="icon-btn" @click="addFromLocal" aria-label="Add from device">
<span class="icon"></span>
</button>
<button type="button" class="icon-btn" @click="addFromCamera" aria-label="Add from camera">
<span class="icon">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<rect x="3" y="6" width="14" height="10" rx="2" stroke="#667eea" stroke-width="1.5" />
<circle cx="10" cy="11" r="3" stroke="#667eea" stroke-width="1.5" />
<rect x="7" y="3" width="6" height="3" rx="1" stroke="#667eea" stroke-width="1.5" />
</svg>
</span>
</button>
</div>
<!-- Camera modal -->
<div v-if="showCamera" class="modal-backdrop">
<div class="modal camera-modal">
<h3>Take a Photo</h3>
<div v-if="cameraError" class="camera-error">{{ cameraError }}</div>
<div v-else>
<div v-if="!capturedImageUrl">
<video ref="cameraVideo" autoplay playsinline class="camera-video"></video>
<div class="actions">
<button type="button" class="btn save" @click="takePhoto">Take Photo</button>
<button type="button" class="btn cancel" @click="closeCamera">Cancel</button>
</div>
</div>
<div v-else>
<img :src="capturedImageUrl" class="captured-preview" alt="Preview" />
<div class="actions">
<button type="button" class="btn save" @click="confirmPhoto">Use Photo</button>
<button type="button" class="btn cancel" @click="retakePhoto">Retake</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.image-scroll {
width: 100%;
margin: 0.7rem 0 0.2rem 0;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 0.2rem;
}
.image-list {
display: flex;
gap: 0.7rem;
min-width: min-content;
align-items: center;
}
.selectable-image {
width: 54px;
height: 54px;
object-fit: cover;
border-radius: 8px;
border: 2px solid #e6e6e6;
background: #fafbff;
cursor: pointer;
transition: border 0.18s;
}
.selectable-image:hover {
border-color: #667eea;
}
.selectable-image.selected {
border-color: #667eea;
box-shadow: 0 0 0 2px #667eea55;
}
.loading-images {
color: #888;
font-size: 0.98rem;
padding: 0.5rem 0;
text-align: center;
}
.image-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 0.5rem;
}
.icon-btn {
background: #f3f3f3;
border: none;
border-radius: 50%;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.18s;
font-size: 1.5rem;
color: #667eea;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.07);
}
.icon-btn:hover {
background: #e0e7ff;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
}
/* Camera modal styles */
.camera-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: #fff;
border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
z-index: 1300;
width: 380px;
max-width: calc(100vw - 32px);
padding-bottom: 1.5rem;
text-align: center;
}
.camera-header {
padding: 1rem;
border-bottom: 1px solid #e6e6e6;
display: flex;
justify-content: space-between;
align-items: center;
}
.camera-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.camera-error {
color: #ff4d4f;
margin-bottom: 1rem;
}
.camera-video {
width: 100%;
max-width: 320px;
border-radius: 12px;
background: #222;
margin-bottom: 1rem;
}
.camera-actions {
padding: 0.8rem 1rem;
display: flex;
justify-content: center;
gap: 1rem;
}
.btn.capture {
background: #667eea;
color: white;
flex: 1;
}
.btn.capture:hover {
background: #5a67d8;
}
.btn.confirm {
background: #28a745;
color: white;
flex: 1;
}
.btn.confirm:hover {
background: #218838;
}
.actions {
display: flex;
gap: 0.7rem;
justify-content: center;
margin-top: 0.5rem;
}
.btn {
padding: 0.5rem 1.1rem;
border-radius: 8px;
border: 0;
cursor: pointer;
font-weight: 700;
font-size: 1rem;
transition: background 0.18s;
}
.btn.cancel {
background: #f3f3f3;
color: #666;
}
.btn.save {
background: #667eea;
color: white;
}
.btn.save:hover {
background: #5a67d8;
}
.captured-preview {
width: auto;
max-width: 100%;
max-height: 240px;
border-radius: 12px;
margin-bottom: 1rem;
display: block;
margin-left: auto;
margin-right: auto;
object-fit: contain;
}
</style>