feat(i18n): translate remaining hardcoded strings (audit finding #14)
Adds 6 new message namespaces (DeleteAccount, NotificationsPage,
PhotoPostForm, StoryForm, MilestoneForm, VideoUploadForm, StoryViewer,
StoryTray, RepostCard) plus a Health.share sub-namespace, with full
EN/DE parity (verified programmatically, 0 keys missing on either side):
- DeleteAccountDialog.tsx: was 100% hardcoded German for a destructive,
irreversible action — now fully translated.
- notifications/page.tsx: mixed hardcoded English (FOLLOW/REACTION/...)
with hardcoded German (BIRTHDAY/ADOPTION_DAY) in the same list.
- HealthDashboard.tsx + WeightChart.tsx: date formatting was hardcoded
to toLocaleDateString("de-DE", ...) regardless of locale, unlike the
DateWheelPicker in the same forms which correctly used useLocale().
ShareDialog was entirely untranslated German alongside fully-translated
sibling tabs.
- PhotoPostForm/MilestoneForm/StoryForm/VideoUploadForm: the entire
post-creation flow had zero i18n despite the parent PostTypeSheet
being fully translated.
- StoryViewer/StoryTray/RepostCard: no i18n at all.
health-card/[token]/page.tsx passes a fixed "de-DE" locale to the now
locale-aware WeightChart, since that whole page is a separate,
out-of-audit-scope public page that's intentionally all-German.
This commit is contained in:
1 parent
4fcde7eb2a
commit
4ba6bbc0c3
14 files changed
+437
-148
No files matched your search
+130
-1
@@ -234,7 +234,8 @@
|
||||
"toastDeleted": "Eintrag gelöscht",
|
||||
"toastError": "Gewichtseintrag konnte nicht gespeichert werden",
|
||||
"toastDeleteError": "Eintrag konnte nicht gelöscht werden",
|
||||
"toastValidation": "Bitte Datum und gültiges Gewicht eingeben"
|
||||
"toastValidation": "Bitte Datum und gültiges Gewicht eingeben",
|
||||
"chartHistory": "Verlauf"
|
||||
},
|
||||
"vet": {
|
||||
"add": "Besuch hinzufügen",
|
||||
@@ -295,6 +296,134 @@
|
||||
"toastDeleted": "Fütterungsinfo entfernt",
|
||||
"toastError": "Fütterungsinfo konnte nicht gespeichert werden",
|
||||
"toastDeleteError": "Fütterungsinfo konnte nicht entfernt werden"
|
||||
},
|
||||
"share": {
|
||||
"title": "Health Card teilen — {petName}",
|
||||
"description": "Jeder mit diesem Link kann {petName}s Gesundheitsdaten einsehen — Gewicht, Impfungen und Tierarztbesuche. Kein Login erforderlich.",
|
||||
"validityLabel": "Gültigkeitsdauer",
|
||||
"expiryDays": "{count} Tage",
|
||||
"generate": "Generieren",
|
||||
"validUntil": "Gültig bis",
|
||||
"openPdf": "Als PDF öffnen / drucken",
|
||||
"regenerateTitle": "Link neu generieren",
|
||||
"regenerateDesc": "Erstellt einen neuen Link und macht den aktuellen ungültig. Nützlich zum Widerrufen von Zugriffen.",
|
||||
"toastGenerateError": "Link konnte nicht generiert werden",
|
||||
"toastRegenerateSuccess": "Neuer Link erstellt — alter Link ist jetzt ungültig",
|
||||
"toastRegenerateError": "Link konnte nicht neu generiert werden"
|
||||
}
|
||||
},
|
||||
"DeleteAccount": {
|
||||
"trigger": "Konto löschen",
|
||||
"warningTitle": "Konto wirklich löschen?",
|
||||
"warningIntro": "Diese Aktion ist unwiderruflich. Folgendes wird dauerhaft gelöscht:",
|
||||
"itemPets": "Alle Tierprofile",
|
||||
"itemPosts": "Alle Posts, Kommentare und Reaktionen",
|
||||
"itemMedia": "Alle Bilder und Videos",
|
||||
"itemMessages": "Alle Chats und Nachrichten",
|
||||
"itemHealth": "Alle Gesundheitsdaten",
|
||||
"itemAccount": "Dein PawFeed-Konto",
|
||||
"cancel": "Abbrechen",
|
||||
"understand": "Ich verstehe, weiter",
|
||||
"confirmTitle": "Bestätigung erforderlich",
|
||||
"confirmIntro": "Gib den Namen eines deiner Haustiere ein, um die Löschung zu bestätigen:",
|
||||
"inputPlaceholder": "Tiername eingeben…",
|
||||
"confirmDelete": "Konto endgültig löschen",
|
||||
"deleting": "Konto wird gelöscht…",
|
||||
"doneTitle": "Konto erfolgreich gelöscht.",
|
||||
"doneRedirect": "Du wirst weitergeleitet…",
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"genericError": "Etwas ist schiefgelaufen"
|
||||
},
|
||||
"NotificationsPage": {
|
||||
"title": "Benachrichtigungen",
|
||||
"emptyTitle": "Noch keine Benachrichtigungen",
|
||||
"emptyDesc": "Wenn Tiere dir folgen oder auf deine Posts reagieren, siehst du es hier.",
|
||||
"typeFollow": "folgt dir jetzt",
|
||||
"typeReaction": "hat auf deinen Post reagiert",
|
||||
"commentedPrefix": "kommentierte:",
|
||||
"typeCommentNoBody": "hat deinen Post kommentiert",
|
||||
"typeMessage": "hat dir eine Nachricht gesendet",
|
||||
"typeMention": "hat dich in einem Kommentar erwähnt",
|
||||
"typePawBack": "hat deinen Post geteilt (Paw-Back)",
|
||||
"typeBirthday": "🎂 Alles Gute zum Geburtstag! Erstelle einen Jubiläums-Post.",
|
||||
"typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag."
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Bildunterschrift schreiben…",
|
||||
"captionError": "Bildunterschrift darf höchstens 500 Zeichen haben.",
|
||||
"charactersRemaining": "Noch {count} Zeichen",
|
||||
"shareCta": "Post teilen",
|
||||
"sharing": "Wird geteilt…",
|
||||
"successToast": "Post geteilt",
|
||||
"errorToast": "Dein Post konnte nicht geteilt werden. Bitte versuch es erneut.",
|
||||
"noPetSelected": "Kein aktives Tier ausgewählt.",
|
||||
"noPhoto": "Bitte füge mindestens ein Foto hinzu."
|
||||
},
|
||||
"StoryForm": {
|
||||
"addPhoto": "Foto hinzufügen",
|
||||
"disappearsNote": "Storys verschwinden nach 24 Stunden",
|
||||
"changePhoto": "Foto ändern",
|
||||
"postCta": "Story posten",
|
||||
"posting": "Wird gepostet…",
|
||||
"successToast": "Story gepostet",
|
||||
"errorToast": "Deine Story konnte nicht gepostet werden. Bitte versuch es erneut.",
|
||||
"noPetSelected": "Kein aktives Tier ausgewählt.",
|
||||
"fileTooLarge": "Foto muss unter 10 MB sein.",
|
||||
"invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
|
||||
"uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut."
|
||||
},
|
||||
"MilestoneForm": {
|
||||
"questionLabel": "Was ist der Meilenstein?",
|
||||
"selectPlaceholder": "Meilenstein auswählen…",
|
||||
"litterDetailsLabel": "Wurf-Details",
|
||||
"optional": "(optional)",
|
||||
"litterPlaceholder": "z.B. 5 gesunde Kätzchen, geboren am 13. Juni…",
|
||||
"charactersRemaining": "Noch {count} Zeichen",
|
||||
"photoLabel": "Foto",
|
||||
"addPhoto": "Foto hinzufügen",
|
||||
"change": "Ändern",
|
||||
"noteLabel": "Notiz hinzufügen",
|
||||
"notePlaceholder": "Notiz hinzufügen… (optional)",
|
||||
"shareCta": "Meilenstein teilen",
|
||||
"sharing": "Wird geteilt…",
|
||||
"successToast": "Meilenstein geteilt",
|
||||
"errorToast": "Meilenstein konnte nicht geteilt werden. Bitte versuch es erneut.",
|
||||
"fileTooLarge": "Foto muss unter 10 MB sein.",
|
||||
"invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
|
||||
"uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut."
|
||||
},
|
||||
"VideoUploadForm": {
|
||||
"selectVideo": "Video auswählen",
|
||||
"formatHint": "MP4, MOV oder WebM · bis 500 MB · max. 3 Minuten",
|
||||
"selectVideoAria": "Video auswählen",
|
||||
"changeVideo": "Anderes Video wählen",
|
||||
"captionLabel": "Bildunterschrift",
|
||||
"captionPlaceholder": "Bildunterschrift schreiben…",
|
||||
"charactersLeft": "Noch {count}",
|
||||
"uploading": "Wird hochgeladen…",
|
||||
"processingDone": "Hochgeladen — Video wird verarbeitet",
|
||||
"uploadFailedGeneric": "Upload fehlgeschlagen — bitte versuch es erneut",
|
||||
"cancel": "Abbrechen",
|
||||
"postVideo": "Video posten",
|
||||
"invalidType": "Lade ein MP4-, MOV- oder WebM-Video hoch.",
|
||||
"fileTooLarge": "Video muss unter 500 MB sein.",
|
||||
"uploadSuccessToast": "Video hochgeladen — wird im Hintergrund verarbeitet",
|
||||
"uploadFailedToast": "Upload fehlgeschlagen: {msg}"
|
||||
},
|
||||
"StoryViewer": {
|
||||
"closeStory": "Story schließen",
|
||||
"storyAriaLabel": "Story von {petName}",
|
||||
"sheetTitleZero": "Noch keine Aufrufe",
|
||||
"sheetTitleSingular": "Von 1 Tier gesehen",
|
||||
"sheetTitlePlural": "Von {count} Tieren gesehen",
|
||||
"noViewersYet": "Noch niemand hat diese Story angesehen."
|
||||
},
|
||||
"StoryTray": {
|
||||
"storiesAriaLabel": "Storys",
|
||||
"addStory": "Story hinzufügen",
|
||||
"newStoryTitle": "Neue Story"
|
||||
},
|
||||
"RepostCard": {
|
||||
"pawedBackBy": "Paw-Back von"
|
||||
}
|
||||
}
|
||||
+130
-1
@@ -234,7 +234,8 @@
|
||||
"toastDeleted": "Entry deleted",
|
||||
"toastError": "Failed to save weight entry",
|
||||
"toastDeleteError": "Failed to delete entry",
|
||||
"toastValidation": "Please fill in date and a valid weight"
|
||||
"toastValidation": "Please fill in date and a valid weight",
|
||||
"chartHistory": "History"
|
||||
},
|
||||
"vet": {
|
||||
"add": "Add Visit",
|
||||
@@ -295,6 +296,134 @@
|
||||
"toastDeleted": "Feeding info removed",
|
||||
"toastError": "Could not save feeding info",
|
||||
"toastDeleteError": "Could not remove feeding info"
|
||||
},
|
||||
"share": {
|
||||
"title": "Share Health Card — {petName}",
|
||||
"description": "Anyone with this link can view {petName}'s health data — weight, vaccines, and vet visits. No login required.",
|
||||
"validityLabel": "Validity period",
|
||||
"expiryDays": "{count} days",
|
||||
"generate": "Generate",
|
||||
"validUntil": "Valid until",
|
||||
"openPdf": "Open as PDF / Print",
|
||||
"regenerateTitle": "Regenerate link",
|
||||
"regenerateDesc": "Creates a new link and invalidates the current one. Useful for revoking access.",
|
||||
"toastGenerateError": "Could not generate link",
|
||||
"toastRegenerateSuccess": "New link created — old link is now invalid",
|
||||
"toastRegenerateError": "Could not regenerate link"
|
||||
}
|
||||
},
|
||||
"DeleteAccount": {
|
||||
"trigger": "Delete account",
|
||||
"warningTitle": "Delete your account?",
|
||||
"warningIntro": "This action is irreversible. The following will be permanently deleted:",
|
||||
"itemPets": "All pet profiles",
|
||||
"itemPosts": "All posts, comments, and reactions",
|
||||
"itemMedia": "All photos and videos",
|
||||
"itemMessages": "All chats and messages",
|
||||
"itemHealth": "All health records",
|
||||
"itemAccount": "Your PawFeed account",
|
||||
"cancel": "Cancel",
|
||||
"understand": "I understand, continue",
|
||||
"confirmTitle": "Confirmation required",
|
||||
"confirmIntro": "Enter the name of one of your pets to confirm deletion:",
|
||||
"inputPlaceholder": "Enter pet name…",
|
||||
"confirmDelete": "Permanently delete account",
|
||||
"deleting": "Deleting account…",
|
||||
"doneTitle": "Account successfully deleted.",
|
||||
"doneRedirect": "You're being redirected…",
|
||||
"unknownError": "Unknown error",
|
||||
"genericError": "Something went wrong"
|
||||
},
|
||||
"NotificationsPage": {
|
||||
"title": "Notifications",
|
||||
"emptyTitle": "No notifications yet",
|
||||
"emptyDesc": "When pets follow you or react to your posts, you'll see it here.",
|
||||
"typeFollow": "started following you",
|
||||
"typeReaction": "reacted to your post",
|
||||
"commentedPrefix": "commented:",
|
||||
"typeCommentNoBody": "commented on your post",
|
||||
"typeMessage": "sent you a message",
|
||||
"typeMention": "mentioned you in a comment",
|
||||
"typePawBack": "shared your post (Paw-Back)",
|
||||
"typeBirthday": "🎂 Happy Birthday! Create an anniversary post.",
|
||||
"typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary."
|
||||
},
|
||||
"PhotoPostForm": {
|
||||
"captionPlaceholder": "Write a caption…",
|
||||
"captionError": "Caption must be 500 characters or fewer.",
|
||||
"charactersRemaining": "{count} characters remaining",
|
||||
"shareCta": "Share Post",
|
||||
"sharing": "Sharing…",
|
||||
"successToast": "Post shared",
|
||||
"errorToast": "Couldn't share your post. Please try again.",
|
||||
"noPetSelected": "No active pet selected.",
|
||||
"noPhoto": "Please add at least one photo."
|
||||
},
|
||||
"StoryForm": {
|
||||
"addPhoto": "Add a photo",
|
||||
"disappearsNote": "Stories disappear after 24 hours",
|
||||
"changePhoto": "Change photo",
|
||||
"postCta": "Post Story",
|
||||
"posting": "Posting…",
|
||||
"successToast": "Story posted",
|
||||
"errorToast": "Couldn't post your story. Please try again.",
|
||||
"noPetSelected": "No active pet selected.",
|
||||
"fileTooLarge": "Photo must be under 10 MB.",
|
||||
"invalidType": "Upload a JPG, PNG, or WebP image.",
|
||||
"uploadFailed": "Upload failed — please try again."
|
||||
},
|
||||
"MilestoneForm": {
|
||||
"questionLabel": "What's the milestone?",
|
||||
"selectPlaceholder": "Select a milestone…",
|
||||
"litterDetailsLabel": "Litter details",
|
||||
"optional": "(optional)",
|
||||
"litterPlaceholder": "e.g. 5 healthy kittens, born 13 June…",
|
||||
"charactersRemaining": "{count} characters remaining",
|
||||
"photoLabel": "Photo",
|
||||
"addPhoto": "Add a photo",
|
||||
"change": "Change",
|
||||
"noteLabel": "Add a note",
|
||||
"notePlaceholder": "Add a note… (optional)",
|
||||
"shareCta": "Share Milestone",
|
||||
"sharing": "Sharing…",
|
||||
"successToast": "Milestone shared",
|
||||
"errorToast": "Couldn't share this milestone. Please try again.",
|
||||
"fileTooLarge": "Photo must be under 10 MB.",
|
||||
"invalidType": "Upload a JPG, PNG, or WebP image.",
|
||||
"uploadFailed": "Upload failed — please try again."
|
||||
},
|
||||
"VideoUploadForm": {
|
||||
"selectVideo": "Select a video",
|
||||
"formatHint": "MP4, MOV or WebM · up to 500 MB · max 3 minutes",
|
||||
"selectVideoAria": "Select video",
|
||||
"changeVideo": "Choose a different video",
|
||||
"captionLabel": "Caption",
|
||||
"captionPlaceholder": "Write a caption…",
|
||||
"charactersLeft": "{count} left",
|
||||
"uploading": "Uploading…",
|
||||
"processingDone": "Uploaded — video is being processed",
|
||||
"uploadFailedGeneric": "Upload failed — please try again",
|
||||
"cancel": "Cancel",
|
||||
"postVideo": "Post video",
|
||||
"invalidType": "Upload an MP4, MOV, or WebM video.",
|
||||
"fileTooLarge": "Video must be under 500 MB.",
|
||||
"uploadSuccessToast": "Video uploaded — processing in the background",
|
||||
"uploadFailedToast": "Upload failed: {msg}"
|
||||
},
|
||||
"StoryViewer": {
|
||||
"closeStory": "Close story",
|
||||
"storyAriaLabel": "{petName}'s story",
|
||||
"sheetTitleZero": "No views yet",
|
||||
"sheetTitleSingular": "Seen by 1 pet",
|
||||
"sheetTitlePlural": "Seen by {count} pets",
|
||||
"noViewersYet": "No one has viewed this story yet."
|
||||
},
|
||||
"StoryTray": {
|
||||
"storiesAriaLabel": "Stories",
|
||||
"addStory": "Add story",
|
||||
"newStoryTitle": "New Story"
|
||||
},
|
||||
"RepostCard": {
|
||||
"pawedBackBy": "Pawed-back by"
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export default function NotificationsPage() {
|
||||
const router = useRouter();
|
||||
const { activePetId } = useActivePet();
|
||||
const trt = useTranslations("RelativeTime");
|
||||
const t = useTranslations("NotificationsPage");
|
||||
const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -81,7 +82,7 @@ export default function NotificationsPage() {
|
||||
<div className="max-w-[640px] mx-auto px-4 py-8">
|
||||
<h1 className="text-xl font-semibold mb-6 flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-orange-500" aria-hidden="true" />
|
||||
Notifications
|
||||
{t("title")}
|
||||
</h1>
|
||||
|
||||
{isLoading && (
|
||||
@@ -101,9 +102,9 @@ export default function NotificationsPage() {
|
||||
{!isLoading && notifications.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 py-20 text-muted-foreground">
|
||||
<Bell className="h-10 w-10 opacity-30" />
|
||||
<p className="text-sm">No notifications yet</p>
|
||||
<p className="text-sm">{t("emptyTitle")}</p>
|
||||
<p className="text-xs text-center max-w-[240px]">
|
||||
When pets follow you or react to your posts, you'll see it here.
|
||||
{t("emptyDesc")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -164,22 +165,22 @@ export default function NotificationsPage() {
|
||||
</Link>
|
||||
)}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{notif.type === "FOLLOW" && "started following you"}
|
||||
{notif.type === "REACTION" && "reacted to your post"}
|
||||
{notif.type === "FOLLOW" && t("typeFollow")}
|
||||
{notif.type === "REACTION" && t("typeReaction")}
|
||||
{notif.type === "COMMENT" && notif.comment && (
|
||||
<>
|
||||
commented: “
|
||||
{t("commentedPrefix")} “
|
||||
{notif.comment.body.slice(0, 60)}
|
||||
{notif.comment.body.length > 60 ? "…" : ""}
|
||||
”
|
||||
</>
|
||||
)}
|
||||
{notif.type === "COMMENT" && !notif.comment && "commented on your post"}
|
||||
{notif.type === "MESSAGE" && "sent you a message"}
|
||||
{notif.type === "MENTION" && "mentioned you in a comment"}
|
||||
{notif.type === "PAW_BACK" && "shared your post (Paw-Back)"}
|
||||
{notif.type === "BIRTHDAY" && "🎂 Alles Gute zum Geburtstag! Erstelle einen Jubiläums-Post."}
|
||||
{notif.type === "ADOPTION_DAY" && "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag."}
|
||||
{notif.type === "COMMENT" && !notif.comment && t("typeCommentNoBody")}
|
||||
{notif.type === "MESSAGE" && t("typeMessage")}
|
||||
{notif.type === "MENTION" && t("typeMention")}
|
||||
{notif.type === "PAW_BACK" && t("typePawBack")}
|
||||
{notif.type === "BIRTHDAY" && t("typeBirthday")}
|
||||
{notif.type === "ADOPTION_DAY" && t("typeAdoptionDay")}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
|
||||
@@ -183,7 +183,10 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
|
||||
</tbody>
|
||||
</table>
|
||||
{pet.weightLogs.length >= 2 && (
|
||||
<WeightChart data={pet.weightLogs} />
|
||||
// This page is entirely hardcoded German (not part of the i18n audit
|
||||
// scope — unlike HealthDashboard/WeightChart's in-app usage), so pass
|
||||
// a fixed locale here rather than the viewer's locale to stay consistent.
|
||||
<WeightChart data={pet.weightLogs} locale="de-DE" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Trash2, TriangleAlert, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -19,6 +20,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
|
||||
const t = useTranslations("DeleteAccount");
|
||||
// Lazy initial state (not an effect) — lets the legal-update banner
|
||||
// deep-link straight into this dialog via ?deleteAccount=1.
|
||||
const [open, setOpen] = useState(() => !!defaultOpen);
|
||||
@@ -56,7 +58,7 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
|
||||
|
||||
if (!res.ok) {
|
||||
const data = (await res.json()) as { error?: string };
|
||||
throw new Error(data.error ?? "Unbekannter Fehler");
|
||||
throw new Error(data.error ?? t("unknownError"));
|
||||
}
|
||||
|
||||
setStep("done");
|
||||
@@ -64,11 +66,20 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
|
||||
window.location.replace("https://pawfeed.org");
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Etwas ist schiefgelaufen");
|
||||
setError(err instanceof Error ? err.message : t("genericError"));
|
||||
setStep("confirm");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItems = [
|
||||
t("itemPets"),
|
||||
t("itemPosts"),
|
||||
t("itemMedia"),
|
||||
t("itemMessages"),
|
||||
t("itemHealth"),
|
||||
t("itemAccount"),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -76,7 +87,7 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
|
||||
onClick={handleOpen}
|
||||
className="text-xs text-destructive/50 hover:text-destructive transition-colors"
|
||||
>
|
||||
Konto löschen
|
||||
{t("trigger")}
|
||||
</button>
|
||||
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
@@ -86,23 +97,16 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<TriangleAlert className="h-5 w-5 shrink-0" />
|
||||
Konto wirklich löschen?
|
||||
{t("warningTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Diese Aktion ist <strong>unwiderruflich</strong>. Folgendes wird dauerhaft gelöscht:
|
||||
{t("warningIntro")}
|
||||
</p>
|
||||
|
||||
<ul className="mt-3 space-y-1.5 text-sm text-muted-foreground">
|
||||
{[
|
||||
"Alle Tierprofile",
|
||||
"Alle Posts, Kommentare und Reaktionen",
|
||||
"Alle Bilder und Videos",
|
||||
"Alle Chats und Nachrichten",
|
||||
"Alle Gesundheitsdaten",
|
||||
"Dein PawFeed-Konto",
|
||||
].map((item) => (
|
||||
{deleteItems.map((item) => (
|
||||
<li key={item} className="flex items-center gap-2">
|
||||
<span className="text-destructive font-bold">✕</span>
|
||||
{item}
|
||||
@@ -112,10 +116,10 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
|
||||
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Abbrechen
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => setStep("confirm")}>
|
||||
Ich verstehe, weiter
|
||||
{t("understand")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -126,12 +130,12 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<Trash2 className="h-5 w-5 shrink-0" />
|
||||
Bestätigung erforderlich
|
||||
{t("confirmTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Gib den Namen eines deiner Haustiere ein, um die Löschung zu bestätigen:
|
||||
{t("confirmIntro")}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
@@ -151,7 +155,7 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
|
||||
className="mt-3"
|
||||
value={input}
|
||||
onChange={(e) => { setInput(e.target.value); setError(null); }}
|
||||
placeholder="Tiername eingeben…"
|
||||
placeholder={t("inputPlaceholder")}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -161,14 +165,14 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
|
||||
|
||||
<div className="flex gap-2 justify-end mt-4">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Abbrechen
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!isValidName}
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Konto endgültig löschen
|
||||
{t("confirmDelete")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
@@ -177,15 +181,15 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
|
||||
{step === "loading" && (
|
||||
<div className="flex flex-col items-center gap-4 py-10">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-orange-500" />
|
||||
<p className="text-sm text-muted-foreground">Konto wird gelöscht…</p>
|
||||
<p className="text-sm text-muted-foreground">{t("deleting")}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<div className="flex flex-col items-center gap-4 py-10">
|
||||
<CheckCircle2 className="h-8 w-8 text-emerald-500" />
|
||||
<p className="text-sm font-medium">Konto erfolgreich gelöscht.</p>
|
||||
<p className="text-xs text-muted-foreground">Du wirst weitergeleitet…</p>
|
||||
<p className="text-sm font-medium">{t("doneTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("doneRedirect")}</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PawPrint } from "lucide-react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -39,6 +40,7 @@ function RepostCardInner({
|
||||
post: PostCardPost;
|
||||
originalPost: NonNullable<PostCardPost["repost"]>["originalPost"];
|
||||
}) {
|
||||
const t = useTranslations("RepostCard");
|
||||
const reposterName = post.pet.name;
|
||||
const origPet = originalPost.pet;
|
||||
const origAvatarUrl = getAvatarUrl(origPet.avatarKey);
|
||||
@@ -63,7 +65,7 @@ function RepostCardInner({
|
||||
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
||||
<PawPrint className="h-3.5 w-3.5 text-orange-400" aria-hidden="true" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Pawed-back by{" "}
|
||||
{t("pawedBackBy")}{" "}
|
||||
<a href={`/pets/${post.pet.id}`} className="font-medium hover:underline">
|
||||
@{reposterName}
|
||||
</a>
|
||||
@@ -81,7 +83,7 @@ function RepostCardInner({
|
||||
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
||||
<PawPrint className="h-3.5 w-3.5 text-orange-400" aria-hidden="true" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Pawed-back by{" "}
|
||||
{t("pawedBackBy")}{" "}
|
||||
<Link href={`/pets/${post.pet.id}`} className="font-medium hover:underline">
|
||||
@{reposterName}
|
||||
</Link>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Trash2, Share2, RefreshCw, Copy, Check, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
@@ -37,8 +37,8 @@ interface HealthDashboardProps {
|
||||
petName: string;
|
||||
}
|
||||
|
||||
function formatDate(d: Date | string) {
|
||||
return new Date(d).toLocaleDateString("de-DE", {
|
||||
function formatDate(d: Date | string, locale: string) {
|
||||
return new Date(d).toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
@@ -59,6 +59,7 @@ function isOverdue(date: Date | string): boolean {
|
||||
|
||||
function WeightTab({ petId }: { petId: string }) {
|
||||
const t = useTranslations("Health");
|
||||
const locale = useLocale();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -149,7 +150,7 @@ function WeightTab({ petId }: { petId: string }) {
|
||||
<p className="text-sm text-muted-foreground text-center py-8">{t("weight.noEntries")}</p>
|
||||
)}
|
||||
|
||||
{!isLoading && data.length >= 2 && <WeightChart data={data} />}
|
||||
{!isLoading && data.length >= 2 && <WeightChart data={data} locale={locale} />}
|
||||
|
||||
{data.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
@@ -158,7 +159,7 @@ function WeightTab({ petId }: { petId: string }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{formatWeight(entry.weightGrams)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(entry.date)}{entry.notes ? ` · ${entry.notes}` : ""}
|
||||
{formatDate(entry.date, locale)}{entry.notes ? ` · ${entry.notes}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -182,6 +183,7 @@ function WeightTab({ petId }: { petId: string }) {
|
||||
|
||||
function VetVisitsTab({ petId }: { petId: string }) {
|
||||
const t = useTranslations("Health");
|
||||
const locale = useLocale();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -281,7 +283,7 @@ function VetVisitsTab({ petId }: { petId: string }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">{visit.reason}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(visit.date)}{visit.vetName ? ` · ${visit.vetName}` : ""}
|
||||
{formatDate(visit.date, locale)}{visit.vetName ? ` · ${visit.vetName}` : ""}
|
||||
</p>
|
||||
{visit.notes && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{visit.notes}</p>
|
||||
@@ -308,6 +310,7 @@ function VetVisitsTab({ petId }: { petId: string }) {
|
||||
|
||||
function VaccinesTab({ petId }: { petId: string }) {
|
||||
const t = useTranslations("Health");
|
||||
const locale = useLocale();
|
||||
const trpc = useTRPC();
|
||||
const qc = useQueryClient();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -416,12 +419,12 @@ function VaccinesTab({ petId }: { petId: string }) {
|
||||
)}
|
||||
{vac.nextDueDate && !overdue && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t("vaccines.badgeDue", { date: formatDate(vac.nextDueDate) })}
|
||||
{t("vaccines.badgeDue", { date: formatDate(vac.nextDueDate, locale) })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("vaccines.textGiven", { date: formatDate(vac.dateGiven) })}{vac.notes ? ` · ${vac.notes}` : ""}
|
||||
{t("vaccines.textGiven", { date: formatDate(vac.dateGiven, locale) })}{vac.notes ? ` · ${vac.notes}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -444,13 +447,7 @@ function VaccinesTab({ petId }: { petId: string }) {
|
||||
|
||||
// ── Share Dialog ──────────────────────────────────────────────────────────────
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ value: "7", label: "7 Tage" },
|
||||
{ value: "14", label: "14 Tage" },
|
||||
{ value: "30", label: "30 Tage" },
|
||||
{ value: "60", label: "60 Tage" },
|
||||
{ value: "90", label: "90 Tage" },
|
||||
] as const;
|
||||
const EXPIRY_VALUES = ["7", "14", "30", "60", "90"] as const;
|
||||
|
||||
function ShareDialog({ petId, petName, open, onOpenChange }: {
|
||||
petId: string;
|
||||
@@ -458,6 +455,8 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
}) {
|
||||
const t = useTranslations("Health.share");
|
||||
const locale = useLocale();
|
||||
const trpc = useTRPC();
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
|
||||
@@ -470,7 +469,7 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
|
||||
setToken(data.token);
|
||||
setExpiresAt(data.expiresAt ? new Date(data.expiresAt) : null);
|
||||
},
|
||||
onError: () => toast.error("Link konnte nicht generiert werden"),
|
||||
onError: () => toast.error(t("toastGenerateError")),
|
||||
});
|
||||
|
||||
const regenerateMutation = useMutation({
|
||||
@@ -478,9 +477,9 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
|
||||
onSuccess: (data) => {
|
||||
setToken(data.token);
|
||||
setExpiresAt(data.expiresAt ? new Date(data.expiresAt) : null);
|
||||
toast.success("Neuer Link erstellt — alter Link ist jetzt ungültig");
|
||||
toast.success(t("toastRegenerateSuccess"));
|
||||
},
|
||||
onError: () => toast.error("Link konnte nicht neu generiert werden"),
|
||||
onError: () => toast.error(t("toastRegenerateError")),
|
||||
});
|
||||
|
||||
const handleOpen = (v: boolean) => {
|
||||
@@ -514,27 +513,29 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
|
||||
<Dialog open={open} onOpenChange={handleOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Health Card teilen — {petName}</DialogTitle>
|
||||
<DialogTitle>{t("title", { petName })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Jeder mit diesem Link kann {petName}'s Gesundheitsdaten einsehen — Gewicht, Impfungen und Tierarztbesuche. Kein Login erforderlich.
|
||||
{t("description", { petName })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">Gültigkeitsdauer</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t("validityLabel")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={expiryDays} onValueChange={(v) => setExpiryDays(v as typeof expiryDays)}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPIRY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
{EXPIRY_VALUES.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{t("expiryDays", { count: value })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm" onClick={handleGenerate} disabled={isPending} className="shrink-0">
|
||||
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Generieren"}
|
||||
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("generate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -556,22 +557,22 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
|
||||
|
||||
{expiresAt && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gültig bis <span className="font-medium text-foreground">{formatDate(expiresAt)}</span>
|
||||
{t("validUntil")} <span className="font-medium text-foreground">{formatDate(expiresAt, locale)}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button variant="secondary" size="sm" className="w-full gap-2" onClick={handleOpenPdf}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Als PDF öffnen / drucken
|
||||
{t("openPdf")}
|
||||
</Button>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Link neu generieren</p>
|
||||
<p className="text-sm font-medium">{t("regenerateTitle")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Erstellt einen neuen Link und macht den aktuellen ungültig. Nützlich zum Widerrufen von Zugriffen.
|
||||
{t("regenerateDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type ChartEntry = { date: Date | string; weightGrams: number };
|
||||
|
||||
function formatDate(d: Date | string) {
|
||||
return new Date(d).toLocaleDateString("de-DE", {
|
||||
function formatDate(d: Date | string, locale: string) {
|
||||
return new Date(d).toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
@@ -17,7 +18,8 @@ function formatWeight(grams: number): string {
|
||||
return `${parseFloat((grams / 1000).toFixed(3))} kg`;
|
||||
}
|
||||
|
||||
export function WeightChart({ data }: { data: ChartEntry[] }) {
|
||||
export function WeightChart({ data, locale }: { data: ChartEntry[]; locale: string }) {
|
||||
const t = useTranslations("Health.weight");
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
||||
|
||||
const sorted = [...data].sort(
|
||||
@@ -86,7 +88,7 @@ export function WeightChart({ data }: { data: ChartEntry[] }) {
|
||||
<div className="rounded-xl border bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-medium text-muted-foreground tracking-wide uppercase">
|
||||
Verlauf
|
||||
{t("chartHistory")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{trendLabel}
|
||||
@@ -195,7 +197,7 @@ export function WeightChart({ data }: { data: ChartEntry[] }) {
|
||||
{hoveredIdx !== null && (() => {
|
||||
const p = pts[hoveredIdx];
|
||||
const weight = formatWeight(p.e.weightGrams);
|
||||
const dateStr = formatDate(p.e.date);
|
||||
const dateStr = formatDate(p.e.date, locale);
|
||||
const tw = 96, th = 40;
|
||||
let tx = p.x - tw / 2;
|
||||
tx = Math.max(L, Math.min(tx, W - R - tw));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { ChevronDown, Loader2, ImagePlus, Camera } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { MilestoneType } from "@prisma/client";
|
||||
@@ -36,6 +37,7 @@ type UploadState = "idle" | "uploading" | "ready";
|
||||
* On success: invalidate feed, close dialog, route to /feed (D-03).
|
||||
*/
|
||||
export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
const t = useTranslations("MilestoneForm");
|
||||
const trpc = useTRPC();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -66,10 +68,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
queryClient.invalidateQueries({ queryKey: [["trpc", "milestones", "listByPet"]] });
|
||||
onClose();
|
||||
router.push("/feed");
|
||||
toast.success("Milestone shared");
|
||||
toast.success(t("successToast"));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Couldn't share this milestone. Please try again.");
|
||||
toast.error(t("errorToast"));
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -80,11 +82,11 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
setUploadError(null);
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setUploadError("Photo must be under 10 MB.");
|
||||
setUploadError(t("fileTooLarge"));
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) {
|
||||
setUploadError("Upload a JPG, PNG, or WebP image.");
|
||||
setUploadError(t("invalidType"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -114,12 +116,12 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
setUploadedKey(key);
|
||||
setUploadState("ready");
|
||||
} catch {
|
||||
setUploadError("Upload failed — please try again.");
|
||||
setUploadError(t("uploadFailed"));
|
||||
setUploadState("idle");
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
},
|
||||
[activePetId, getPresignedUrlMutation]
|
||||
[activePetId, getPresignedUrlMutation, t]
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
@@ -150,7 +152,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
{/* Type picker */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
What's the milestone?
|
||||
{t("questionLabel")}
|
||||
</label>
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger className="flex items-center justify-between w-full min-h-[44px] px-3 py-2 rounded-md border border-input bg-background text-sm hover:bg-accent hover:text-accent-foreground transition-colors">
|
||||
@@ -160,7 +162,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
{selectedMeta.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select a milestone…</span>
|
||||
<span className="text-muted-foreground">{t("selectPlaceholder")}</span>
|
||||
)}
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
|
||||
</PopoverTrigger>
|
||||
@@ -191,10 +193,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
{selectedType === MilestoneType.LITTER && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Litter details <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
{t("litterDetailsLabel")} <span className="text-muted-foreground font-normal">{t("optional")}</span>
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder="e.g. 5 healthy kittens, born 13 June…"
|
||||
placeholder={t("litterPlaceholder")}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
maxLength={500}
|
||||
@@ -203,7 +205,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
/>
|
||||
{notes.length > 450 && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{500 - notes.length} characters remaining
|
||||
{t("charactersRemaining", { count: 500 - notes.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -212,7 +214,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
{/* Optional photo */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Photo <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
{t("photoLabel")} <span className="text-muted-foreground font-normal">{t("optional")}</span>
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
@@ -227,7 +229,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus className="h-7 w-7 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="text-sm text-muted-foreground">Add a photo</span>
|
||||
<span className="text-sm text-muted-foreground">{t("addPhoto")}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="relative rounded-xl overflow-hidden">
|
||||
@@ -249,7 +251,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Camera className="h-3 w-3 inline mr-1" aria-hidden="true" />
|
||||
Change
|
||||
{t("change")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -260,10 +262,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
{/* Optional caption */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Add a note <span className="text-muted-foreground font-normal">(optional)</span>
|
||||
{t("noteLabel")} <span className="text-muted-foreground font-normal">{t("optional")}</span>
|
||||
</label>
|
||||
<Textarea
|
||||
placeholder="Add a note… (optional)"
|
||||
placeholder={t("notePlaceholder")}
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
maxLength={500}
|
||||
@@ -272,7 +274,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
/>
|
||||
{caption.length > 450 && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{500 - caption.length} characters remaining
|
||||
{t("charactersRemaining", { count: 500 - caption.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -285,10 +287,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
|
||||
Sharing…
|
||||
{t("sharing")}
|
||||
</>
|
||||
) : (
|
||||
"Share Milestone"
|
||||
t("shareCta")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
@@ -37,6 +38,7 @@ interface PhotoPostFormProps {
|
||||
}
|
||||
|
||||
export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
|
||||
const t = useTranslations("PhotoPostForm");
|
||||
const trpc = useTRPC();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -54,27 +56,27 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
|
||||
onClose();
|
||||
// Navigate to /feed with new post at top (D-03)
|
||||
router.push("/feed");
|
||||
toast.success("Post shared");
|
||||
toast.success(t("successToast"));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Couldn't share your post. Please try again.");
|
||||
toast.error(t("errorToast"));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!activePetId) {
|
||||
toast.error("No active pet selected.");
|
||||
toast.error(t("noPetSelected"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (imageKeys.length === 0) {
|
||||
toast.error("Please add at least one photo.");
|
||||
toast.error(t("noPhoto"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (caption.length > 500) {
|
||||
setCaptionError("Caption must be 500 characters or fewer.");
|
||||
setCaptionError(t("captionError"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -83,12 +85,12 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
|
||||
caption: caption.trim() || null,
|
||||
imageKeys,
|
||||
});
|
||||
}, [activePetId, imageKeys, caption, createMutation]);
|
||||
}, [activePetId, imageKeys, caption, createMutation, t]);
|
||||
|
||||
if (!activePetId) {
|
||||
return (
|
||||
<div className="p-6 text-center text-sm text-muted-foreground">
|
||||
No active pet selected.
|
||||
{t("noPetSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,12 +106,12 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
|
||||
{/* Caption textarea */}
|
||||
<div className="space-y-1">
|
||||
<MentionTextarea
|
||||
placeholder="Write a caption…"
|
||||
placeholder={t("captionPlaceholder")}
|
||||
value={caption}
|
||||
onChange={(val) => {
|
||||
setCaption(val);
|
||||
if (val.length > 500) {
|
||||
setCaptionError("Caption must be 500 characters or fewer.");
|
||||
setCaptionError(t("captionError"));
|
||||
} else {
|
||||
setCaptionError(null);
|
||||
}
|
||||
@@ -123,7 +125,7 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
|
||||
)}
|
||||
{caption.length > 450 && !captionError && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{500 - caption.length} characters remaining
|
||||
{t("charactersRemaining", { count: 500 - caption.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -138,7 +140,7 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
|
||||
}
|
||||
className="w-full min-h-[44px] bg-orange-500 hover:bg-orange-600 text-white font-medium"
|
||||
>
|
||||
{createMutation.isPending ? "Sharing…" : "Share Post"}
|
||||
{createMutation.isPending ? t("sharing") : t("shareCta")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Camera, Loader2, ImagePlus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
@@ -29,6 +30,7 @@ type UploadState = "idle" | "uploading" | "ready" | "submitting";
|
||||
* 4. stories.create({ petId, storageKey }) commits the story
|
||||
*/
|
||||
export function StoryForm({ onClose }: StoryFormProps) {
|
||||
const t = useTranslations("StoryForm");
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
@@ -53,11 +55,11 @@ export function StoryForm({ onClose }: StoryFormProps) {
|
||||
queryClient.invalidateQueries(
|
||||
trpc.stories.listActive.queryOptions({ petId: activePetId ?? "" })
|
||||
);
|
||||
toast.success("Story posted");
|
||||
toast.success(t("successToast"));
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Couldn't post your story. Please try again.");
|
||||
toast.error(t("errorToast"));
|
||||
setUploadState("ready");
|
||||
},
|
||||
})
|
||||
@@ -66,18 +68,18 @@ export function StoryForm({ onClose }: StoryFormProps) {
|
||||
const handleFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (!activePetId) {
|
||||
toast.error("No active pet selected.");
|
||||
toast.error(t("noPetSelected"));
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setErrorMessage("Photo must be under 10 MB.");
|
||||
setErrorMessage(t("fileTooLarge"));
|
||||
return;
|
||||
}
|
||||
if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) {
|
||||
setErrorMessage("Upload a JPG, PNG, or WebP image.");
|
||||
setErrorMessage(t("invalidType"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,12 +112,12 @@ export function StoryForm({ onClose }: StoryFormProps) {
|
||||
setUploadedKey(key);
|
||||
setUploadState("ready");
|
||||
} catch {
|
||||
setErrorMessage("Upload failed — please try again.");
|
||||
setErrorMessage(t("uploadFailed"));
|
||||
setUploadState("idle");
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
},
|
||||
[activePetId, getPresignedUrlMutation]
|
||||
[activePetId, getPresignedUrlMutation, t]
|
||||
);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
@@ -147,7 +149,7 @@ export function StoryForm({ onClose }: StoryFormProps) {
|
||||
if ((e.key === "Enter" || e.key === " ") && uploadState === "idle")
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
aria-label="Add a photo"
|
||||
aria-label={t("addPhoto")}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -161,9 +163,9 @@ export function StoryForm({ onClose }: StoryFormProps) {
|
||||
<>
|
||||
<ImagePlus className="h-10 w-10 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">Add a photo</p>
|
||||
<p className="text-sm font-medium">{t("addPhoto")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Stories disappear after 24 hours
|
||||
{t("disappearsNote")}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -195,7 +197,7 @@ export function StoryForm({ onClose }: StoryFormProps) {
|
||||
}}
|
||||
>
|
||||
<Camera className="h-3 w-3 inline mr-1" aria-hidden="true" />
|
||||
Change photo
|
||||
{t("changePhoto")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -207,7 +209,7 @@ export function StoryForm({ onClose }: StoryFormProps) {
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Stories disappear after 24 hours
|
||||
{t("disappearsNote")}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
@@ -218,10 +220,10 @@ export function StoryForm({ onClose }: StoryFormProps) {
|
||||
{uploadState === "submitting" ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
|
||||
Posting…
|
||||
{t("posting")}
|
||||
</>
|
||||
) : (
|
||||
"Post Story"
|
||||
t("postCta")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
@@ -29,6 +30,7 @@ const TRAY_ITEM_SIZE = TRAY_AVATAR_SIZE + RING_PAD * 2; // total height of a Paw
|
||||
* each wrapped in PawRing. Tapping a pet opens the StoryViewer.
|
||||
*/
|
||||
export function StoryTray() {
|
||||
const t = useTranslations("StoryTray");
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
@@ -53,7 +55,7 @@ export function StoryTray() {
|
||||
<div
|
||||
className="flex gap-4 overflow-x-auto px-6 py-3 scrollbar-hide"
|
||||
role="list"
|
||||
aria-label="Stories"
|
||||
aria-label={t("storiesAriaLabel")}
|
||||
>
|
||||
{/* Add story item — wrapper matches PawRing container height so circles align */}
|
||||
<div className="flex flex-col items-center gap-1 shrink-0" role="listitem">
|
||||
@@ -65,13 +67,13 @@ export function StoryTray() {
|
||||
className="flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 transition-colors"
|
||||
style={{ width: TRAY_AVATAR_SIZE, height: TRAY_AVATAR_SIZE }}
|
||||
onClick={() => setAddStoryOpen(true)}
|
||||
aria-label="Add story"
|
||||
aria-label={t("addStory")}
|
||||
>
|
||||
<PlusCircle className="h-7 w-7 text-white" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[56px]">
|
||||
Add story
|
||||
{t("addStory")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -124,7 +126,7 @@ export function StoryTray() {
|
||||
<Dialog open={addStoryOpen} onOpenChange={setAddStoryOpen}>
|
||||
<DialogContent className="sm:max-w-[512px] max-h-[90dvh] overflow-y-auto p-0">
|
||||
<DialogHeader className="px-4 pt-4 pb-2">
|
||||
<DialogTitle className="text-xl font-semibold">New Story</DialogTitle>
|
||||
<DialogTitle className="text-xl font-semibold">{t("newStoryTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<StoryForm onClose={() => setAddStoryOpen(false)} />
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { X, Eye } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
@@ -39,6 +40,7 @@ export function StoryViewer({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: StoryViewerProps) {
|
||||
const t = useTranslations("StoryViewer");
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
@@ -129,7 +131,7 @@ export function StoryViewer({
|
||||
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`${petName}'s story`}
|
||||
aria-label={t("storyAriaLabel", { petName })}
|
||||
>
|
||||
{/* Progress bars */}
|
||||
<div className="absolute top-0 left-0 right-0 flex gap-1 px-3 pt-3">
|
||||
@@ -156,7 +158,7 @@ export function StoryViewer({
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={mediaUrl}
|
||||
alt={`${petName}'s story`}
|
||||
alt={t("storyAriaLabel", { petName })}
|
||||
className="max-h-screen max-w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
@@ -180,7 +182,7 @@ export function StoryViewer({
|
||||
<button
|
||||
className="absolute top-8 right-4 flex items-center justify-center w-11 h-11 rounded-full bg-black/40 text-white hover:bg-black/60"
|
||||
onClick={handleClose}
|
||||
aria-label="Close story"
|
||||
aria-label={t("closeStory")}
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
@@ -190,7 +192,11 @@ export function StoryViewer({
|
||||
<button
|
||||
className="absolute top-8 left-4 flex items-center gap-1.5 px-3 py-2 rounded-full bg-black/40 text-white text-xs hover:bg-black/60"
|
||||
onClick={() => setViewerSheetOpen(true)}
|
||||
aria-label={`Seen by ${viewers.length} pets`}
|
||||
aria-label={
|
||||
viewers.length === 1
|
||||
? t("sheetTitleSingular")
|
||||
: t("sheetTitlePlural", { count: viewers.length })
|
||||
}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span>{viewers.length}</span>
|
||||
@@ -204,14 +210,16 @@ export function StoryViewer({
|
||||
<SheetContent side="bottom" className="pb-8 max-h-[60dvh] overflow-y-auto">
|
||||
<SheetHeader className="mb-4">
|
||||
<SheetTitle className="text-lg font-semibold">
|
||||
{viewers.length > 0
|
||||
? `Seen by ${viewers.length} ${viewers.length === 1 ? "pet" : "pets"}`
|
||||
: "No views yet"}
|
||||
{viewers.length === 0
|
||||
? t("sheetTitleZero")
|
||||
: viewers.length === 1
|
||||
? t("sheetTitleSingular")
|
||||
: t("sheetTitlePlural", { count: viewers.length })}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
{viewers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No one has viewed this story yet.
|
||||
{t("noViewersYet")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { Video, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
@@ -21,6 +22,7 @@ interface VideoUploadFormProps {
|
||||
}
|
||||
|
||||
export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
const t = useTranslations("VideoUploadForm");
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -37,17 +39,17 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
|
||||
const handleFileSelect = useCallback((file: File) => {
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
toast.error("Upload an MP4, MOV, or WebM video.");
|
||||
toast.error(t("invalidType"));
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error("Video must be under 500 MB.");
|
||||
toast.error(t("fileTooLarge"));
|
||||
return;
|
||||
}
|
||||
setFile(file);
|
||||
setPreviewUrl(URL.createObjectURL(file));
|
||||
setUploadState("idle");
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -86,15 +88,15 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
});
|
||||
|
||||
setUploadState("processing");
|
||||
toast.success("Video uploaded — processing in the background");
|
||||
toast.success(t("uploadSuccessToast"));
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error("[VideoUpload]", msg);
|
||||
setUploadState("error");
|
||||
toast.error(`Upload failed: ${msg}`);
|
||||
toast.error(t("uploadFailedToast", { msg }));
|
||||
}
|
||||
}, [file, activePetId, caption, createUploadMutation, onClose]);
|
||||
}, [file, activePetId, caption, createUploadMutation, onClose, t]);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
@@ -108,12 +110,12 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
|
||||
aria-label="Select video"
|
||||
aria-label={t("selectVideoAria")}
|
||||
>
|
||||
<Video className="h-10 w-10 text-muted-foreground mx-auto mb-3" aria-hidden="true" />
|
||||
<p className="text-sm font-medium">Select a video</p>
|
||||
<p className="text-sm font-medium">{t("selectVideo")}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
MP4, MOV or WebM · up to 500 MB · max 3 minutes
|
||||
{t("formatHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -144,17 +146,17 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
onClick={() => { setFile(null); setPreviewUrl(null); }}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Choose a different video
|
||||
{t("changeVideo")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Caption */}
|
||||
{file && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="video-caption">Caption</Label>
|
||||
<Label htmlFor="video-caption">{t("captionLabel")}</Label>
|
||||
<Textarea
|
||||
id="video-caption"
|
||||
placeholder="Write a caption…"
|
||||
placeholder={t("captionPlaceholder")}
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
maxLength={500}
|
||||
@@ -164,7 +166,7 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
/>
|
||||
{caption.length > 450 && (
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{500 - caption.length} left
|
||||
{t("charactersLeft", { count: 500 - caption.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -174,7 +176,7 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
{uploadState === "uploading" && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Uploading…</span>
|
||||
<span>{t("uploading")}</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
@@ -189,14 +191,14 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
{uploadState === "processing" && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
Uploaded — video is being processed
|
||||
{t("processingDone")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadState === "error" && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Upload failed — please try again
|
||||
{t("uploadFailedGeneric")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -208,7 +210,7 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
disabled={uploadState === "uploading"}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
@@ -216,9 +218,9 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
|
||||
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white"
|
||||
>
|
||||
{uploadState === "uploading" ? (
|
||||
<><Loader2 className="h-4 w-4 animate-spin mr-2" /> Uploading…</>
|
||||
<><Loader2 className="h-4 w-4 animate-spin mr-2" /> {t("uploading")}</>
|
||||
) : (
|
||||
"Post video"
|
||||
t("postVideo")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user