feat: inline error messages with recovery actions

This commit is contained in:
SnapOtter
2026-06-14 19:24:12 +08:00
parent 619f61b470
commit 56cd843482
23 changed files with 121 additions and 10 deletions
+33 -6
View File
@@ -1,4 +1,4 @@
import { FileImage, FileUp, Upload } from "lucide-react"; import { AlertCircle, FileImage, FileUp, Upload } from "lucide-react";
import { type DragEvent, useCallback, useEffect, useState } from "react"; import { type DragEvent, useCallback, useEffect, useState } from "react";
import { useTranslation } from "@/contexts/i18n-context"; import { useTranslation } from "@/contexts/i18n-context";
import { useUrlImport } from "@/hooks/use-url-import"; import { useUrlImport } from "@/hooks/use-url-import";
@@ -116,11 +116,19 @@ export function Dropzone({
const checkFile = fileFilter ?? isImageFile; const checkFile = fileFilter ?? isImageFile;
const resolvedAccept = expandAccept(accept); const resolvedAccept = expandAccept(accept);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [urlInput, setUrlInput] = useState(""); const [urlInput, setUrlInput] = useState("");
const [urlLoading, setUrlLoading] = useState(false); const [urlLoading, setUrlLoading] = useState(false);
const [urlError, setUrlError] = useState<string | null>(null); const [urlError, setUrlError] = useState<string | null>(null);
const [showBulkModal, setShowBulkModal] = useState(false); const [showBulkModal, setShowBulkModal] = useState(false);
useEffect(() => {
if (error) {
const timer = setTimeout(() => setError(null), 5000);
return () => clearTimeout(timer);
}
}, [error]);
const { importSingleUrl } = useUrlImport(); const { importSingleUrl } = useUrlImport();
const handleUrlSubmit = useCallback(async () => { const handleUrlSubmit = useCallback(async () => {
@@ -154,20 +162,32 @@ export function Dropzone({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsDragging(false); setIsDragging(false);
const files = Array.from(e.dataTransfer.files).filter(checkFile); setError(null);
if (files.length > 0) onFiles?.(files); const droppedFiles = Array.from(e.dataTransfer.files);
const validFiles = droppedFiles.filter(checkFile);
if (validFiles.length > 0) {
onFiles?.(validFiles);
} else if (droppedFiles.length > 0) {
setError(acceptDescription || `This tool accepts ${accept || "image files"}`);
}
}, },
[onFiles, checkFile], [onFiles, checkFile, acceptDescription, accept],
); );
const handleClick = () => { const handleClick = () => {
setError(null);
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.multiple = multiple; input.multiple = multiple;
if (resolvedAccept) input.accept = resolvedAccept; if (resolvedAccept) input.accept = resolvedAccept;
input.onchange = (e) => { input.onchange = (e) => {
const files = Array.from((e.target as HTMLInputElement).files || []).filter(checkFile); const picked = Array.from((e.target as HTMLInputElement).files || []);
if (files.length > 0) onFiles?.(files); const validFiles = picked.filter(checkFile);
if (validFiles.length > 0) {
onFiles?.(validFiles);
} else if (picked.length > 0) {
setError(acceptDescription || `This tool accepts ${accept || "image files"}`);
}
}; };
input.click(); input.click();
}; };
@@ -260,6 +280,13 @@ export function Dropzone({
{acceptDescription ?? t.dropzone.defaultFormats} {acceptDescription ?? t.dropzone.defaultFormats}
</p> </p>
{error && (
<p className="mt-3 text-sm text-destructive flex items-center gap-1.5">
<AlertCircle className="h-4 w-4 shrink-0" />
{error}
</p>
)}
{!compact && onUrlImport && ( {!compact && onUrlImport && (
<> <>
<div className="flex items-center gap-2 w-full max-w-xs"> <div className="flex items-center gap-2 w-full max-w-xs">
+24 -3
View File
@@ -1,5 +1,6 @@
import { MODALITIES, PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared"; import { MODALITIES, PYTHON_SIDECAR_TOOLS, TOOL_BUNDLE_MAP, TOOLS } from "@snapotter/shared";
import { import {
AlertCircle,
CheckCircle2, CheckCircle2,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -617,10 +618,30 @@ export function ToolPage() {
// which also match !hasProcessed and would show the canvas instead of the error) // which also match !hasProcessed and would show the canvas instead of the error)
if (hasFile && !hasProcessed && currentEntry?.status === "failed") { if (hasFile && !hasProcessed && currentEntry?.status === "failed") {
return ( return (
<div className="flex flex-col items-center justify-center gap-3 h-full text-center px-4"> <div className="flex-1 flex items-center justify-center p-6">
<p className="text-sm text-red-500"> <div className="text-center max-w-sm">
{currentEntry.error ?? t.toolPage.processingFailed} <AlertCircle className="mx-auto h-10 w-10 text-destructive mb-3" />
<p className="font-medium text-foreground mb-1">
{currentEntry.error || t.toolPage.processingFailed}
</p> </p>
<p className="text-sm text-muted-foreground mb-4">{t.toolPage.settingsSaved}</p>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={handleUndo}
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
>
{t.toolPage.tryAgain}
</button>
<button
type="button"
onClick={startOver}
className="px-4 py-2 rounded-md text-sm text-muted-foreground hover:text-foreground"
>
{t.toolPage.tryDifferentFile}
</button>
</div>
</div>
</div> </div>
); );
} }
+3
View File
@@ -2453,6 +2453,9 @@ export const ar: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "تم حفظ إعداداتك.",
tryAgain: "حاول مرة أخرى",
tryDifferentFile: "جرب ملفًا مختلفًا",
}, },
homePage: { homePage: {
generatingPreview: "جاري إنشاء المعاينة...", generatingPreview: "جاري إنشاء المعاينة...",
+3
View File
@@ -2467,6 +2467,9 @@ export const de: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Ihre Einstellungen wurden gespeichert.",
tryAgain: "Erneut versuchen",
tryDifferentFile: "Andere Datei versuchen",
}, },
homePage: { homePage: {
generatingPreview: "Vorschau wird generiert...", generatingPreview: "Vorschau wird generiert...",
+3
View File
@@ -2417,6 +2417,9 @@ export const en = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Your settings are saved.",
tryAgain: "Try again",
tryDifferentFile: "Try a different file",
}, },
homePage: { homePage: {
generatingPreview: "Generating preview...", generatingPreview: "Generating preview...",
+3
View File
@@ -2449,6 +2449,9 @@ export const es: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Tu configuracion se ha guardado.",
tryAgain: "Reintentar",
tryDifferentFile: "Probar con otro archivo",
}, },
homePage: { homePage: {
generatingPreview: "Generando vista previa...", generatingPreview: "Generando vista previa...",
+3
View File
@@ -2468,6 +2468,9 @@ export const fr: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Vos parametres sont enregistres.",
tryAgain: "Reessayer",
tryDifferentFile: "Essayer un autre fichier",
}, },
homePage: { homePage: {
generatingPreview: "Generation de l'apercu...", generatingPreview: "Generation de l'apercu...",
+3
View File
@@ -2450,6 +2450,9 @@ export const hi: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "आपकी सेटिंग्स सहेज ली गई हैं।",
tryAgain: "पुनः प्रयास करें",
tryDifferentFile: "एक अलग फ़ाइल आज़माएं",
}, },
homePage: { homePage: {
generatingPreview: "प्रीव्यू जनरेट हो रहा है...", generatingPreview: "प्रीव्यू जनरेट हो रहा है...",
+3
View File
@@ -2462,6 +2462,9 @@ export const id: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Pengaturan Anda tersimpan.",
tryAgain: "Coba lagi",
tryDifferentFile: "Coba file lain",
}, },
homePage: { homePage: {
generatingPreview: "Membuat pratinjau...", generatingPreview: "Membuat pratinjau...",
+3
View File
@@ -2461,6 +2461,9 @@ export const it: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Le impostazioni sono state salvate.",
tryAgain: "Riprova",
tryDifferentFile: "Prova un file diverso",
}, },
homePage: { homePage: {
generatingPreview: "Generazione anteprima...", generatingPreview: "Generazione anteprima...",
+3
View File
@@ -2420,6 +2420,9 @@ export const ja: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "設定は保存されています。",
tryAgain: "再試行",
tryDifferentFile: "別のファイルを試す",
}, },
homePage: { homePage: {
generatingPreview: "プレビューを生成中...", generatingPreview: "プレビューを生成中...",
+3
View File
@@ -2405,6 +2405,9 @@ export const ko: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "설정이 저장되었습니다.",
tryAgain: "다시 시도",
tryDifferentFile: "다른 파일 시도",
}, },
homePage: { homePage: {
generatingPreview: "미리보기 생성 중...", generatingPreview: "미리보기 생성 중...",
+3
View File
@@ -2464,6 +2464,9 @@ export const nl: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Uw instellingen zijn opgeslagen.",
tryAgain: "Opnieuw proberen",
tryDifferentFile: "Een ander bestand proberen",
}, },
homePage: { homePage: {
generatingPreview: "Preview genereren...", generatingPreview: "Preview genereren...",
+3
View File
@@ -2465,6 +2465,9 @@ export const pl: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Twoje ustawienia zostaly zapisane.",
tryAgain: "Sprobuj ponownie",
tryDifferentFile: "Sprobuj inny plik",
}, },
homePage: { homePage: {
generatingPreview: "Generowanie podglądu...", generatingPreview: "Generowanie podglądu...",
+3
View File
@@ -2461,6 +2461,9 @@ export const ptBR: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Suas configuracoes foram salvas.",
tryAgain: "Tentar novamente",
tryDifferentFile: "Tentar outro arquivo",
}, },
homePage: { homePage: {
generatingPreview: "Gerando visualizacao...", generatingPreview: "Gerando visualizacao...",
+3
View File
@@ -2463,6 +2463,9 @@ export const ru: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Ваши настройки сохранены.",
tryAgain: "Попробовать снова",
tryDifferentFile: "Попробовать другой файл",
}, },
homePage: { homePage: {
generatingPreview: "Генерация предпросмотра...", generatingPreview: "Генерация предпросмотра...",
+3
View File
@@ -2460,6 +2460,9 @@ export const sv: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Dina installningar ar sparade.",
tryAgain: "Forsok igen",
tryDifferentFile: "Prova en annan fil",
}, },
homePage: { homePage: {
generatingPreview: "Genererar forhandsvisning...", generatingPreview: "Genererar forhandsvisning...",
+3
View File
@@ -2442,6 +2442,9 @@ export const th: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "บันทึกการตั้งค่าของคุณแล้ว",
tryAgain: "ลองอีกครั้ง",
tryDifferentFile: "ลองไฟล์อื่น",
}, },
homePage: { homePage: {
generatingPreview: "กำลังสร้างตัวอย่าง...", generatingPreview: "กำลังสร้างตัวอย่าง...",
+3
View File
@@ -2465,6 +2465,9 @@ export const tr: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Ayarlariniz kaydedildi.",
tryAgain: "Tekrar dene",
tryDifferentFile: "Farkli bir dosya dene",
}, },
homePage: { homePage: {
generatingPreview: "Önizleme oluşturuluyor...", generatingPreview: "Önizleme oluşturuluyor...",
+3
View File
@@ -2463,6 +2463,9 @@ export const uk: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Ваші налаштування збережено.",
tryAgain: "Спробувати знову",
tryDifferentFile: "Спробувати інший файл",
}, },
homePage: { homePage: {
generatingPreview: "Генерація попереднього перегляду...", generatingPreview: "Генерація попереднього перегляду...",
+3
View File
@@ -2462,6 +2462,9 @@ export const vi: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "Cai dat cua ban da duoc luu.",
tryAgain: "Thu lai",
tryDifferentFile: "Thu tep khac",
}, },
homePage: { homePage: {
generatingPreview: "Đang tạo bản xem trước...", generatingPreview: "Đang tạo bản xem trước...",
+3
View File
@@ -2395,6 +2395,9 @@ export const zhCN: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "您的设置已保存。",
tryAgain: "重试",
tryDifferentFile: "尝试其他文件",
}, },
homePage: { homePage: {
generatingPreview: "正在生成预览...", generatingPreview: "正在生成预览...",
+3
View File
@@ -2393,6 +2393,9 @@ export const zhTW: TranslationKeys = {
saved: "Saved", saved: "Saved",
noChange: "no change", noChange: "no change",
download: "Download", download: "Download",
settingsSaved: "您的設定已儲存。",
tryAgain: "重試",
tryDifferentFile: "嘗試其他檔案",
}, },
homePage: { homePage: {
generatingPreview: "正在產生預覽...", generatingPreview: "正在產生預覽...",