Files
sgse-app/apps/web/src/lib/components/almoxarifado/ImageUpload.svelte

446 lines
11 KiB
Svelte

<script lang="ts">
import { Image as ImageIcon, Upload, X, Camera } from 'lucide-svelte';
interface Props {
value?: string | null;
onChange?: (base64: string | null) => void;
maxSizeMB?: number;
maxWidth?: number;
maxHeight?: number;
}
let {
value = $bindable(null),
onChange,
maxSizeMB = 5,
maxWidth = 1200,
maxHeight = 1200
}: Props = $props();
let preview = $state<string | null>(value);
let error = $state<string | null>(null);
let inputElement: HTMLInputElement | null = null;
let showCamera = $state(false);
let videoElement: HTMLVideoElement | null = null;
let stream: MediaStream | null = null;
let capturing = $state(false);
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
error = null;
// Validar tamanho
if (file.size > maxSizeMB * 1024 * 1024) {
error = `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB`;
return;
}
// Validar tipo
if (!file.type.startsWith('image/')) {
error = 'Por favor, selecione um arquivo de imagem';
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
if (result) {
// Redimensionar imagem se necessário
resizeImage(result, maxWidth, maxHeight)
.then((resized) => {
preview = resized;
value = resized;
if (onChange) {
onChange(resized);
}
})
.catch((err) => {
error = err instanceof Error ? err.message : 'Erro ao processar imagem';
});
}
};
reader.onerror = () => {
error = 'Erro ao ler arquivo';
};
reader.readAsDataURL(file);
}
function resizeImage(
dataUrl: string,
maxWidth: number,
maxHeight: number
): Promise<string> {
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => {
let width = img.width;
let height = img.height;
// Calcular novas dimensões mantendo proporção
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = width * ratio;
height = height * ratio;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Não foi possível criar contexto do canvas'));
return;
}
ctx.drawImage(img, 0, 0, width, height);
const resizedDataUrl = canvas.toDataURL('image/jpeg', 0.85);
resolve(resizedDataUrl);
};
img.onerror = () => {
reject(new Error('Erro ao carregar imagem'));
};
img.src = dataUrl;
});
}
function removeImage() {
preview = null;
value = null;
if (inputElement) {
inputElement.value = '';
}
if (onChange) {
onChange(null);
}
}
function triggerFileInput() {
inputElement?.click();
}
async function openCamera() {
// Se já estiver inicializando ou já tiver stream, não fazer nada
if (stream) {
return;
}
// Primeiro, abrir o modal
showCamera = true;
capturing = false;
error = null;
// Aguardar o próximo tick para garantir que o DOM foi atualizado
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
try {
// Verificar se a API está disponível
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
error = 'Câmera não disponível neste dispositivo ou navegador não suporta acesso à câmera';
showCamera = false;
return;
}
// Aguardar o elemento de vídeo estar disponível no DOM
let attempts = 0;
while (!videoElement && attempts < 30) {
await new Promise((resolve) => setTimeout(resolve, 50));
attempts++;
}
if (!videoElement) {
throw new Error('Elemento de vídeo não encontrado no DOM');
}
// Solicitar acesso à câmera
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Câmera traseira por padrão
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
// Atribuir stream ao vídeo
videoElement.srcObject = stream;
// Aguardar o vídeo estar pronto e começar a reproduzir
await videoElement.play();
// Aguardar metadata estar carregado
if (videoElement.readyState < 2) {
await new Promise<void>((resolve, reject) => {
if (!videoElement) {
reject(new Error('Elemento de vídeo não encontrado'));
return;
}
const onLoadedMetadata = () => {
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
videoElement?.removeEventListener('error', onError);
resolve();
};
const onError = () => {
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
videoElement?.removeEventListener('error', onError);
reject(new Error('Erro ao carregar vídeo'));
};
videoElement.addEventListener('loadedmetadata', onLoadedMetadata, { once: true });
videoElement.addEventListener('error', onError, { once: true });
});
}
capturing = true;
} catch (err) {
console.error('Erro ao acessar câmera:', err);
let errorMessage = 'Erro ao acessar câmera';
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
errorMessage = 'Permissão de acesso à câmera negada. Por favor, permita o acesso à câmera nas configurações do navegador.';
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
errorMessage = 'Nenhuma câmera encontrada no dispositivo.';
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
errorMessage = 'Câmera está sendo usada por outro aplicativo.';
} else {
errorMessage = err.message || errorMessage;
}
}
error = errorMessage;
showCamera = false;
capturing = false;
stopCamera();
}
}
function stopCamera() {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
if (videoElement) {
videoElement.srcObject = null;
}
capturing = false;
}
function closeCamera() {
stopCamera();
showCamera = false;
error = null;
}
async function capturePhoto() {
if (!videoElement) return;
try {
// Criar canvas para capturar o frame
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
error = 'Não foi possível criar contexto do canvas';
return;
}
// Desenhar o frame atual do vídeo no canvas
ctx.drawImage(videoElement, 0, 0);
// Converter para base64
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
// Redimensionar e processar
const resized = await resizeImage(dataUrl, maxWidth, maxHeight);
preview = resized;
value = resized;
if (onChange) {
onChange(resized);
}
// Fechar câmera
closeCamera();
} catch (err) {
error = err instanceof Error ? err.message : 'Erro ao capturar foto';
console.error('Erro ao capturar foto:', err);
}
}
// Sincronizar preview com value sempre que value mudar
$effect(() => {
// Sempre sincronizar quando value mudar
preview = value;
});
// Limpar stream quando o componente for desmontado
$effect(() => {
return () => {
stopCamera();
};
});
</script>
<div class="image-upload">
<input
type="file"
accept="image/*"
class="hidden"
bind:this={inputElement}
onchange={handleFileSelect}
aria-label="Selecionar imagem do produto"
/>
{#if preview}
<div class="relative inline-block">
<img src={preview} alt="Preview da imagem do produto" class="max-w-full max-h-64 rounded-lg" />
<button
type="button"
class="btn btn-sm btn-circle btn-error absolute top-2 right-2"
onclick={removeImage}
aria-label="Remover imagem"
>
<X class="h-4 w-4" />
</button>
</div>
{:else}
<div class="flex flex-col gap-4">
<div
class="border-2 border-dashed border-base-300 rounded-lg p-8 text-center cursor-pointer hover:border-primary transition-colors"
onclick={triggerFileInput}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
triggerFileInput();
}
}}
>
<Upload class="h-12 w-12 mx-auto mb-4 text-base-content/40" />
<p class="text-base-content/70 font-medium mb-2">Clique para fazer upload da imagem</p>
<p class="text-sm text-base-content/50">
PNG, JPG ou GIF até {maxSizeMB}MB
</p>
</div>
<div class="divider text-sm">ou</div>
<button
type="button"
class="btn btn-outline btn-primary w-full"
onclick={openCamera}
>
<Camera class="h-5 w-5" />
Capturar da Câmera
</button>
</div>
{/if}
{#if error}
<div class="alert alert-error mt-4">
<span>{error}</span>
</div>
{/if}
{#if preview}
<div class="flex gap-2 mt-4">
<button
type="button"
class="btn btn-sm btn-outline btn-primary flex-1"
onclick={triggerFileInput}
>
<ImageIcon class="h-4 w-4" />
Alterar Imagem
</button>
<button
type="button"
class="btn btn-sm btn-outline btn-primary flex-1"
onclick={openCamera}
>
<Camera class="h-4 w-4" />
Capturar Foto
</button>
</div>
{/if}
</div>
<!-- Modal da Câmera -->
{#if showCamera}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80" onclick={closeCamera}>
<div
class="bg-base-100 rounded-lg shadow-2xl p-6 max-w-2xl w-full mx-4"
onclick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-xl font-bold">Capturar Foto</h3>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
onclick={closeCamera}
aria-label="Fechar câmera"
>
<X class="h-5 w-5" />
</button>
</div>
<div class="relative bg-black rounded-lg overflow-hidden mb-4" style="aspect-ratio: 4/3; min-height: 300px;">
{#if showCamera}
<video
bind:this={videoElement}
autoplay
playsinline
muted
class="w-full h-full object-cover"
style="transform: scaleX(-1); opacity: {capturing ? '1' : '0'}; transition: opacity 0.3s;"
></video>
{/if}
{#if !capturing}
<div class="flex items-center justify-center h-full absolute inset-0 z-10">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary mb-2"></span>
<p class="text-base-content/70 text-sm">Iniciando câmera...</p>
</div>
</div>
{/if}
</div>
<div class="flex gap-2 justify-end">
<button
type="button"
class="btn btn-ghost"
onclick={closeCamera}
>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
onclick={capturePhoto}
disabled={!capturing}
>
<Camera class="h-5 w-5" />
Capturar Foto
</button>
</div>
</div>
</div>
{/if}
<style>
.image-upload {
width: 100%;
}
</style>