Kasir/resources/js/components/PhotoUploader.vue
2025-10-27 20:02:02 +07:00

444 lines
14 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.

<template>
<div>
<label class="block text-D mb-1">Foto</label>
<div class="grid grid-cols-3 gap-3 relative">
<!-- Uploaded Images -->
<div
v-for="(image, index) in uploadedImages"
:key="`img-${image.id}`"
class="relative group aspect-square"
>
<div class="w-full h-full bg-gray-100 rounded-lg border-2 border-gray-200 overflow-hidden">
<img :src="image.url" :alt="`Foto ${index + 1}`" class="w-full h-full object-cover" />
<button
@click="removeImage(image.id)"
:disabled="uploadLoading"
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm font-bold shadow-lg hover:bg-red-600 transition-colors disabled:bg-gray-400"
>
×
</button>
</div>
</div>
<!-- Upload Button -->
<div v-if="uploadedImages.length < maxPhotos" class="relative aspect-square">
<div
@drop="handleDrop"
@dragover.prevent
@dragenter.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@click="toggleUploadMenu"
class="w-full h-full bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-D hover:bg-blue-50 transition-colors group"
:class="{
'border-blue-400 bg-blue-50': isDragging,
'cursor-not-allowed opacity-50': uploadLoading,
}"
>
<div class="text-center">
<div
v-if="!uploadLoading"
class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2 group-hover:bg-D transition-colors"
>
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
></path>
</svg>
</div>
<div v-else class="w-12 h-12 bg-D rounded-lg flex items-center justify-center mx-auto mb-2">
<svg class="animate-spin w-6 h-6 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-75"
fill="currentColor"
d="m4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
<p
class="text-xs text-gray-600 font-medium"
v-html="uploadLoading ? 'Uploading...' : 'Unggah<br/>Foto'"
></p>
</div>
</div>
<!-- Dropdown Menu -->
<div
v-if="showUploadMenu"
class="absolute top-full left-0 mt-2 w-60 bg-white border border-gray-200 rounded-lg shadow-lg z-20"
>
<button
@click="triggerFileUpload"
class="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3 border-b border-gray-100"
>
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
></path>
</svg>
<div>
<div class="font-medium text-gray-900">Upload dari File</div>
<div class="text-sm text-gray-500">Pilih foto dari galeri</div>
</div>
</button>
<button
@click="openCameraModal"
class="w-full px-4 py-3 text-left hover:bg-gray-50 flex items-center space-x-3"
>
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<div>
<div class="font-medium text-gray-900">Ambil dari Kamera</div>
<div class="text-sm text-gray-500">Foto langsung dengan kamera</div>
</div>
</button>
</div>
</div>
</div>
<!-- Hidden File Input -->
<input
ref="fileInput"
type="file"
multiple
accept="image/jpeg,image/jpg,image/png"
@change="handleFileSelect"
class="hidden"
/>
<p class="text-xs text-gray-500 mt-2">Format: JPG, JPEG, PNG (Max: 2MB per file, Max: {{ maxPhotos }} foto)</p>
<div
v-if="uploadError"
class="mt-2 p-2 bg-red-50 border border-red-200 rounded text-sm text-red-600"
>
{{ uploadError }}
</div>
<!-- Overlay -->
<div v-if="showUploadMenu" @click="showUploadMenu = false" class="fixed inset-0 z-10"></div>
<!-- Camera Modal -->
<div
v-if="showCamera"
class="fixed inset-0 bg-black/75 flex items-center justify-center z-[9999]"
>
<div class="bg-white rounded-lg shadow-lg p-4 relative">
<h3 class="text-lg font-semibold mb-3 text-center">Ambil Foto</h3>
<div class="relative bg-black rounded overflow-hidden" style="width: 400px; height: 400px;">
<video
ref="video"
autoplay
playsinline
class="absolute"
style="
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
object-fit: cover;
width: 100%;
height: 100%;
"
></video>
<!-- Square Crop Overlay with Darkened Outside Area -->
<div class="absolute inset-0 pointer-events-none">
<!-- Top dark overlay -->
<div class="absolute top-0 left-0 right-0 bg-black/60" style="height: 0;"></div>
<!-- Bottom dark overlay -->
<div class="absolute bottom-0 left-0 right-0 bg-black/60" style="height: 0;"></div>
<!-- Left dark overlay -->
<div class="absolute top-0 bottom-0 left-0 bg-black/60" style="width: 0;"></div>
<!-- Right dark overlay -->
<div class="absolute top-0 bottom-0 right-0 bg-black/60" style="width: 0;"></div>
<!-- Crop area border -->
<div class="absolute inset-0 border-2 border-blue-500"></div>
<!-- Center crosshair -->
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div class="relative w-8 h-8">
<div class="absolute top-1/2 left-0 w-full h-0.5 bg-blue-500/50 transform -translate-y-1/2"></div>
<div class="absolute left-1/2 top-0 h-full w-0.5 bg-blue-500/50 transform -translate-x-1/2"></div>
</div>
</div>
</div>
</div>
<canvas ref="canvas" class="hidden"></canvas>
<div class="mt-3 flex justify-between">
<button @click="closeCamera" class="px-4 py-2 bg-gray-400 text-white rounded">
Batal
</button>
<button @click="capturePhoto" class="px-4 py-2 bg-blue-500 text-white rounded">
Ambil Foto
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onUnmounted } from 'vue';
import axios from 'axios';
const props = defineProps({
modelValue: {
type: Array,
default: () => [],
},
maxPhotos: {
type: Number,
default: 6,
},
});
const emit = defineEmits(['update:modelValue', 'error']);
const uploadedImages = ref([...props.modelValue]);
const showUploadMenu = ref(false);
const fileInput = ref(null);
const uploadLoading = ref(false);
const isDragging = ref(false);
const uploadError = ref('');
// Camera states
const showCamera = ref(false);
const video = ref(null);
const canvas = ref(null);
let stream = null;
// Watch for external changes to modelValue
watch(
() => props.modelValue,
(newVal) => {
uploadedImages.value = [...newVal];
}
);
// Watch for internal changes to uploadedImages
watch(uploadedImages, (newVal) => {
emit('update:modelValue', newVal);
});
const toggleUploadMenu = () => {
if (!uploadLoading.value && uploadedImages.value.length < props.maxPhotos) {
showUploadMenu.value = !showUploadMenu.value;
}
};
const triggerFileUpload = () => {
showUploadMenu.value = false;
fileInput.value?.click();
};
const handleFileSelect = (event) => {
const files = Array.from(event.target.files);
processFiles(files);
};
const handleDrop = (event) => {
event.preventDefault();
isDragging.value = false;
if (uploadLoading.value || uploadedImages.value.length >= props.maxPhotos) return;
const files = Array.from(event.dataTransfer.files);
processFiles(files);
};
const processFiles = async (files) => {
uploadError.value = '';
if (uploadedImages.value.length + files.length > props.maxPhotos) {
uploadError.value = `Maksimal ${props.maxPhotos} foto yang dapat diupload`;
emit('error', uploadError.value);
return;
}
const validFiles = files.filter((file) => {
const isValidType = ['image/jpeg', 'image/jpg', 'image/png'].includes(file.type);
const isValidSize = file.size <= 2 * 1024 * 1024;
if (!isValidType) {
uploadError.value = 'Format file harus JPG, JPEG, atau PNG';
emit('error', uploadError.value);
return false;
}
if (!isValidSize) {
uploadError.value = 'Ukuran file maksimal 2MB';
emit('error', uploadError.value);
return false;
}
return true;
});
if (validFiles.length === 0) return;
// Process files dengan auto crop 1:1
uploadLoading.value = true;
for (const file of validFiles) {
await cropAndUploadFile(file);
}
uploadLoading.value = false;
if (fileInput.value) fileInput.value.value = '';
};
// Auto crop 1:1 function
const cropAndUploadFile = (file) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
// Create canvas untuk crop 1:1
const size = Math.min(img.width, img.height);
const canvas = document.createElement('canvas');
canvas.width = 800;
canvas.height = 800;
const ctx = canvas.getContext('2d');
// Calculate center crop
const startX = (img.width - size) / 2;
const startY = (img.height - size) / 2;
// Draw cropped image
ctx.drawImage(
img,
startX, startY, size, size, // source
0, 0, 800, 800 // destination
);
// Convert to blob and upload
canvas.toBlob(async (blob) => {
if (blob) {
const croppedFile = new File([blob], file.name, {
type: 'image/jpeg',
lastModified: Date.now(),
});
await uploadFile(croppedFile);
}
resolve();
}, 'image/jpeg', 0.9);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
});
};
const uploadFile = async (file) => {
try {
const formData = new FormData();
formData.append('foto', file);
const response = await axios.post('/api/foto', formData, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'multipart/form-data',
},
});
uploadedImages.value.push(response.data);
} catch (error) {
console.error('Upload error:', error);
uploadError.value = error.response?.data?.message || 'Gagal mengupload foto';
emit('error', uploadError.value);
}
};
const removeImage = async (id) => {
try {
await axios.delete(`/api/foto/${id}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
uploadedImages.value = uploadedImages.value.filter((i) => i.id !== id);
} catch (error) {
uploadError.value = 'Gagal menghapus foto';
emit('error', uploadError.value);
}
};
// Camera Functions
const openCameraModal = async () => {
showUploadMenu.value = false;
showCamera.value = true;
try {
stream = await navigator.mediaDevices.getUserMedia({ video: true });
video.value.srcObject = stream;
} catch (err) {
console.error('Gagal akses kamera:', err);
alert('Tidak bisa mengakses kamera, cek izin browser!');
closeCamera();
}
};
const closeCamera = () => {
showCamera.value = false;
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
};
const capturePhoto = () => {
const videoElement = video.value;
const canvasElement = canvas.value;
// Get video dimensions
const videoWidth = videoElement.videoWidth;
const videoHeight = videoElement.videoHeight;
// Calculate square crop (center)
const size = Math.min(videoWidth, videoHeight);
const startX = (videoWidth - size) / 2;
const startY = (videoHeight - size) / 2;
// Set canvas to square
canvasElement.width = 800;
canvasElement.height = 800;
const ctx = canvasElement.getContext('2d');
// Draw cropped square image
ctx.drawImage(
videoElement,
startX, startY, size, size, // source
0, 0, 800, 800 // destination
);
canvasElement.toBlob(
async (blob) => {
if (!blob) return;
const file = new File([blob], 'camera_photo.jpg', { type: 'image/jpeg' });
closeCamera();
uploadLoading.value = true;
await uploadFile(file);
uploadLoading.value = false;
},
'image/jpeg',
0.9
);
};
// Cleanup on unmount
onUnmounted(() => {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
});
</script>