Files
notebrook-notes/frontend-vue/src/components/dialogs/CameraCaptureDialog.vue

530 lines
12 KiB
Vue

<template>
<div class="camera-capture-dialog">
<div class="camera-container">
<!-- Camera Feed -->
<div class="camera-feed" v-if="!capturedImage">
<video
ref="videoElement"
autoplay
playsinline
muted
:class="{ 'mirrored': isFrontCamera }"
></video>
<!-- Camera Controls Overlay -->
<div class="camera-overlay">
<div class="camera-info">
<div class="camera-status" :class="{ 'active': isStreaming }">
<Icon name="camera" />
<span v-if="isStreaming">Camera Active</span>
<span v-else>Camera Inactive</span>
</div>
</div>
<!-- Switch Camera Button -->
<BaseButton
v-if="availableCameras.length > 1"
@click="switchCamera"
variant="secondary"
size="sm"
class="switch-camera-btn"
:disabled="!isStreaming"
>
<Icon name="camera" />
Switch
</BaseButton>
</div>
</div>
<!-- Captured Image Preview -->
<div class="image-preview" v-if="capturedImage">
<img
:src="capturedImage"
alt="Captured photo"
class="captured-photo"
/>
</div>
<!-- Error Message -->
<div class="error-message" v-if="errorMessage">
<Icon name="warning" />
{{ errorMessage }}
</div>
<!-- Camera Permission Info -->
<div class="permission-info" v-if="!hasPermission && !errorMessage">
<Icon name="info" />
<p>Camera access is required to take photos. Please grant permission when prompted.</p>
</div>
</div>
<!-- Capture Controls -->
<div class="capture-controls">
<div class="capture-buttons" v-if="!capturedImage">
<BaseButton
@click="capturePhoto"
variant="primary"
size="lg"
:disabled="!isStreaming"
class="capture-btn"
>
<Icon name="camera" />
Take Photo
</BaseButton>
</div>
<div class="review-buttons" v-if="capturedImage">
<BaseButton
@click="retakePhoto"
variant="secondary"
>
<Icon name="camera" />
Retake
</BaseButton>
<BaseButton
@click="sendPhoto"
variant="primary"
:disabled="isSending"
:loading="isSending"
>
<Icon name="send" />
Send Photo
</BaseButton>
</div>
</div>
<!-- Dialog Actions -->
<div class="dialog-actions">
<BaseButton
@click="closeDialog"
variant="secondary"
>
Cancel
</BaseButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useAppStore } from '@/stores/app'
import { useToastStore } from '@/stores/toast'
import { apiService } from '@/services/api'
import BaseButton from '@/components/base/BaseButton.vue'
import Icon from '@/components/base/Icon.vue'
const emit = defineEmits<{
close: []
sent: []
}>()
const appStore = useAppStore()
const toastStore = useToastStore()
// Refs
const videoElement = ref<HTMLVideoElement>()
const capturedImage = ref<string>()
const isStreaming = ref(false)
const hasPermission = ref(false)
const isSending = ref(false)
const errorMessage = ref('')
const availableCameras = ref<MediaDeviceInfo[]>([])
const currentCameraIndex = ref(0)
const isFrontCamera = ref(true)
// Stream management
let currentStream: MediaStream | null = null
// Methods
const initializeCamera = async () => {
try {
errorMessage.value = ''
// Get available cameras
const devices = await navigator.mediaDevices.enumerateDevices()
availableCameras.value = devices.filter(device => device.kind === 'videoinput')
if (availableCameras.value.length === 0) {
throw new Error('No cameras found')
}
// Start with front camera if available
const frontCamera = availableCameras.value.find(camera =>
camera.label.toLowerCase().includes('front') ||
camera.label.toLowerCase().includes('user')
)
if (frontCamera) {
currentCameraIndex.value = availableCameras.value.indexOf(frontCamera)
isFrontCamera.value = true
} else {
currentCameraIndex.value = 0
isFrontCamera.value = false
}
await startCamera()
hasPermission.value = true
} catch (error) {
console.error('Failed to initialize camera:', error)
errorMessage.value = 'Failed to access camera. Please check permissions and try again.'
hasPermission.value = false
}
}
const startCamera = async () => {
try {
// Stop current stream if exists
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop())
}
const constraints: MediaStreamConstraints = {
video: {
deviceId: availableCameras.value[currentCameraIndex.value]?.deviceId,
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: isFrontCamera.value ? 'user' : 'environment'
}
}
currentStream = await navigator.mediaDevices.getUserMedia(constraints)
if (videoElement.value) {
videoElement.value.srcObject = currentStream
isStreaming.value = true
}
} catch (error) {
console.error('Failed to start camera:', error)
throw error
}
}
const switchCamera = async () => {
if (availableCameras.value.length <= 1) return
currentCameraIndex.value = (currentCameraIndex.value + 1) % availableCameras.value.length
// Determine if this is likely a front camera
const currentCamera = availableCameras.value[currentCameraIndex.value]
isFrontCamera.value = currentCamera.label.toLowerCase().includes('front') ||
currentCamera.label.toLowerCase().includes('user')
try {
await startCamera()
} catch (error) {
console.error('Failed to switch camera:', error)
toastStore.error('Failed to switch camera')
}
}
const capturePhoto = () => {
if (!videoElement.value || !isStreaming.value) return
try {
// Create canvas to capture frame
const canvas = document.createElement('canvas')
const video = videoElement.value
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Failed to get canvas context')
// Flip horizontally for front camera
if (isFrontCamera.value) {
ctx.scale(-1, 1)
ctx.drawImage(video, -canvas.width, 0, canvas.width, canvas.height)
} else {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
}
// Convert to data URL
capturedImage.value = canvas.toDataURL('image/jpeg', 0.8)
// Stop camera stream
stopCamera()
toastStore.success('Photo captured!')
} catch (error) {
console.error('Failed to capture photo:', error)
toastStore.error('Failed to capture photo')
}
}
const retakePhoto = () => {
capturedImage.value = undefined
initializeCamera()
}
const sendPhoto = async () => {
if (!capturedImage.value) return
isSending.value = true
errorMessage.value = ''
try {
// Create a message first to attach the photo to
const message = await apiService.createMessage(appStore.currentChannelId!, 'Photo')
// Convert data URL to blob
const response = await fetch(capturedImage.value)
const blob = await response.blob()
// Create file from blob
const file = new File([blob], `photo-${Date.now()}.jpg`, {
type: 'image/jpeg'
})
// Upload photo
const uploadedFile = await apiService.uploadFile(appStore.currentChannelId!, message.id, file)
// Create complete message with file metadata
const completeMessage = {
id: message.id,
channel_id: appStore.currentChannelId!,
content: message.content,
created_at: message.created_at,
file_id: uploadedFile.id,
fileId: uploadedFile.id,
filePath: uploadedFile.file_path,
fileType: uploadedFile.file_type,
fileSize: uploadedFile.file_size,
originalName: uploadedFile.original_name,
fileCreatedAt: uploadedFile.created_at
}
// Add the complete message to the store (this will trigger immediate UI update)
appStore.addMessage(completeMessage)
toastStore.success('Photo sent!')
emit('sent')
emit('close')
} catch (error) {
console.error('Failed to send photo:', error)
errorMessage.value = 'Failed to send photo. Please try again.'
toastStore.error('Failed to send photo')
} finally {
isSending.value = false
}
}
const stopCamera = () => {
if (currentStream) {
currentStream.getTracks().forEach(track => track.stop())
currentStream = null
}
isStreaming.value = false
}
const closeDialog = () => {
stopCamera()
emit('close')
}
// Lifecycle
onMounted(() => {
initializeCamera()
})
onUnmounted(() => {
stopCamera()
})
</script>
<style scoped>
.camera-capture-dialog {
padding: 1rem 0;
min-width: 500px;
max-width: 600px;
}
.camera-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.camera-feed {
position: relative;
width: 100%;
max-width: 500px;
border-radius: 12px;
overflow: hidden;
background: #000;
aspect-ratio: 16/9;
}
.camera-feed video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.camera-feed video.mirrored {
transform: scaleX(-1);
}
.camera-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1rem;
background: linear-gradient(to bottom, rgba(0,0,0,0.3), transparent);
}
.camera-info {
flex: 1;
}
.camera-status {
display: flex;
align-items: center;
gap: 0.5rem;
color: rgba(255, 255, 255, 0.8);
font-size: 0.875rem;
padding: 0.5rem 0.75rem;
background: rgba(0, 0, 0, 0.5);
border-radius: 20px;
backdrop-filter: blur(8px);
}
.camera-status.active {
color: #10b981;
}
.switch-camera-btn {
background: rgba(0, 0, 0, 0.5) !important;
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.2) !important;
color: white !important;
}
.image-preview {
width: 100%;
max-width: 500px;
border-radius: 12px;
overflow: hidden;
aspect-ratio: 16/9;
}
.captured-photo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #dc2626;
font-weight: 500;
max-width: 500px;
}
.permission-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
color: #0369a1;
max-width: 500px;
}
.permission-info p {
margin: 0;
font-size: 0.875rem;
}
.capture-controls {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.capture-buttons, .review-buttons {
display: flex;
gap: 1rem;
}
.capture-btn {
padding: 1rem 2rem;
font-size: 1.125rem;
font-weight: 600;
border-radius: 50px;
min-width: 160px;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
.error-message {
background: #7f1d1d;
border-color: #991b1b;
color: #fca5a5;
}
.permission-info {
background: #1e3a8a;
border-color: #3b82f6;
color: #93c5fd;
}
.dialog-actions {
border-top-color: #374151;
}
}
/* Mobile responsiveness */
@media (max-width: 640px) {
.camera-capture-dialog {
min-width: unset;
max-width: unset;
width: 100%;
}
.camera-feed, .image-preview {
max-width: 100%;
}
.camera-overlay {
padding: 0.75rem;
}
.capture-btn {
padding: 0.875rem 1.5rem;
font-size: 1rem;
min-width: 140px;
}
.capture-buttons, .review-buttons {
flex-direction: column;
width: 100%;
}
}
</style>