refactor: enhance FileUpload component with improved error handling and UI updates

- Refactored the FileUpload component to improve code readability and maintainability.
- Enhanced error handling during file upload and removal processes, providing clearer feedback to users.
- Updated UI elements for better alignment and consistency, including file type previews and action buttons.
- Integrated the resolve function for help URLs to ensure proper linking.
- Streamlined file validation logic for size and type checks, improving user experience.
This commit is contained in:
2025-11-14 21:55:28 -03:00
parent b503045b41
commit d8da7e2a05

View File

@@ -1,74 +1,74 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient } from "convex-svelte"; import { useConvexClient } from 'convex-svelte';
import { import { resolve } from '$app/paths';
ExternalLink, import {
FileText, ExternalLink,
File as FileIcon, FileText,
Upload, File as FileIcon,
Trash2, Upload,
Eye, Trash2,
RefreshCw, Eye,
} from "lucide-svelte"; RefreshCw
} from 'lucide-svelte';
interface Props { interface Props {
label: string; label: string;
helpUrl?: string; helpUrl?: string;
value?: string; // storageId value?: string; // storageId
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
onUpload: (file: globalThis.File) => Promise<void>; onUpload: (file: globalThis.File) => Promise<void>;
onRemove: () => Promise<void>; onRemove: () => Promise<void>;
} }
let { let {
label, label,
helpUrl, helpUrl,
value = $bindable(), value = $bindable(),
disabled = false, disabled = false,
required = false, required = false,
onUpload, onUpload,
onRemove, onRemove
}: Props = $props(); }: Props = $props();
const client = useConvexClient(); const client = useConvexClient() as unknown as {
storage: {
getUrl: (id: string) => Promise<string | null>;
};
};
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
let uploading = $state(false); let uploading = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
let fileName = $state<string>(""); let fileName = $state<string>('');
let fileType = $state<string>(""); let fileType = $state<string>('');
let previewUrl = $state<string | null>(null); let previewUrl = $state<string | null>(null);
let fileUrl = $state<string | null>(null); let fileUrl = $state<string | null>(null);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [ const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
"application/pdf",
"image/jpeg",
"image/jpg",
"image/png",
];
// Buscar URL do arquivo quando houver um storageId // Buscar URL do arquivo quando houver um storageId
$effect(() => { $effect(() => {
if (value && !fileName) { if (value && !fileName) {
// Tem storageId mas não é um upload recente // Tem storageId mas não é um upload recente
loadExistingFile(value); void loadExistingFile(value);
} }
}); });
async function loadExistingFile(storageId: string) { async function loadExistingFile(storageId: string) {
try { try {
const url = await client.storage.getUrl(storageId as any); const url = await client.storage.getUrl(storageId);
if (url) { if (url) {
async function handleFileSelect(event: Event) { fileUrl = url;
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
// Detectar tipo pelo URL ou assumir PDF // Detectar tipo pelo URL ou assumir PDF
if (url.includes('.pdf') || url.includes('application/pdf')) { if (url.includes('.pdf') || url.includes('application/pdf')) {
fileType = 'application/pdf'; fileType = 'application/pdf';
} else { } else {
fileType = 'image/jpeg'; fileType = 'image/jpeg';
previewUrl = url; // Para imagens, a URL serve como preview // Para imagens, a URL serve como preview
previewUrl = url;
} }
} }
} catch (err) { } catch (err) {
@@ -76,111 +76,130 @@
} }
} }
error = null; async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
// Validate file size if (!file) {
if (file.size > MAX_FILE_SIZE) { return;
error = "Arquivo muito grande. Tamanho máximo: 10MB"; }
target.value = "";
return;
}
// Validate file type error = null;
if (!ALLOWED_TYPES.includes(file.type)) {
error = "Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)";
target.value = "";
return;
}
try { // Validate file size
uploading = true; if (file.size > MAX_FILE_SIZE) {
fileName = file.name; error = 'Arquivo muito grande. Tamanho máximo: 10MB';
fileType = file.type; target.value = '';
return;
}
// Create preview for images // Validate file type
if (file.type.startsWith("image/")) { if (!ALLOWED_TYPES.includes(file.type)) {
const reader = new FileReader(); error = 'Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)';
reader.onload = (e) => { target.value = '';
previewUrl = e.target?.result as string; return;
}; }
reader.readAsDataURL(file);
}
await onUpload(file); try {
} catch (err: any) { uploading = true;
error = err?.message || "Erro ao fazer upload do arquivo"; fileName = file.name;
previewUrl = null; fileType = file.type;
} finally {
uploading = false;
target.value = "";
}
}
async function handleRemove() { // Create preview for images
if (!confirm("Tem certeza que deseja remover este arquivo?")) { if (file.type.startsWith('image/')) {
return; const reader = new FileReader();
} reader.onload = (e) => {
const result = e.target?.result;
if (typeof result === 'string') {
previewUrl = result;
}
};
reader.readAsDataURL(file);
} else {
previewUrl = null;
}
try { await onUpload(file);
uploading = true; } catch (err: unknown) {
await onRemove(); if (err instanceof Error) {
fileName = ""; error = err.message || 'Erro ao fazer upload do arquivo';
fileType = ""; } else {
previewUrl = null; error = 'Erro ao fazer upload do arquivo';
fileUrl = null; }
} catch (err: any) { previewUrl = null;
error = err?.message || "Erro ao remover arquivo"; } finally {
} finally { uploading = false;
uploading = false; target.value = '';
} }
} }
function handleView() { async function handleRemove() {
if (fileUrl) { if (!confirm('Tem certeza que deseja remover este arquivo?')) {
window.open(fileUrl, "_blank"); return;
} }
}
function openFileDialog() { try {
fileInput?.click(); uploading = true;
} await onRemove();
fileName = '';
fileType = '';
previewUrl = null;
fileUrl = null;
error = null;
} catch (err: unknown) {
if (err instanceof Error) {
error = err.message || 'Erro ao remover arquivo';
} else {
error = 'Erro ao remover arquivo';
}
} finally {
uploading = false;
}
}
function handleView() {
if (fileUrl) {
window.open(fileUrl, '_blank');
}
}
function openFileDialog() {
fileInput?.click();
}
</script> </script>
<div class="form-control w-full"> <div class="form-control w-full">
<label class="label" for="file-upload-input"> <label class="label" for="file-upload-input">
<span class="label-text font-medium flex items-center gap-2"> <span class="label-text flex items-center gap-2 font-medium">
{label} {label}
{#if required} {#if required}
<span class="text-error">*</span> <span class="text-error">*</span>
{/if} {/if}
{#if helpUrl} {#if helpUrl}
<div <div class="tooltip tooltip-right" data-tip="Clique para acessar o link">
class="tooltip tooltip-right" <a
data-tip="Clique para acessar o link" href={resolve(helpUrl)}
> target="_blank"
<a rel="noopener noreferrer"
href={helpUrl} class="text-primary hover:text-primary-focus transition-colors"
target="_blank" aria-label="Acessar link"
rel="noopener noreferrer" >
class="text-primary hover:text-primary-focus transition-colors" <ExternalLink class="h-4 w-4" strokeWidth={2} />
aria-label="Acessar link" </a>
> </div>
<ExternalLink class="h-4 w-4" strokeWidth={2} /> {/if}
</a> </span>
</div> </label>
{/if}
</span>
</label>
<input <input
id="file-upload-input" id="file-upload-input"
type="file" type="file"
bind:this={fileInput} bind:this={fileInput}
onchange={handleFileSelect} onchange={handleFileSelect}
accept=".pdf,.jpg,.jpeg,.png" accept=".pdf,.jpg,.jpeg,.png"
class="hidden" class="hidden"
{disabled} {disabled}
/> />
{#if value || fileName} {#if value || fileName}
<div class="border-base-300 bg-base-100 flex items-center gap-2 rounded-lg border p-3"> <div class="border-base-300 bg-base-100 flex items-center gap-2 rounded-lg border p-3">
@@ -194,78 +213,78 @@
</div> </div>
{:else} {:else}
<div class="bg-success/10 flex h-12 w-12 items-center justify-center rounded"> <div class="bg-success/10 flex h-12 w-12 items-center justify-center rounded">
<File class="text-success h-6 w-6" strokeWidth={2} /> <FileIcon class="text-success h-6 w-6" strokeWidth={2} />
</div> </div>
{/if} {/if}
</div> </div>
<!-- File info --> <!-- File info -->
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate"> <p class="truncate text-sm font-medium">
{fileName || "Arquivo anexado"} {fileName || 'Arquivo anexado'}
</p> </p>
<p class="text-xs text-base-content/60"> <p class="text-base-content/60 text-xs">
{#if uploading} {#if uploading}
Carregando... Carregando...
{:else} {:else}
Enviado com sucesso Enviado com sucesso
{/if} {/if}
</p> </p>
</div> </div>
<!-- Actions --> <!-- Actions -->
<div class="flex gap-2"> <div class="flex gap-2">
{#if fileUrl} {#if fileUrl}
<button <button
type="button" type="button"
onclick={handleView} onclick={handleView}
class="btn btn-sm btn-ghost text-info" class="btn btn-sm btn-ghost text-info"
disabled={uploading || disabled} disabled={uploading || disabled}
title="Visualizar arquivo" title="Visualizar arquivo"
> >
<Eye class="h-4 w-4" strokeWidth={2} /> <Eye class="h-4 w-4" strokeWidth={2} />
</button> </button>
{/if} {/if}
<button <button
type="button" type="button"
onclick={openFileDialog} onclick={openFileDialog}
class="btn btn-sm btn-ghost" class="btn btn-sm btn-ghost"
disabled={uploading || disabled} disabled={uploading || disabled}
title="Substituir arquivo" title="Substituir arquivo"
> >
<RefreshCw class="h-4 w-4" strokeWidth={2} /> <RefreshCw class="h-4 w-4" strokeWidth={2} />
</button> </button>
<button <button
type="button" type="button"
onclick={handleRemove} onclick={handleRemove}
class="btn btn-sm btn-ghost text-error" class="btn btn-sm btn-ghost text-error"
disabled={uploading || disabled} disabled={uploading || disabled}
title="Remover arquivo" title="Remover arquivo"
> >
<Trash2 class="h-4 w-4" strokeWidth={2} /> <Trash2 class="h-4 w-4" strokeWidth={2} />
</button> </button>
</div> </div>
</div> </div>
{:else} {:else}
<button <button
type="button" type="button"
onclick={openFileDialog} onclick={openFileDialog}
class="btn btn-outline btn-block justify-start gap-2" class="btn btn-outline btn-block justify-start gap-2"
disabled={uploading || disabled} disabled={uploading || disabled}
> >
{#if uploading} {#if uploading}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
Carregando... Carregando...
{:else} {:else}
<Upload class="h-5 w-5" strokeWidth={2} /> <Upload class="h-5 w-5" strokeWidth={2} />
Selecionar arquivo (PDF ou imagem, máx. 10MB) Selecionar arquivo (PDF ou imagem, máx. 10MB)
{/if} {/if}
</button> </button>
{/if} {/if}
{#if error} {#if error}
<div class="label"> <div class="label">
<span class="label-text-alt text-error">{error}</span> <span class="label-text-alt text-error">{error}</span>
</div> </div>
{/if} {/if}
</div> </div>