diff --git a/app/package-lock.json b/app/package-lock.json index 409a2f0..a16abef 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,19 +1,21 @@ { "name": "app", - "version": "0.0.0", + "version": "1.1.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "app", - "version": "0.0.0", + "version": "1.1.0-rc.1", "dependencies": { "@carbon/icons-react": "^11.79.0", "@carbon/react": "^1.106.0", "@supabase/supabase-js": "^2.104.1", "date-fns": "^4.1.0", + "i18next": "^26.0.8", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-i18next": "^17.0.6" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1066,9 +1068,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1090,9 +1089,6 @@ "cpu": [ "arm" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1114,9 +1110,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1138,9 +1131,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1162,9 +1152,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1186,9 +1173,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1359,9 +1343,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1379,9 +1360,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1399,9 +1377,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1419,9 +1394,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1439,9 +1411,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1459,9 +1428,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2682,6 +2648,43 @@ "dev": true, "license": "MIT" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.8", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz", + "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/iceberg-js": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", @@ -3022,9 +3025,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3046,9 +3046,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3070,9 +3067,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3094,9 +3088,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3515,6 +3506,33 @@ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", "license": "MIT" }, + "node_modules/react-i18next": { + "version": "17.0.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz", + "integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", @@ -3811,6 +3829,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.10", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", @@ -3979,6 +4006,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/app/package.json b/app/package.json index a11f289..7c881d2 100644 --- a/app/package.json +++ b/app/package.json @@ -20,8 +20,10 @@ "@carbon/react": "^1.106.0", "@supabase/supabase-js": "^2.104.1", "date-fns": "^4.1.0", + "i18next": "^26.0.8", "react": "^19.2.5", - "react-dom": "^19.2.5" + "react-dom": "^19.2.5", + "react-i18next": "^17.0.6" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/app/public/fonts/Vazirmatn-Bold.woff2 b/app/public/fonts/Vazirmatn-Bold.woff2 new file mode 100644 index 0000000..65b427f Binary files /dev/null and b/app/public/fonts/Vazirmatn-Bold.woff2 differ diff --git a/app/public/fonts/Vazirmatn-Regular.woff2 b/app/public/fonts/Vazirmatn-Regular.woff2 new file mode 100644 index 0000000..c9824c8 Binary files /dev/null and b/app/public/fonts/Vazirmatn-Regular.woff2 differ diff --git a/app/public/fonts/Vazirmatn-SemiBold.woff2 b/app/public/fonts/Vazirmatn-SemiBold.woff2 new file mode 100644 index 0000000..5301641 Binary files /dev/null and b/app/public/fonts/Vazirmatn-SemiBold.woff2 differ diff --git a/app/public/locales/en/translation.json b/app/public/locales/en/translation.json new file mode 100644 index 0000000..e220bb7 --- /dev/null +++ b/app/public/locales/en/translation.json @@ -0,0 +1,383 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "back": "Back", + "loading": "Loading…", + "error": "Error", + "delete": "Delete", + "edit": "Edit", + "close": "Close", + "saving": "Saving…", + "saved": "Saved", + "primary": "Primary", + "secondary": "Secondary", + "exercises": "Exercises", + "sets": "sets", + "reps": "reps", + "session": "session", + "sessions": "sessions", + "noResults": "No results.", + "resetFilter": "Reset filter", + "none": "none", + "front": "Front", + "back_view": "Back", + "saveFailed": "Saving failed. Try again.", + "add": "Add" + }, + "muscles": { + "chest": "Chest", + "shoulders_front": "Front shoulders", + "shoulders_side": "Side shoulders", + "biceps": "Biceps", + "forearms": "Forearms", + "abs": "Abs", + "obliques": "Obliques", + "quads": "Quads", + "calves": "Calves", + "traps": "Traps", + "rear_delts": "Rear delts", + "lats": "Lats", + "triceps": "Triceps", + "lower_back": "Lower back", + "glutes": "Glutes", + "hamstrings": "Hamstrings", + "calves_back": "Calves (rear)" + }, + "bodymap": { + "front": "front", + "back": "back", + "mapLabel": "Muscle map, {{view}}", + "freqMapLabel": "Training frequency map, {{view}}", + "primaryLabel": "Primary", + "secondaryLabel": "Secondary", + "totalLabel": "Total", + "notTrained": "Not trained", + "ariaPrimary_one": "{{muscle}} – primary: {{count}} session", + "ariaPrimary_other": "{{muscle}} – primary: {{count}} sessions", + "ariaSecondary_one": "{{muscle}} – secondary: {{count}} session", + "ariaSecondary_other": "{{muscle}} – secondary: {{count}} sessions", + "ariaNotTrained": "{{muscle}} – not trained" + }, + "nav": { + "logSession": "Log session", + "history": "Training history", + "report": "Period report", + "library": "Library", + "planner": "Plan week", + "settings": "Settings" + }, + "login": { + "subtitle": "Sign in to continue", + "emailLabel": "Email address", + "emailPlaceholder": "your@email.com", + "sendLink": "Send login link", + "sending": "Sending…", + "checkEmail": "Check your email", + "sentTo": "We sent a login link to", + "failed": "Login failed:" + }, + "home": { + "logNew": "Log new session", + "todaySession": "TODAY'S SESSION", + "lastSession": "LAST SESSION", + "seeAll": "SEE ALL →", + "loading": "Loading last session…", + "noSessions": "No sessions logged yet. Log your first session!", + "ownTraining": "Personal training", + "train": "Train.", + "today": "Today.", + "weekStrip": { + "mon": "M", + "tue": "T", + "wed": "W", + "thu": "T", + "fri": "F", + "sat": "S", + "sun": "S" + } + }, + "muscleMap": { + "sectionLabel": "LOG SESSION", + "stepSnap": "Snap", + "stepConfirm": "Confirm", + "stepResult": "Result", + "heroLine1": "Take a photo of", + "heroLine2": "the board.", + "imageCount_one": "{{count}} photo", + "imageCount_other": "{{count}} photos", + "selected": "selected", + "dropzoneLabel": "Upload training photo", + "dropzoneClick": "Tap to choose photo", + "dropzoneDrag": "or drag and drop · JPEG, PNG, WebP", + "useTemplate": "Template", + "manualEntry": "Enter manually", + "tipsHeading": "Tips", + "tipsBody": "Good lighting and the full board in frame gives the best results. Multiple images supported.", + "analyzeBtn": "Analyze image", + "analyzing": "Reading training program and identifying exercises…", + "foundExercises_one": "{{count}} exercise found.", + "foundExercises_other": "{{count}} exercises found.", + "today": "Today", + "otherDay": "Other day", + "dateLabel": "Date", + "datePlaceholder": "dd/mm/yyyy", + "selectGymSession": "Which class was this?", + "selectGymOptional": "Select class (optional)", + "conflictTitle": "Existing session:", + "conflictBody": "This class already has a saved session ({{date}}). Saving will replace it.", + "musclesViaClaude": "Muscles recognized by Claude", + "musclesViaDB": "Muscles recognized via database", + "musclesUnknown": "Muscles not recognized", + "addManual": "Add exercise manually", + "saveAndShow": "Save and show result", + "hitMuscles1": "You hit", + "hitMuscles2": "muscles.", + "kpiMuscles": "Muscles", + "kpiTime": "Time", + "trainedMuscles": "Trained muscles", + "exercisesThisSession": "Exercises this session", + "nextStep": "Next step", + "nextStepBody": "See which muscles you neglect over time.", + "analyzePeriod": "Analyze the period", + "getRecommendations": "What should I train next time?", + "loadingRecs": "Getting recommendations…", + "recommendedExercises": "Recommended exercises", + "noRecs": "No recommendations available.", + "logNew": "Log new session", + "removeImage": "Remove image {{n}}", + "imageAlt": "Training photo {{n}}", + "addMoreImages": "Add more images", + "savingError": "Saving failed", + "progressLabel": "Progress", + "primaryTag": "Primary", + "secondaryTag": "Secondary" + }, + "history": { + "sectionLabel": "HISTORY", + "noSessions": "No sessions saved yet.", + "prevMonth": "Previous month", + "nextMonth": "Next month", + "hoverHint": "Hover over the body for details", + "muscleGroups": "Muscle groups", + "primaryCount": "Primary ({{count}})", + "secondaryCount": "Secondary ({{count}})", + "reanalyze": "Re-analyze", + "analyzing": "Analyzing…", + "editSession": "Edit session", + "ownTraining": "Personal training", + "exerciseCount_one": "{{count}} exercise", + "exerciseCount_other": "{{count}} exercises", + "sessionCount_one": "{{count}} session", + "sessionCount_other": "{{count}} sessions", + "filterWithDate": "{{count}} of {{total}} {{sessionLabel}} on {{date}}", + "filteredMonth": "{{count}} {{sessionLabel}} in {{month}} with these filters", + "monthCount": "{{count}} {{sessionLabel}} in {{month}}.", + "volumeLegendMin": "VOLUME 1", + "volumeLegendMax": "5+", + "days": { + "mon": "Mo", + "tue": "Tu", + "wed": "We", + "thu": "Th", + "fri": "Fr", + "sat": "Sa", + "sun": "Su" + }, + "heroMotivation": { + "1": "great start!", + "5": "five. solid.", + "10": "double digits!", + "20": "twenty. this is a habit.", + "23": "Jordan number.", + "25": "quarter century!", + "30": "thirty. legendary.", + "32": "Rocky mode.", + "40": "FORTY. Arnold approves.", + "42": "the answer to everything.", + "50": "FIFTY. seriously.", + "over50": "over 50. call the doctor." + }, + "heroMotivationFallback": "{{count}} sessions this month." + }, + "bibliotek": { + "sectionLabel": "LIBRARY", + "heading": "Your building blocks.", + "tabExercises": "Exercises", + "tabTemplates": "Templates", + "newExercise": "New exercise", + "shortcuts": "SHORTCUTS", + "searchPlaceholder": "Search exercise…", + "loadingExercises": "Loading exercises…", + "noExercises": "No exercises added yet.", + "noSearchResults": "No exercises match the search.", + "noMuscles": "No muscles", + "newTemplate": "New template", + "templateNameLabel": "Template name", + "templateNamePlaceholder": "e.g. CrossFit - Anna - Monday", + "createTemplate": "Create and add exercises", + "creating": "Creating…", + "loadingTemplates": "Loading templates…", + "noTemplates": "No templates created yet.", + "deleteExerciseTitle": "Delete exercise", + "deleteTemplateTitle": "Delete template", + "deleteConfirm": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.", + "usedInTemplates_one": "The exercise is used in the template", + "usedInTemplates_other": "The exercise is used in the templates", + "exerciseRemovedWarning": "and will be removed from it.", + "exerciseCount": "{{count}} EX" + }, + "planlegger": { + "heading": "Plan the week", + "prevWeek": "Previous week", + "nextWeek": "Next week", + "projectedCoverage": "Projected coverage", + "weekSummary_one": "{{count}} session · {{muscleCount}} muscle groups", + "weekSummary_other": "{{count}} sessions · {{muscleCount}} muscle groups", + "weekPlan": "Weekly plan", + "addSession": "Add session", + "removeTemplate": "Remove {{name}}", + "gapsCard_one": "{{count}} muscle group not covered this week", + "gapsCard_other": "{{count}} muscle groups not covered this week", + "templatesCovering": "Templates covering these:", + "confirmDelete": "Remove the entire week plan?", + "removeWeek": "Remove week", + "savePlan": "Save plan", + "selectTemplate": "Select template", + "noTemplates": "No templates created yet.", + "loadingPlan": "Loading plan…", + "removing": "Removing…", + "remove": "Remove", + "exerciseCount_one": "{{count}} exercise", + "exerciseCount_other": "{{count}} exercises", + "days": { + "1": "MON", + "2": "TUE", + "3": "WED", + "4": "THU", + "5": "FRI", + "6": "SAT", + "7": "SUN" + } + }, + "settings": { + "heading": "Settings", + "appearance": "Appearance", + "darkTheme": "Dark theme", + "darkThemeOff": "Off", + "darkThemeOn": "On", + "account": "Account", + "signOut": "Sign out", + "about": "About the app", + "changelog": "Show changelog", + "contact": "Contact", + "contactBody": "Have feedback or found a bug? Feel free to reach out.", + "sendEmail": "Send email", + "reportGithub": "Report bug on GitHub", + "language": "Language", + "languageNorwegian": "Norsk", + "languageEnglish": "English", + "languagePersian": "فارسی" + }, + "report": { + "heroMuscles_one": "{{count}} muscle", + "heroMuscles_other": "{{count}} muscles", + "heroNeverTrained": "never trained.", + "kpiSessions": "Sessions", + "kpiMuscles": "Muscles", + "kpiAvgPerWeek": "Avg/week", + "hoverHint": "Hover over or focus a muscle for details", + "primarySessions": "PRIMARY SESSIONS", + "lastDate": "LAST", + "legendPrimary": "Primary", + "legendSecondary": "Secondary", + "gapHeading": "NOT HIT", + "frequencyTable": "Muscle frequency", + "colMuscle": "MUSCLE", + "colSession": "SESSION", + "colSets": "SETS", + "getRecommendation": "Get recommendation", + "loadingRecs": "Getting recommendations…", + "analyzingData": "Analyzing training data…", + "recommendedExercises": "Recommended exercises", + "noRecs": "No recommendations available.", + "noSessions": "No sessions found for selected filter.", + "saveRecError": "Could not save exercise. Try again.", + "fetchRecError": "Could not fetch recommendations. Try again.", + "toCta": "Add these to your program →", + "period": "PERIOD", + "activeDays": "ACTIVE DAYS", + "days": { + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun" + }, + "periods": { + "7": "7 days", + "30": "30 days", + "90": "90 days" + } + }, + "exerciseRow": { + "namePlaceholder": "Click to enter exercise…", + "nameRequired": "Required", + "nameAriaLabel": "Exercise name", + "setsLabel": "Sets for {{name}}", + "repsLabel": "Reps for {{name}}", + "deleteExercise": "Delete exercise", + "invalidNumber": "Invalid number – enter 1 to 99" + }, + "exerciseForm": { + "nameLabel": "Name", + "namePlaceholder": "e.g. Squat", + "defaultSets": "Default sets", + "defaultReps": "Default reps", + "saveExercise": "Save exercise" + }, + "libraryPicker": { + "searchLabel": "Search exercise library", + "searchPlaceholder": "Type to filter…", + "noResults": "No results.", + "close": "Close" + }, + "bodyPanel": { + "front": "Front", + "back": "Back" + }, + "musclePicker": { + "frontLabel": "Front muscles", + "backLabel": "Back muscles", + "primaryCount": "Primary ({{count}})", + "secondaryCount": "Secondary ({{count}})", + "helpText": "Click muscle: off → primary → secondary → off. Arrow keys navigate, space/enter selects.", + "stateNotSelected": "not selected", + "statePrimary": "primary", + "stateSecondary": "secondary" + }, + "templatePicker": { + "title": "Select template", + "description": "Select a template to start a session with pre-filled exercises.", + "loading": "Loading templates…", + "noTemplates": "No templates created yet.", + "goToLibrary": "Go to library", + "lastUsed": "Last used {{date}}", + "exerciseCount_one": "{{count}} exercise", + "exerciseCount_other": "{{count}} exercises" + }, + "templateEditor": { + "titleEdit": "Edit template", + "titleUse": "Use template", + "clickToRename": "Click to rename", + "primaryCount": "Primary ({{count}})", + "secondaryCount": "Secondary ({{count}})", + "fromLibrary": "From library", + "manual": "Manually", + "saveChanges": "Save template changes", + "useSession": "Use session", + "saveTemplate": "Save template" + } +} diff --git a/app/public/locales/fa/translation.json b/app/public/locales/fa/translation.json new file mode 100644 index 0000000..4f25034 --- /dev/null +++ b/app/public/locales/fa/translation.json @@ -0,0 +1,381 @@ +{ + "common": { + "save": "ذخیره", + "cancel": "لغو", + "back": "بازگشت", + "loading": "در حال بارگذاری…", + "error": "خطا", + "delete": "حذف", + "edit": "ویرایش", + "close": "بستن", + "saving": "در حال ذخیره…", + "saved": "ذخیره شد", + "primary": "اولیه", + "secondary": "ثانویه", + "exercises": "تمرین‌ها", + "sets": "ست", + "reps": "تکرار", + "session": "جلسه", + "sessions": "جلسات", + "noResults": "نتیجه‌ای یافت نشد.", + "resetFilter": "پاک کردن فیلتر", + "none": "هیچ", + "front": "جلو", + "back_view": "پشت", + "saveFailed": "ذخیره ناموفق بود. دوباره امتحان کنید.", + "add": "افزودن" + }, + "muscles": { + "chest": "سینه", + "shoulders_front": "شانه جلویی", + "shoulders_side": "شانه کناری", + "biceps": "دوسر بازو", + "forearms": "ساعد", + "abs": "شکم", + "obliques": "پهلو", + "quads": "چهارسر ران", + "calves": "ساق پا", + "traps": "ذوزنقه", + "rear_delts": "شانه پشتی", + "lats": "پشتی بزرگ", + "triceps": "سه‌سر بازو", + "lower_back": "کمر پایینی", + "glutes": "سرینی", + "hamstrings": "همسترینگ", + "calves_back": "ساق پا (پشت)" + }, + "bodymap": { + "front": "جلو", + "back": "پشت", + "mapLabel": "نقشه عضلات، {{view}}", + "freqMapLabel": "نقشه فرکانس تمرین، {{view}}", + "primaryLabel": "اولیه", + "secondaryLabel": "ثانویه", + "totalLabel": "مجموع", + "notTrained": "تمرین نشده", + "ariaPrimary_one": "{{muscle}} – اولیه: {{count}} جلسه", + "ariaPrimary_other": "{{muscle}} – اولیه: {{count}} جلسه", + "ariaSecondary_one": "{{muscle}} – ثانویه: {{count}} جلسه", + "ariaSecondary_other": "{{muscle}} – ثانویه: {{count}} جلسه", + "ariaNotTrained": "{{muscle}} – تمرین نشده" + }, + "nav": { + "logSession": "ثبت جلسه", + "history": "تاریخچه تمرین", + "report": "گزارش دوره", + "library": "کتابخانه", + "planner": "برنامه‌ریزی هفته", + "settings": "تنظیمات" + }, + "login": { + "subtitle": "برای ادامه وارد شوید", + "emailLabel": "آدرس ایمیل", + "emailPlaceholder": "ایمیل@مثال.com", + "sendLink": "ارسال لینک ورود", + "sending": "در حال ارسال…", + "checkEmail": "ایمیل خود را بررسی کنید", + "sentTo": "لینک ورود به این آدرس ارسال شد:", + "failed": "ورود ناموفق:" + }, + "home": { + "logNew": "ثبت جلسه جدید", + "todaySession": "جلسه امروز", + "lastSession": "آخرین جلسه", + "seeAll": "مشاهده همه →", + "loading": "در حال بارگذاری آخرین جلسه…", + "noSessions": "هنوز جلسه‌ای ثبت نشده. اولین جلسه خود را ثبت کنید!", + "ownTraining": "تمرین شخصی", + "train": "تمرین کن.", + "today": "امروز.", + "weekStrip": { + "mon": "د", + "tue": "س", + "wed": "چ", + "thu": "پ", + "fri": "ج", + "sat": "ش", + "sun": "ی" + } + }, + "muscleMap": { + "sectionLabel": "ثبت جلسه", + "stepSnap": "عکس", + "stepConfirm": "تأیید", + "stepResult": "نتیجه", + "heroLine1": "از تخته", + "heroLine2": "عکس بگیر.", + "imageCount_one": "{{count}} تصویر", + "imageCount_other": "{{count}} تصویر", + "selected": "انتخاب شده", + "dropzoneLabel": "آپلود تصویر تمرین", + "dropzoneClick": "برای انتخاب تصویر ضربه بزنید", + "dropzoneDrag": "یا بکشید و رها کنید · JPEG، PNG، WebP", + "useTemplate": "قالب", + "manualEntry": "ورود دستی", + "tipsHeading": "راهنما", + "tipsBody": "نور مناسب و نمایش کامل تخته در تصویر بهترین نتیجه را می‌دهد. چند تصویر پشتیبانی می‌شود.", + "analyzeBtn": "تحلیل تصویر", + "analyzing": "در حال خواندن برنامه تمرینی و شناسایی تمرین‌ها…", + "foundExercises_one": "{{count}} تمرین یافت شد.", + "foundExercises_other": "{{count}} تمرین یافت شد.", + "today": "امروز", + "otherDay": "روز دیگر", + "dateLabel": "تاریخ", + "datePlaceholder": "روز/ماه/سال", + "selectGymSession": "این کدام کلاس بود؟", + "selectGymOptional": "انتخاب کلاس (اختیاری)", + "conflictTitle": "جلسه موجود:", + "conflictBody": "این کلاس از قبل یک جلسه ذخیره شده دارد ({{date}}). ذخیره جدید جایگزین آن می‌شود.", + "musclesViaClaude": "عضلات شناسایی شده توسط Claude", + "musclesViaDB": "عضلات شناسایی شده از پایگاه داده", + "musclesUnknown": "عضلات شناسایی نشده", + "addManual": "افزودن تمرین به صورت دستی", + "saveAndShow": "ذخیره و نمایش نتیجه", + "hitMuscles1": "شما", + "hitMuscles2": "عضله تمرین دادید.", + "kpiMuscles": "عضلات", + "kpiTime": "زمان", + "trainedMuscles": "عضلات تمرین‌شده", + "exercisesThisSession": "تمرین‌های این جلسه", + "nextStep": "مرحله بعد", + "nextStepBody": "ببینید با گذر زمان کدام عضلات را فراموش می‌کنید.", + "analyzePeriod": "تحلیل دوره", + "getRecommendations": "دفعه بعد چه چیزی تمرین کنم؟", + "loadingRecs": "در حال دریافت پیشنهادها…", + "recommendedExercises": "تمرین‌های پیشنهادی", + "noRecs": "پیشنهادی موجود نیست.", + "logNew": "ثبت جلسه جدید", + "removeImage": "حذف تصویر {{n}}", + "imageAlt": "تصویر تمرین {{n}}", + "addMoreImages": "افزودن تصاویر بیشتر", + "savingError": "ذخیره ناموفق بود", + "progressLabel": "پیشرفت", + "primaryTag": "اولیه", + "secondaryTag": "ثانویه" + }, + "history": { + "sectionLabel": "تاریخچه", + "noSessions": "هنوز جلسه‌ای ذخیره نشده.", + "prevMonth": "ماه قبل", + "nextMonth": "ماه بعد", + "hoverHint": "برای جزئیات، نشانگر را روی بدن ببرید", + "muscleGroups": "گروه‌های عضلانی", + "primaryCount": "اولیه ({{count}})", + "secondaryCount": "ثانویه ({{count}})", + "reanalyze": "تحلیل مجدد", + "analyzing": "در حال تحلیل…", + "editSession": "ویرایش جلسه", + "ownTraining": "تمرین شخصی", + "exerciseCount_one": "{{count}} تمرین", + "exerciseCount_other": "{{count}} تمرین", + "sessionCount_one": "{{count}} جلسه", + "sessionCount_other": "{{count}} جلسه", + "filterWithDate": "{{count}} از {{total}} {{sessionLabel}} در {{date}}", + "filteredMonth": "{{count}} {{sessionLabel}} در {{month}} با این فیلترها", + "monthCount": "{{count}} {{sessionLabel}} در {{month}}.", + "volumeLegendMin": "حجم ۱", + "volumeLegendMax": "۵+", + "days": { + "mon": "د", + "tue": "س", + "wed": "چ", + "thu": "پ", + "fri": "ج", + "sat": "ش", + "sun": "ی" + }, + "heroMotivation": { + "1": "شروع عالی!", + "5": "پنج تا. محکم.", + "10": "دو رقمی!", + "20": "بیست. این یه عادت شده.", + "25": "ربع قرن!", + "30": "سی تا. افسانه‌ای.", + "40": "چهل. آرنولد تأیید می‌کنه.", + "42": "جواب همه چیز.", + "50": "پنجاه. جدی؟", + "over50": "بیشتر از ۵۰. با دکتر مشورت کن." + }, + "heroMotivationFallback": "{{count}} جلسه این ماه." + }, + "bibliotek": { + "sectionLabel": "کتابخانه", + "heading": "بلوک‌های سازنده شما.", + "tabExercises": "تمرین‌ها", + "tabTemplates": "قالب‌ها", + "newExercise": "تمرین جدید", + "shortcuts": "میانبرها", + "searchPlaceholder": "جستجوی تمرین…", + "loadingExercises": "در حال بارگذاری تمرین‌ها…", + "noExercises": "هنوز تمرینی اضافه نشده.", + "noSearchResults": "هیچ تمرینی با این جستجو مطابقت ندارد.", + "noMuscles": "بدون عضله", + "newTemplate": "قالب جدید", + "templateNameLabel": "نام قالب", + "templateNamePlaceholder": "مثلاً: کراسفیت - شنبه", + "createTemplate": "ایجاد و افزودن تمرین‌ها", + "creating": "در حال ایجاد…", + "loadingTemplates": "در حال بارگذاری قالب‌ها…", + "noTemplates": "هنوز قالبی ایجاد نشده.", + "deleteExerciseTitle": "حذف تمرین", + "deleteTemplateTitle": "حذف قالب", + "deleteConfirm": "آیا مطمئن هستید که می‌خواهید «{{name}}» را حذف کنید؟ این عمل قابل بازگشت نیست.", + "usedInTemplates_one": "این تمرین در قالب استفاده شده", + "usedInTemplates_other": "این تمرین در قالب‌ها استفاده شده", + "exerciseRemovedWarning": "و از آن حذف خواهد شد.", + "exerciseCount": "{{count}} تمرین" + }, + "planlegger": { + "heading": "برنامه‌ریزی هفته", + "prevWeek": "هفته قبل", + "nextWeek": "هفته بعد", + "projectedCoverage": "پوشش پیش‌بینی‌شده", + "weekSummary_one": "{{count}} جلسه · {{muscleCount}} گروه عضلانی", + "weekSummary_other": "{{count}} جلسه · {{muscleCount}} گروه عضلانی", + "weekPlan": "برنامه هفتگی", + "addSession": "افزودن جلسه", + "removeTemplate": "حذف {{name}}", + "gapsCard_one": "{{count}} گروه عضلانی این هفته پوشش داده نشده", + "gapsCard_other": "{{count}} گروه عضلانی این هفته پوشش داده نشده", + "templatesCovering": "قالب‌هایی که این عضلات را پوشش می‌دهند:", + "confirmDelete": "کل برنامه هفتگی حذف شود؟", + "removeWeek": "حذف هفته", + "savePlan": "ذخیره برنامه", + "selectTemplate": "انتخاب قالب", + "noTemplates": "هنوز قالبی ایجاد نشده.", + "loadingPlan": "در حال بارگذاری برنامه…", + "removing": "در حال حذف…", + "remove": "حذف", + "exerciseCount_one": "{{count}} تمرین", + "exerciseCount_other": "{{count}} تمرین", + "days": { + "1": "دوش", + "2": "سه‌ش", + "3": "چهار", + "4": "پنج", + "5": "جمعه", + "6": "شنبه", + "7": "یکشنبه" + } + }, + "settings": { + "heading": "تنظیمات", + "appearance": "ظاهر", + "darkTheme": "تم تاریک", + "darkThemeOff": "خاموش", + "darkThemeOn": "روشن", + "account": "حساب کاربری", + "signOut": "خروج", + "about": "درباره برنامه", + "changelog": "نمایش تاریخچه تغییرات", + "contact": "تماس", + "contactBody": "بازخورد دارید یا باگی پیدا کردید؟ با ما در تماس باشید.", + "sendEmail": "ارسال ایمیل", + "reportGithub": "گزارش باگ در GitHub", + "language": "زبان", + "languageNorwegian": "Norsk", + "languageEnglish": "English", + "languagePersian": "فارسی" + }, + "report": { + "heroMuscles_one": "{{count}} عضله", + "heroMuscles_other": "{{count}} عضله", + "heroNeverTrained": "هرگز تمرین نشده.", + "kpiSessions": "جلسات", + "kpiMuscles": "عضلات", + "kpiAvgPerWeek": "میانگین/هفته", + "hoverHint": "برای جزئیات، نشانگر را روی عضله ببرید یا روی آن فوکوس کنید", + "primarySessions": "جلسات اولیه", + "lastDate": "آخرین", + "legendPrimary": "اولیه", + "legendSecondary": "ثانویه", + "gapHeading": "تمرین نشده", + "frequencyTable": "فرکانس عضلات", + "colMuscle": "عضله", + "colSession": "جلسه", + "colSets": "ست", + "getRecommendation": "دریافت پیشنهاد", + "loadingRecs": "در حال دریافت پیشنهادها…", + "analyzingData": "در حال تحلیل داده‌های تمرینی…", + "recommendedExercises": "تمرین‌های پیشنهادی", + "noRecs": "پیشنهادی موجود نیست.", + "noSessions": "جلسه‌ای برای فیلتر انتخاب‌شده یافت نشد.", + "saveRecError": "ذخیره تمرین ناموفق بود. دوباره امتحان کنید.", + "fetchRecError": "دریافت پیشنهادها ناموفق بود. دوباره امتحان کنید.", + "toCta": "این‌ها را به برنامه‌ات اضافه کن →", + "period": "دوره", + "activeDays": "روزهای فعال", + "days": { + "mon": "دوش", + "tue": "سه‌ش", + "wed": "چهار", + "thu": "پنج", + "fri": "جمعه", + "sat": "شنبه", + "sun": "یکشنبه" + }, + "periods": { + "7": "۷ روز", + "30": "۳۰ روز", + "90": "۹۰ روز" + } + }, + "exerciseRow": { + "namePlaceholder": "برای نوشتن تمرین کلیک کنید…", + "nameRequired": "الزامی", + "nameAriaLabel": "نام تمرین", + "setsLabel": "ست برای {{name}}", + "repsLabel": "تکرار برای {{name}}", + "deleteExercise": "حذف تمرین", + "invalidNumber": "عدد نامعتبر – عددی بین ۱ تا ۹۹ وارد کنید" + }, + "exerciseForm": { + "nameLabel": "نام", + "namePlaceholder": "مثلاً اسکوات", + "defaultSets": "ست پیش‌فرض", + "defaultReps": "تکرار پیش‌فرض", + "saveExercise": "ذخیره تمرین" + }, + "libraryPicker": { + "searchLabel": "جستجو در کتابخانه تمرین‌ها", + "searchPlaceholder": "برای فیلتر تایپ کنید…", + "noResults": "نتیجه‌ای یافت نشد.", + "close": "بستن" + }, + "bodyPanel": { + "front": "جلو", + "back": "پشت" + }, + "musclePicker": { + "frontLabel": "عضلات جلو", + "backLabel": "عضلات پشت", + "primaryCount": "اولیه ({{count}})", + "secondaryCount": "ثانویه ({{count}})", + "helpText": "روی عضله کلیک کنید: خاموش ← اولیه ← ثانویه ← خاموش. کلیدهای جهت برای ناوبری، فاصله/اینتر برای انتخاب.", + "stateNotSelected": "انتخاب نشده", + "statePrimary": "اولیه", + "stateSecondary": "ثانویه" + }, + "templatePicker": { + "title": "انتخاب قالب", + "description": "یک قالب انتخاب کنید تا جلسه را با تمرین‌های از پیش تعریف‌شده شروع کنید.", + "loading": "در حال بارگذاری قالب‌ها…", + "noTemplates": "هنوز قالبی ایجاد نشده.", + "goToLibrary": "رفتن به کتابخانه", + "lastUsed": "آخرین استفاده: {{date}}", + "exerciseCount_one": "{{count}} تمرین", + "exerciseCount_other": "{{count}} تمرین" + }, + "templateEditor": { + "titleEdit": "ویرایش قالب", + "titleUse": "استفاده از قالب", + "clickToRename": "برای تغییر نام کلیک کنید", + "primaryCount": "اولیه ({{count}})", + "secondaryCount": "ثانویه ({{count}})", + "fromLibrary": "از کتابخانه", + "manual": "دستی", + "saveChanges": "ذخیره تغییرات قالب", + "useSession": "شروع جلسه", + "saveTemplate": "ذخیره قالب" + } +} diff --git a/app/public/locales/nb/translation.json b/app/public/locales/nb/translation.json new file mode 100644 index 0000000..aaa6d58 --- /dev/null +++ b/app/public/locales/nb/translation.json @@ -0,0 +1,421 @@ +{ + "common": { + "save": "Lagre", + "cancel": "Avbryt", + "back": "Tilbake", + "loading": "Laster…", + "error": "Feil", + "delete": "Slett", + "edit": "Rediger", + "close": "Lukk", + "saving": "Lagrer…", + "saved": "Lagret", + "primary": "Primær", + "secondary": "Sekundær", + "exercises": "Øvelser", + "sets": "sett", + "reps": "reps", + "session": "økt", + "sessions": "økter", + "noResults": "Ingen treff.", + "resetFilter": "Nullstill filter", + "none": "ingen", + "front": "Front", + "back_view": "Bak", + "saveFailed": "Lagring feilet. Prøv igjen.", + "add": "Legg til" + }, + "muscles": { + "chest": "Bryst", + "shoulders_front": "Fremre skuldre", + "shoulders_side": "Laterale skuldre", + "biceps": "Biceps", + "forearms": "Underarmer", + "abs": "Mage", + "obliques": "Oblique", + "quads": "Quadriceps", + "calves": "Legg", + "traps": "Trapezius", + "rear_delts": "Bakre skuldre", + "lats": "Latissimus", + "triceps": "Triceps", + "lower_back": "Korsrygg", + "glutes": "Sete", + "hamstrings": "Hamstrings", + "calves_back": "Legg (bak)" + }, + "bodymap": { + "front": "fremside", + "back": "bakside", + "mapLabel": "Muskelkart, {{view}}", + "freqMapLabel": "Treningsfrekvenskart, {{view}}", + "primaryLabel": "Primær", + "secondaryLabel": "Sekundær", + "totalLabel": "Totalt", + "notTrained": "Ikke trent", + "ariaPrimary_one": "{{muscle}} – primær: {{count}} økt", + "ariaPrimary_other": "{{muscle}} – primær: {{count}} økter", + "ariaSecondary_one": "{{muscle}} – sekundær: {{count}} økt", + "ariaSecondary_other": "{{muscle}} – sekundær: {{count}} økter", + "ariaNotTrained": "{{muscle}} – ikke trent" + }, + "nav": { + "logSession": "Logg økt", + "history": "Treningshistorikk", + "report": "Perioderapport", + "library": "Bibliotek", + "planner": "Planlegg uke", + "settings": "Innstillinger" + }, + "login": { + "subtitle": "Logg inn for å fortsette", + "emailLabel": "E-postadresse", + "emailPlaceholder": "din@epost.no", + "sendLink": "Send innloggingslenke", + "sending": "Sender…", + "checkEmail": "Sjekk e-posten din", + "sentTo": "Vi sendte en innloggingslenke til", + "failed": "Innlogging feilet:" + }, + "home": { + "logNew": "Logg ny økt", + "todaySession": "DAGENS ØKT", + "lastSession": "SISTE ØKT", + "seeAll": "SE ALLE →", + "loading": "Laster siste økt…", + "noSessions": "Ingen økter logget ennå. Logg din første økt!", + "ownTraining": "Egentrening", + "train": "Tren.", + "today": "I dag.", + "weekStrip": { + "mon": "M", + "tue": "T", + "wed": "O", + "thu": "T", + "fri": "F", + "sat": "L", + "sun": "S" + } + }, + "muscleMap": { + "sectionLabel": "LOGG ØKT", + "stepSnap": "Knips", + "stepConfirm": "Bekreft", + "stepResult": "Resultat", + "heroLine1": "Ta bilde av", + "heroLine2": "tavla.", + "imageCount_one": "{{count}} bilde", + "imageCount_other": "{{count}} bilder", + "selected": "valgt", + "dropzoneLabel": "Last opp treningsbilde", + "dropzoneClick": "Trykk for å velge bilde", + "dropzoneDrag": "eller dra og slipp · JPEG, PNG, WebP", + "useTemplate": "Mal", + "manualEntry": "Legg inn manuelt", + "tipsHeading": "Tips", + "tipsBody": "God belysning og hele tavla i bildet gir best resultat. Flere bilder støttes.", + "analyzeBtn": "Analyser bilde", + "analyzing": "Leser treningsprogram og identifiserer øvelser…", + "foundExercises_one": "{{count}} øvelse funnet.", + "foundExercises_other": "{{count}} øvelser funnet.", + "today": "I dag", + "otherDay": "Annen dag", + "dateLabel": "Dato", + "datePlaceholder": "dd/mm/åååå", + "selectGymSession": "Hvilken time var dette?", + "selectGymOptional": "Velg gymtime (valgfritt)", + "conflictTitle": "Eksisterende økt:", + "conflictBody": "Denne gymtimen har allerede en lagret økt ({{date}}). Lagring erstatter den.", + "musclesViaClaude": "Muskler gjenkjent av Claude", + "musclesViaDB": "Muskler gjenkjent via database", + "musclesUnknown": "Muskler ikke gjenkjent", + "addManual": "Legg til øvelse manuelt", + "saveAndShow": "Lagre og se resultat", + "hitMuscles1": "Du traff", + "hitMuscles2": "muskler.", + "kpiMuscles": "Muskler", + "kpiTime": "Tid", + "trainedMuscles": "Trente muskler", + "exercisesThisSession": "Øvelser denne økten", + "nextStep": "Neste steg", + "nextStepBody": "Se hvilke muskler du glemmer over tid.", + "analyzePeriod": "Analyser perioden", + "getRecommendations": "Hva bør jeg trene neste gang?", + "loadingRecs": "Henter anbefalinger…", + "recommendedExercises": "Anbefalte øvelser", + "noRecs": "Ingen anbefalinger tilgjengelig.", + "logNew": "Logg ny økt", + "removeImage": "Fjern bilde {{n}}", + "imageAlt": "Treningsbilde {{n}}", + "addMoreImages": "Legg til flere bilder", + "savingError": "Lagring feilet", + "progressLabel": "Fremgang", + "primaryTag": "Primær", + "secondaryTag": "Sekundær" + }, + "history": { + "sectionLabel": "HISTORIKK", + "noSessions": "Ingen økter lagret ennå.", + "prevMonth": "Forrige måned", + "nextMonth": "Neste måned", + "hoverHint": "Hold musepeker over kroppen for detaljer", + "muscleGroups": "Muskelgrupper", + "primaryCount": "Primær ({{count}})", + "secondaryCount": "Sekundær ({{count}})", + "reanalyze": "Re-analyser", + "analyzing": "Analyserer…", + "editSession": "Rediger økt", + "ownTraining": "Egentrening", + "exerciseCount_one": "{{count}} øvelse", + "exerciseCount_other": "{{count}} øvelser", + "sessionCount_one": "{{count}} økt", + "sessionCount_other": "{{count}} økter", + "filterWithDate": "{{count}} av {{total}} {{sessionLabel}} den {{date}}", + "filteredMonth": "{{count}} {{sessionLabel}} i {{month}} med disse filtrene", + "monthCount": "{{count}} {{sessionLabel}} i {{month}}.", + "volumeLegendMin": "VOLUM 1", + "volumeLegendMax": "5+", + "days": { + "mon": "ma", + "tue": "ti", + "wed": "on", + "thu": "to", + "fri": "fr", + "sat": "lø", + "sun": "sø" + }, + "heroMotivation": { + "1": "god start!", + "2": "to for to!", + "3": "tre på rad!", + "4": "fire! fint.", + "5": "fem. solid.", + "6": "seks. i rute.", + "7": "syv. nesten daglig.", + "8": "åtte. kroppen takker.", + "9": "ni. ett til!", + "10": "tosifret!", + "11": "elleve. du mener det.", + "12": "tolv. tre per uke.", + "13": "tretten. heldig kropp.", + "14": "fjorten. halvveis til 28.", + "15": "femten. meget bra.", + "16": "seksten. du er maskinen.", + "17": "sytten. ett per muskel!", + "18": "atten. kortet tjener inn.", + "19": "nitten. ett til!", + "20": "tjue. dette er en vane.", + "21": "tjueen. vanedannende.", + "22": "tjueto. ingen stopper deg.", + "23": "Jordan-nummer.", + "24": "tjuefire. Kobe-territorium.", + "25": "kvartmål!", + "26": "tjueseks. halvveis til 52.", + "27": "tjuesyv. over Kobe.", + "28": "tjueåtte. én per dag?", + "29": "tjueni. nesten 30!", + "30": "tredve. legendarisk.", + "31": "trettieen. hver dag.", + "32": "Rocky-modus.", + "33": "trettire. halvtredjes.", + "34": "trettfire. dedikert.", + "35": "trettiofem. femgangen!", + "36": "seksgangen squared.", + "37": "trettisyv. dette er deg.", + "38": "trettåtte. bevisst.", + "39": "trettini. nesten firti!", + "40": "FIRTI. Arnold nikker.", + "41": "over 40. egen klasse.", + "42": "svaret på alt.", + "43": "førtitre. hvem gjør det?", + "44": "førtfire. dobbel innsats.", + "45": "førtiofem. fire-og-halv timer.", + "46": "ikke normalt. kompliment.", + "47": "førtisyv. legen er stolt.", + "48": "én og en halv per dag.", + "49": "ett til: femti-klubben!", + "50": "FEMTI. ikke virkelig.", + "over50": "over 50. ring legen." + } + }, + "bibliotek": { + "sectionLabel": "BIBLIOTEK", + "heading": "Dine byggklosser.", + "tabExercises": "Øvelser", + "tabTemplates": "Maler", + "newExercise": "Ny øvelse", + "shortcuts": "SNARVEIER", + "searchPlaceholder": "Søk øvelse…", + "loadingExercises": "Laster øvelser…", + "noExercises": "Ingen øvelser lagt til ennå.", + "noSearchResults": "Ingen øvelser matcher søket.", + "noMuscles": "Ingen muskler", + "newTemplate": "Ny mal", + "templateNameLabel": "Navn på mal", + "templateNamePlaceholder": "f.eks. CrossFit - Anna - mandag", + "createTemplate": "Opprett og legg til øvelser", + "creating": "Oppretter…", + "loadingTemplates": "Laster maler…", + "noTemplates": "Ingen maler opprettet ennå.", + "deleteExerciseTitle": "Slett øvelse", + "deleteTemplateTitle": "Slett mal", + "deleteConfirm": "Er du sikker på at du vil slette «{{name}}»? Dette kan ikke angres.", + "usedInTemplates_one": "Øvelsen brukes i malen", + "usedInTemplates_other": "Øvelsen brukes i malene", + "exerciseRemovedWarning": "og vil bli fjernet derfra.", + "exerciseCount": "{{count}} ØV" + }, + "planlegger": { + "heading": "Planlegg uken", + "prevWeek": "Forrige uke", + "nextWeek": "Neste uke", + "projectedCoverage": "Projisert dekning", + "weekSummary_one": "{{count}} økt · {{muscleCount}} muskelgrupper", + "weekSummary_other": "{{count}} økter · {{muscleCount}} muskelgrupper", + "weekPlan": "Ukesplan", + "addSession": "Legg til økt", + "removeTemplate": "Fjern {{name}}", + "gapsCard_one": "{{count}} muskelgruppe er ikke dekket denne uken", + "gapsCard_other": "{{count}} muskelgrupper er ikke dekket denne uken", + "templatesCovering": "Maler som dekker disse:", + "confirmDelete": "Fjerne hele ukeplanen?", + "removeWeek": "Fjern uke", + "savePlan": "Lagre plan", + "selectTemplate": "Velg mal", + "noTemplates": "Ingen maler opprettet ennå.", + "loadingPlan": "Laster plan…", + "removing": "Fjerner…", + "remove": "Fjern", + "exerciseCount_one": "{{count}} øvelse", + "exerciseCount_other": "{{count}} øvelser", + "days": { + "1": "MAN", + "2": "TIR", + "3": "ONS", + "4": "TOR", + "5": "FRE", + "6": "LØR", + "7": "SØN" + } + }, + "settings": { + "heading": "Innstillinger", + "appearance": "Utseende", + "darkTheme": "Mørkt tema", + "darkThemeOff": "Av", + "darkThemeOn": "På", + "account": "Konto", + "signOut": "Logg ut", + "about": "Om appen", + "changelog": "Vis endringslogg", + "contact": "Kontakt", + "contactBody": "Har du tilbakemeldinger eller fant en feil? Ta gjerne kontakt.", + "sendEmail": "Send e-post", + "reportGithub": "Rapporter feil på GitHub", + "language": "Språk", + "languageNorwegian": "Norsk", + "languageEnglish": "English", + "languagePersian": "فارسی" + }, + "report": { + "heroMuscles_one": "{{count}} muskel", + "heroMuscles_other": "{{count}} muskler", + "heroNeverTrained": "aldri trent.", + "kpiSessions": "Økter", + "kpiMuscles": "Muskler", + "kpiAvgPerWeek": "Snitt/uke", + "hoverHint": "Hold musepeker over eller fokuser muskel for detaljer", + "primarySessions": "PRIMÆRØKTER", + "lastDate": "SIST", + "legendPrimary": "Primær", + "legendSecondary": "Sekundær", + "gapHeading": "IKKE TRUFFET", + "frequencyTable": "Muskelfrekvens", + "colMuscle": "MUSKEL", + "colSession": "ØKT", + "colSets": "SETT", + "getRecommendation": "Få anbefaling", + "loadingRecs": "Henter anbefalinger…", + "analyzingData": "Analyserer treningsdata…", + "recommendedExercises": "Anbefalte øvelser", + "noRecs": "Ingen anbefalinger tilgjengelig.", + "noSessions": "Ingen økter funnet for valgte filter.", + "saveRecError": "Kunne ikke lagre øvelsen. Prøv igjen.", + "fetchRecError": "Kunne ikke hente anbefalinger. Prøv igjen.", + "toCta": "Disse bør du legge inn i programmet →", + "period": "PERIODE", + "activeDays": "AKTIVE DAGER", + "days": { + "mon": "Man", + "tue": "Tir", + "wed": "Ons", + "thu": "Tor", + "fri": "Fre", + "sat": "Lør", + "sun": "Søn" + }, + "periods": { + "7": "7 dager", + "30": "30 dager", + "90": "90 dager" + } + }, + "exerciseRow": { + "namePlaceholder": "Klikk for å skrive øvelse…", + "nameRequired": "Påkrevd", + "nameAriaLabel": "Øvelsenavn", + "setsLabel": "Sett for {{name}}", + "repsLabel": "Reps for {{name}}", + "deleteExercise": "Slett øvelse", + "invalidNumber": "Ugyldig antall – skriv inn 1 til 99" + }, + "exerciseForm": { + "nameLabel": "Navn", + "namePlaceholder": "f.eks. Knebøy", + "defaultSets": "Standard sett", + "defaultReps": "Standard reps", + "saveExercise": "Lagre øvelse" + }, + "libraryPicker": { + "searchLabel": "Søk i øvelsesbiblioteket", + "searchPlaceholder": "Skriv for å filtrere…", + "noResults": "Ingen treff.", + "close": "Lukk" + }, + "bodyPanel": { + "front": "Front", + "back": "Bak" + }, + "musclePicker": { + "frontLabel": "Frontside muskler", + "backLabel": "Bakside muskler", + "primaryCount": "Primær ({{count}})", + "secondaryCount": "Sekundær ({{count}})", + "helpText": "Klikk muskel: av → primær → sekundær → av. Piltaster navigerer, mellomrom/enter velger.", + "stateNotSelected": "ikke valgt", + "statePrimary": "primær", + "stateSecondary": "sekundær" + }, + "templatePicker": { + "title": "Velg mal", + "description": "Velg en mal for å starte en økt med forhåndsutfylte øvelser.", + "loading": "Laster maler…", + "noTemplates": "Ingen maler opprettet ennå.", + "goToLibrary": "Gå til biblioteket", + "lastUsed": "Sist brukt {{date}}", + "exerciseCount_one": "{{count}} øvelse", + "exerciseCount_other": "{{count}} øvelser" + }, + "templateEditor": { + "titleEdit": "Rediger mal", + "titleUse": "Bruk mal", + "clickToRename": "Klikk for å endre navn", + "primaryCount": "Primær ({{count}})", + "secondaryCount": "Sekundær ({{count}})", + "fromLibrary": "Fra biblioteket", + "manual": "Manuelt", + "saveChanges": "Lagre endringer i malen", + "useSession": "Bruk økt", + "saveTemplate": "Lagre mal" + } +} diff --git a/app/src/components/Bibliotek.jsx b/app/src/components/Bibliotek.jsx index 1a7b433..be941e2 100644 --- a/app/src/components/Bibliotek.jsx +++ b/app/src/components/Bibliotek.jsx @@ -4,6 +4,7 @@ import { TextInput, Modal, } from "@carbon/react"; import { Add, TrashCan, Edit as EditIcon, ChevronRight, Search } from "@carbon/icons-react"; +import { useTranslation } from "react-i18next"; import PageShell, { SectionLabel, PageHeading, AccentChip } from "./PageShell"; import { fetchLibraryExercises, saveLibraryExercise, updateLibraryExercise, deleteLibraryExercise, @@ -14,14 +15,15 @@ import { logDevError } from "../lib/utils"; import ExerciseForm from "./ExerciseForm"; export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { + const { t } = useTranslation(); const [tabIndex, setTabIndex] = useState(initialTab); const [exSearch, setExSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); useEffect(() => { - const t = setTimeout(() => setDebouncedSearch(exSearch), 200); - return () => clearTimeout(t); + const timer = setTimeout(() => setDebouncedSearch(exSearch), 200); + return () => clearTimeout(timer); }, [exSearch]); const [exercises, setExercises] = useState([]); @@ -84,7 +86,7 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { const handleDeleteExercise = async (id) => { const ex = exercises.find(e => e.id === id); const affectedTemplates = await fetchTemplateNamesUsingExercise(id).catch(() => []); - setConfirmDelete({ type: "exercise", id, name: ex?.name || "øvelsen", affectedTemplates }); + setConfirmDelete({ type: "exercise", id, name: ex?.name || "", affectedTemplates }); }; const handleSaveNewTemplate = async () => { @@ -103,7 +105,7 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { const handleDeleteTemplate = (id) => { const tpl = templates.find(t => t.id === id); - setConfirmDelete({ type: "template", id, name: tpl?.name || "malen" }); + setConfirmDelete({ type: "template", id, name: tpl?.name || "" }); }; const executeDelete = async () => { @@ -126,15 +128,15 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { }; const tabLabels = [ - `Øvelser${!exLoading ? ` (${exercises.length})` : ""}`, - `Maler${!tplLoading ? ` (${templates.length})` : ""}`, + `${t("bibliotek.tabExercises")}${!exLoading ? ` (${exercises.length})` : ""}`, + `${t("bibliotek.tabTemplates")}${!tplLoading ? ` (${templates.length})` : ""}`, ]; return (
- BIBLIOTEK - Dine byggklosser. + {t("bibliotek.sectionLabel")} + {t("bibliotek.heading")} {/* Pill tab strip */}
{tabLabels.map((label, i) => ( )} - {/* Snarvei carousel — template shortcuts */} + {/* Shortcut carousel — template shortcuts */} {!tplLoading && templates.length > 0 && (

- SNARVEIER + {t("bibliotek.shortcuts")}

{templates.map(tpl => { @@ -211,7 +213,7 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { {tpl.name}
- {exCount} ØV + {t("bibliotek.exerciseCount", { count: exCount })}
); @@ -228,7 +230,7 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { type="search" id="exercise-search" name="exercise-search" - placeholder="Søk øvelse…" + placeholder={t("bibliotek.searchPlaceholder")} value={exSearch} onChange={e => setExSearch(e.target.value)} style={{ @@ -254,10 +256,10 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { )} {exLoading ? ( - + ) : filteredExercises.length === 0 && !showNewEx ? (

- {exSearch.trim() ? "Ingen øvelser matcher søket." : "Ingen øvelser lagt til ennå."} + {exSearch.trim() ? t("bibliotek.noSearchResults") : t("bibliotek.noExercises")}

) : (
@@ -274,7 +276,7 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) {
{(ex.primary_muscles || []).slice(0, 4).map(id => ( - {MUSCLES[id]?.label || id} + {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} ))} {(ex.secondary_muscles || []).slice(0, 3).map(id => ( - {MUSCLES[id]?.label || id} + {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} ))} {!(ex.primary_muscles?.length) && !(ex.secondary_muscles?.length) && ( - Ingen muskler + {t("bibliotek.noMuscles")} )}
@@ -309,9 +311,9 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { {ex.default_sets}×{ex.default_reps} )} -
)} @@ -322,16 +324,16 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) {
)} - {/* ── MALER ── */} + {/* ── TEMPLATES ── */} {tabIndex === 1 && (
{tplError && ( - + )} {!showNewTpl && ( )} @@ -339,29 +341,29 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) {
setNewTplName(e.target.value)} - placeholder="f.eks. CrossFit - Anna - mandag" + placeholder={t("bibliotek.templateNamePlaceholder")} onKeyDown={(e) => e.key === "Enter" && handleSaveNewTemplate()} style={{ marginBottom: 12 }} />
)} {tplLoading ? ( - + ) : templates.length === 0 && !showNewTpl ? (

- Ingen maler opprettet ennå. + {t("bibliotek.noTemplates")}

) : (
@@ -392,14 +394,14 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { {tpl.name}
- {exCount} ØV · {muscleCount} MUSKLER{usedAt ? ` · SIST ${usedAt}` : ""} + {t("bibliotek.exerciseCount", { count: exCount })} · {muscleCount} MUS{usedAt ? ` · ${usedAt}` : ""}
); @@ -413,18 +415,18 @@ export default function Bibliotek({ onEditTemplate, initialTab = 0 }) { setConfirmDelete(null)} onRequestSubmit={executeDelete} > -

Er du sikker på at du vil slette «{confirmDelete?.name}»? Dette kan ikke angres.

+

{t("bibliotek.deleteConfirm", { name: confirmDelete?.name })}

{confirmDelete?.affectedTemplates?.length > 0 && (

- Øvelsen brukes i {confirmDelete.affectedTemplates.length === 1 ? "malen" : "malene"}{" "} - {confirmDelete.affectedTemplates.join(", ")} og vil bli fjernet derfra. + {t("bibliotek.usedInTemplates", { count: confirmDelete.affectedTemplates.length })}{" "} + {confirmDelete.affectedTemplates.join(", ")} {t("bibliotek.exerciseRemovedWarning")}

)}
diff --git a/app/src/components/BodyPanel.jsx b/app/src/components/BodyPanel.jsx index adffad0..23c84c4 100644 --- a/app/src/components/BodyPanel.jsx +++ b/app/src/components/BodyPanel.jsx @@ -1,10 +1,12 @@ import { useState } from "react"; import { Button } from "@carbon/react"; +import { useTranslation } from "react-i18next"; import { BodySVG, useIsMobile } from "../lib/bodymap.jsx"; // Renders a front+back body map pair: side-by-side on desktop, toggled on mobile. // Manages its own mobile view state so parents don't need to. export default function BodyPanel({ primary, secondary, muscleMap, marginBottom = 16, onHover, hovered }) { + const { t } = useTranslation(); const isMobile = useIsMobile(); const [mobileView, setMobileView] = useState("front"); @@ -15,7 +17,7 @@ export default function BodyPanel({ primary, secondary, muscleMap, marginBottom {["front", "back"].map(v => ( ))}
diff --git a/app/src/components/ExerciseForm.jsx b/app/src/components/ExerciseForm.jsx index d11e06f..871f36b 100644 --- a/app/src/components/ExerciseForm.jsx +++ b/app/src/components/ExerciseForm.jsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { Button, TextInput } from "@carbon/react"; +import { useTranslation } from "react-i18next"; import MusclePicker from "./MusclePicker"; // Form for creating or editing a library exercise. @@ -9,6 +10,7 @@ import MusclePicker from "./MusclePicker"; // onCancel() // saving — boolean, disables the save button while in flight export default function ExerciseForm({ initial, onSave, onCancel, saving }) { + const { t } = useTranslation(); const [name, setName] = useState(initial?.name || ""); const [primary, setPrimary] = useState(initial?.primary_muscles || []); const [secondary, setSecondary] = useState(initial?.secondary_muscles || []); @@ -19,16 +21,16 @@ export default function ExerciseForm({ initial, onSave, onCancel, saving }) {
setName(e.target.value)} - placeholder="f.eks. Knebøy" + placeholder={t("exerciseForm.namePlaceholder")} style={{ marginBottom: 12 }} />
setDefaultSets(e.target.value)} placeholder="–" @@ -36,7 +38,7 @@ export default function ExerciseForm({ initial, onSave, onCancel, saving }) { /> setDefaultReps(e.target.value)} placeholder="–" @@ -50,7 +52,7 @@ export default function ExerciseForm({ initial, onSave, onCancel, saving }) { instanceId={initial?.id || "new"} />
- +
diff --git a/app/src/components/ExerciseRow.jsx b/app/src/components/ExerciseRow.jsx index fda50aa..a9f53d2 100644 --- a/app/src/components/ExerciseRow.jsx +++ b/app/src/components/ExerciseRow.jsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { Checkbox, Button } from "@carbon/react"; import { TrashCan } from "@carbon/icons-react"; +import { useTranslation } from "react-i18next"; import { isInvalidNum } from "../lib/utils"; export default function ExerciseRow({ @@ -11,6 +12,7 @@ export default function ExerciseRow({ validateNumbers = false, autoFocusName = false, }) { + const { t } = useTranslation(); const [editingName, setEditingName] = useState(autoFocusName); const bg = layer === "layer-02" ? "var(--cds-layer-02)" : "var(--cds-layer-01)"; @@ -46,7 +48,7 @@ export default function ExerciseRow({ autoFocus id={`ex-name-${exercise.id}`} name={`ex-name-${exercise.id}`} - aria-label="Øvelsenavn" + aria-label={t("exerciseRow.nameAriaLabel")} value={exercise.name} onChange={(e) => onChange({ name: e.target.value, standardName: e.target.value })} onBlur={() => setEditingName(false)} @@ -71,9 +73,9 @@ export default function ExerciseRow({ {exercise.name?.trim() ? ( exercise.name ) : nameInvalid ? ( - Påkrevd + {t("exerciseRow.nameRequired")} ) : ( - Klikk for å skrive øvelse… + {t("exerciseRow.namePlaceholder")} )}
)} @@ -92,7 +94,9 @@ export default function ExerciseRow({ placeholder="–" id={`ex-${field}-${exercise.id}`} name={`ex-${field}-${exercise.id}`} - aria-label={field === "sets" ? `Sett for ${exercise.name || "øvelse"}` : `Reps for ${exercise.name || "øvelse"}`} + aria-label={field === "sets" + ? t("exerciseRow.setsLabel", { name: exercise.name || t("common.exercises") }) + : t("exerciseRow.repsLabel", { name: exercise.name || t("common.exercises") })} aria-invalid={isFieldInvalid || undefined} aria-describedby={isFieldInvalid ? errorId : undefined} value={exercise[field] || ""} @@ -110,14 +114,14 @@ export default function ExerciseRow({ }} /> - {field === "sets" ? "sett" : "reps"} + {field === "sets" ? t("common.sets") : t("common.reps")} {isFieldInvalid && ( - Ugyldig antall – skriv inn 1 til 99 + {t("exerciseRow.invalidNumber")} )} @@ -129,7 +133,7 @@ export default function ExerciseRow({ kind="ghost" hasIconOnly renderIcon={TrashCan} - iconDescription="Slett øvelse" + iconDescription={t("exerciseRow.deleteExercise")} size="sm" onClick={(e) => { e.stopPropagation(); onDelete(); }} /> diff --git a/app/src/components/History.jsx b/app/src/components/History.jsx index 3dd3e7e..f04d61b 100644 --- a/app/src/components/History.jsx +++ b/app/src/components/History.jsx @@ -13,11 +13,10 @@ import { Camera, Add, Edit as EditIcon, Renew, ChevronDown, ChevronLeft, Chevron import ExerciseRowWithAutocomplete from "./ExerciseRowWithAutocomplete"; import BodyPanel from "./BodyPanel"; import PageShell, { SectionLabel, PageHeading } from "./PageShell"; +import { useTranslation } from "react-i18next"; const MUSCLE_FILTER_ITEMS = Object.entries(MUSCLES).map(([id, { label }]) => ({ id, label })); -const DAY_HEADERS = ["ma", "ti", "on", "to", "fr", "lø", "sø"]; - function calHeatColor(count) { if (!count) return "var(--surface-card)"; if (count <= 1) return "var(--heat-1)"; @@ -28,6 +27,16 @@ function calHeatColor(count) { } function MonthGrid({ year, month, sessionCountMap, onDayClick, selectedDate, today }) { + const { t } = useTranslation(); + const DAY_HEADERS = [ + t("history.days.mon"), + t("history.days.tue"), + t("history.days.wed"), + t("history.days.thu"), + t("history.days.fri"), + t("history.days.sat"), + t("history.days.sun"), + ]; const todayStr = format(today, "yyyy-MM-dd"); const selectedStr = selectedDate ? format(selectedDate, "yyyy-MM-dd") : null; const firstDOW = (new Date(year, month, 1).getDay() + 6) % 7; @@ -83,7 +92,7 @@ function MonthGrid({ year, month, sessionCountMap, onDayClick, selectedDate, tod return ( ); })} @@ -501,7 +457,7 @@ export default function History({ initialDate }) { onClick={() => setMuscleFilter([])} style={{ background: "none", border: "none", padding: "0 16px", cursor: "pointer", fontSize: 11, color: "var(--accent)", fontFamily: "var(--cds-font-mono)", letterSpacing: "0.06em", opacity: muscleFilter.length > 0 ? 1 : 0, pointerEvents: muscleFilter.length > 0 ? "auto" : "none" }} > - Nullstill filter + {t("common.resetFilter")} @@ -512,11 +468,11 @@ export default function History({ initialDate }) { ) : (
-
- VOLUM 1 + {t("history.volumeLegendMin")} {["--heat-1","--heat-2","--heat-3","--heat-4","--heat-5"].map(v => (
))} - 5+ + {t("history.volumeLegendMax")}
)} @@ -562,15 +518,15 @@ export default function History({ initialDate }) { const musIds = sessionMuscleIdMap.get(session.id) ?? new Set(); const isFilterMatch = muscleFilter.length > 0 && muscleFilter.some(id => musIds.has(id)); const matchedLabels = isFilterMatch - ? muscleFilter.filter(id => musIds.has(id)).map(id => MUSCLES[id]?.label || id) + ? muscleFilter.filter(id => musIds.has(id)).map(id => t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })) : []; - const topMuscles = extractMuscles(session).primary.slice(0, 2).map(id => MUSCLES[id]?.label || id); + const topMuscles = extractMuscles(session).primary.slice(0, 2).map(id => t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })); const sessionTime = session.gym_calendar?.start_time ? new Date(session.gym_calendar.start_time).toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" }) : new Date(session.created_at).toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" }); const sessionTitle = session.gym_calendar ? `${sessionTime} – ${session.gym_calendar.name}` - : `${sessionTime} – Egentrening`; + : `${sessionTime} – ${t("history.ownTraining")}`; return (
0 && !isFilterMatch ? 0.45 : 1 }}> @@ -582,7 +538,7 @@ export default function History({ initialDate }) { width: "100%", display: "flex", alignItems: "center", gap: 8, background: "var(--surface-card)", border: "1px solid var(--border-subtle-wl)", - borderLeft: isFilterMatch ? "3px solid var(--accent)" : "3px solid var(--border-subtle-wl)", + borderInlineStart: isFilterMatch ? "3px solid var(--accent)" : "3px solid var(--border-subtle-wl)", borderBottom: isExpanded ? "none" : "1px solid var(--border-subtle-wl)", padding: "10px 14px", cursor: "pointer", textAlign: "left", }} @@ -592,7 +548,7 @@ export default function History({ initialDate }) {
- {exCount} øvelser + {t("history.exerciseCount", { count: exCount })} {isFilterMatch ? matchedLabels.map(label => {label}) @@ -603,7 +559,7 @@ export default function History({ initialDate }) { {isExpanded && ( -
+
{/* Gym class tag (read) or selector (edit) */} {isEditing ? ( @@ -654,9 +610,9 @@ export default function History({ initialDate }) {
{hoveredMuscle ? ( -
+
- {MUSCLES[hoveredMuscle]?.label} + {t(`muscles.${hoveredMuscle}`, { defaultValue: MUSCLES[hoveredMuscle]?.label })}
@@ -664,7 +620,7 @@ export default function History({ initialDate }) { {(sessionMuscleMap[hoveredMuscle] || []).length} - {(sessionMuscleMap[hoveredMuscle] || []).length === 1 ? "ØVELSE" : "ØVELSER"} + {t("common.exercises")}
@@ -674,20 +630,20 @@ export default function History({ initialDate }) {
) : (
- Hold musepeker over kroppen for detaljer + {t("history.hoverHint")}
)}
- Primær ({sessionMuscles.primary.length}) - Sekundær ({sessionMuscles.secondary.length}) + {t("history.primaryCount", { count: sessionMuscles.primary.length })} + {t("history.secondaryCount", { count: sessionMuscles.secondary.length })}
{/* Exercise list */}

- Øvelser + {t("common.exercises")}

{isEditing ? ( @@ -719,12 +675,12 @@ export default function History({ initialDate }) { }} style={{ width: "100%" }} > - Legg til øvelse manuelt + {t("muscleMap.addManual")} ) : ( (session.session_exercises || []).map(ex => { - const muscleLabels = (ex.muscle_activations || []).map(ma => MUSCLES[ma.muscle_id]?.label || ma.muscle_id).join(", "); + const muscleLabels = (ex.muscle_activations || []).map(ma => t(`muscles.${ma.muscle_id}`, { defaultValue: MUSCLES[ma.muscle_id]?.label || ma.muscle_id })).join(", "); return (
@@ -747,7 +703,7 @@ export default function History({ initialDate }) { {!isEditing && (

- Muskelgrupper + {t("history.muscleGroups")}

{sessionMuscles.primary.map(id => { const exNames = (sessionMuscleMap[id] || []).join(", "); @@ -756,10 +712,10 @@ export default function History({ initialDate }) {
{exNames ? ( - {MUSCLES[id]?.label || id} - ) : MUSCLES[id]?.label || id} + {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} + ) : t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} - Primær + {t("common.primary")}
); })} @@ -770,10 +726,10 @@ export default function History({ initialDate }) {
{exNames ? ( - {MUSCLES[id]?.label || id} - ) : MUSCLES[id]?.label || id} + {t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} + ) : t(`muscles.${id}`, { defaultValue: MUSCLES[id]?.label || id })} - Sekundær + {t("common.secondary")}
); })} @@ -784,25 +740,25 @@ export default function History({ initialDate }) { {isEditing && ( <> {analyzeError && ( - + )} {editError && ( - + )} { if (e.target.files[0]) reanalyze(e.target.files[0]); e.target.value = ""; }} />
- +
@@ -811,7 +767,7 @@ export default function History({ initialDate }) { {/* Read mode: edit button (hidden when any session is in edit mode) */} {!editMode && ( )}
@@ -824,7 +780,7 @@ export default function History({ initialDate }) { {!loading && sessions.length === 0 && (

- Ingen økter lagret ennå. + {t("history.noSessions")}

)} diff --git a/app/src/components/Home.jsx b/app/src/components/Home.jsx index 43f4231..b4ea94c 100644 --- a/app/src/components/Home.jsx +++ b/app/src/components/Home.jsx @@ -3,13 +3,14 @@ import { format, parseISO, startOfISOWeek, addDays, getISOWeek } from "date-fns" import { nb } from "date-fns/locale"; import { InlineLoading } from "@carbon/react"; import { ArrowRight } from "@carbon/icons-react"; +import { useTranslation } from "react-i18next"; import { BodySVG } from "../lib/bodymap.jsx"; import { fetchLastSession, fetchThisWeekSessions } from "../lib/db"; import { extractMuscles, logDevError } from "../lib/utils"; import PageShell, { SectionLabel, AccentChip } from "./PageShell"; import { useNav } from "../lib/NavContext"; -const DAY_LABELS = ["M", "T", "O", "T", "F", "L", "S"]; +const WEEK_DAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; function formatSessionDate(isoDate) { const raw = format(parseISO(isoDate), "EEEE d. MMMM", { locale: nb }); @@ -33,6 +34,7 @@ function countUniqueMuscles(sessions) { } export default function Home({ onShowHistoryWithDate }) { + const { t } = useTranslation(); const { onShowLogger } = useNav(); const [lastSession, setLastSession] = useState(undefined); const [weekSessions, setWeekSessions] = useState(undefined); @@ -60,8 +62,8 @@ export default function Home({ onShowHistoryWithDate }) { } useEffect(() => { - fetchLastSession().then(setLastSession).catch(() => setLastSession(null)); // home renders empty state on failure - fetchThisWeekSessions().then(setWeekSessions).catch(() => setWeekSessions([])); // home renders empty state on failure + fetchLastSession().then(setLastSession).catch(() => setLastSession(null)); + fetchThisWeekSessions().then(setWeekSessions).catch(() => setWeekSessions([])); }, []); useEffect(() => { @@ -76,12 +78,12 @@ export default function Home({ onShowHistoryWithDate }) { const isToday = lastSession?.session_date === format(today, "yyyy-MM-dd"); const weekStart = startOfISOWeek(today); - const weekDays = DAY_LABELS.map((label, i) => { + const weekDays = WEEK_DAY_KEYS.map((key, i) => { const date = format(addDays(weekStart, i), "yyyy-MM-dd"); const sessions = weekSessions?.filter(s => s.session_date === date) ?? []; const count = sessions.reduce((sum, s) => sum + (s.session_exercises?.length ?? 0), 0); const names = sessions.map(s => s.gym_calendar?.name).filter(Boolean); - return { label, date, count, names }; + return { label: t(`home.weekStrip.${key}`), date, count, names }; }); const maxWeekCount = Math.max(...weekDays.map(d => d.count), 1); @@ -102,8 +104,8 @@ export default function Home({ onShowHistoryWithDate }) { {formatTodayEyebrow(today)}
-
Tren.
-
I dag.
+
{t("home.train")}
+
{t("home.today")}
@@ -138,7 +140,7 @@ export default function Home({ onShowHistoryWithDate }) {
0 ? "button" : undefined} tabIndex={count > 0 ? 0 : -1} - aria-label={count > 0 ? `${label}: ${count} ${count === 1 ? "øvelse" : "øvelser"}` : undefined} + aria-label={count > 0 ? `${label}: ${t("templatePicker.exerciseCount", { count })}` : undefined} onClick={count > 0 ? () => onShowHistoryWithDate(date) : undefined} onKeyDown={count > 0 ? e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onShowHistoryWithDate(date); } } : undefined} onMouseEnter={count > 0 ? e => { @@ -195,7 +197,7 @@ export default function Home({ onShowHistoryWithDate }) {
{weekSessions !== undefined && (
- {`${weekSessionCount} ØKTE${weekSessionCount !== 1 ? "R" : ""} · ${weekMuscleCount} MUSKELGRUPPE${weekMuscleCount !== 1 ? "R" : ""}`} + {t("planlegger.weekSummary", { count: weekSessionCount, muscleCount: weekMuscleCount }).toUpperCase()}
)}
@@ -203,19 +205,19 @@ export default function Home({ onShowHistoryWithDate }) { {/* Last session */}
- {isToday ? "DAGENS ØKT" : "SISTE ØKT"} + {isToday ? t("home.todaySession") : t("home.lastSession")}
{lastSession === undefined && (
- +
)} @@ -228,7 +230,7 @@ export default function Home({ onShowHistoryWithDate }) { padding: 24, textAlign: "center", color: "var(--cds-text-secondary)", fontSize: 14, }}> - Ingen økter logget ennå. Logg din første økt! + {t("home.noSessions")}
)} @@ -258,12 +260,12 @@ export default function Home({ onShowHistoryWithDate }) {
) : (
- Egentrening + {t("home.ownTraining")}
)}
- {exCount} øvelser - {muscleCount} muskler + {t("history.exerciseCount", { count: exCount })} + {t("history.exerciseCount", { count: muscleCount })}
diff --git a/app/src/components/LibraryPicker.jsx b/app/src/components/LibraryPicker.jsx index 4cd2548..98bf4b2 100644 --- a/app/src/components/LibraryPicker.jsx +++ b/app/src/components/LibraryPicker.jsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { Button, TextInput } from "@carbon/react"; +import { useTranslation } from "react-i18next"; // Searchable picker that shows library exercises and lets the user add one to a list. // Props: @@ -7,6 +8,7 @@ import { Button, TextInput } from "@carbon/react"; // onAdd(exercise) — called when user clicks an exercise // onClose() — called when user dismisses the picker export default function LibraryPicker({ libraryExercises, onAdd, onClose }) { + const { t } = useTranslation(); const [query, setQuery] = useState(""); const filtered = query.trim() ? libraryExercises.filter(e => e.name.toLowerCase().includes(query.toLowerCase())) @@ -21,16 +23,16 @@ export default function LibraryPicker({ libraryExercises, onAdd, onClose }) { }}> setQuery(e.target.value)} - placeholder="Skriv for å filtrere…" + placeholder={t("libraryPicker.searchPlaceholder")} style={{ marginBottom: 8 }} autoFocus />
{filtered.length === 0 ? ( -

Ingen treff.

+

{t("libraryPicker.noResults")}

) : ( filtered.map(ex => ( +
); } diff --git a/app/src/components/Login.jsx b/app/src/components/Login.jsx index e7854ad..e05ea9e 100644 --- a/app/src/components/Login.jsx +++ b/app/src/components/Login.jsx @@ -1,8 +1,10 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { supabase } from "../lib/supabase"; import { Button, TextInput, InlineNotification } from "@carbon/react"; import { Email } from "@carbon/icons-react"; +// Daily quotes stay in Norwegian regardless of language setting. function getDailyQuote() { const now = new Date(); const mmdd = String(now.getMonth() + 1).padStart(2, "0") + "-" + String(now.getDate()).padStart(2, "0"); @@ -26,6 +28,7 @@ function getDailyQuote() { } export default function Login() { + const { t } = useTranslation(); const [email, setEmail] = useState(""); const [loading, setLoading] = useState(false); const [sent, setSent] = useState(false); @@ -67,7 +70,7 @@ export default function Login() { Workout Lens
- Logg inn for å fortsette + {t("login.subtitle")}
{getDailyQuote()} @@ -84,9 +87,9 @@ export default function Login() { gap: 12, }}> -
Sjekk e-posten din
+
{t("login.checkEmail")}
- Vi sendte en innloggingslenke til {email} + {t("login.sentTo")} {email}
) : ( @@ -94,8 +97,8 @@ export default function Login() { setEmail(e.target.value)} required @@ -103,10 +106,10 @@ export default function Login() { {error && ( )} )} diff --git a/app/src/components/MuscleMap.jsx b/app/src/components/MuscleMap.jsx index b410251..e1f08b7 100644 --- a/app/src/components/MuscleMap.jsx +++ b/app/src/components/MuscleMap.jsx @@ -14,18 +14,14 @@ import ExerciseRowWithAutocomplete from "./ExerciseRowWithAutocomplete"; import BodyPanel from "./BodyPanel"; import PageShell, { SectionLabel, AccentChip, StickyCta } from "./PageShell"; import { useNav } from "../lib/NavContext"; +import { useTranslation } from "react-i18next"; +import i18n from "../lib/i18n"; const localDateStr = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,"0")}-${String(d.getDate()).padStart(2,"0")}`; }; -const STEP_DEFS = [ - { num: "01", label: "Knips" }, - { num: "02", label: "Bekreft" }, - { num: "03", label: "Resultat" }, -]; - const MAX_FILE_SIZE_MB = 5; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; @@ -111,6 +107,7 @@ export function reducer(state, action) { // ── MAIN COMPONENT ──────────────────────────────────────────────────── export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed }) { + const { t } = useTranslation(); const { onShowHome, onShowTemplatePicker, onShowReportWithPrefill } = useNav(); const [state, dispatch] = useReducer(reducer, initialState); const { step, images, exercises, muscles, error, dragging, editingId, @@ -122,6 +119,12 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } const [newExerciseIds, setNewExerciseIds] = useState(() => new Set()); const [useTodayDate, setUseTodayDate] = useState(true); + const STEP_DEFS = [ + { num: "01", label: t("muscleMap.stepSnap") }, + { num: "02", label: t("muscleMap.stepConfirm") }, + { num: "03", label: t("muscleMap.stepResult") }, + ]; + useEffect(() => { fetchLibraryExercises().then(setLibraryExercises).catch(() => {}); }, []); @@ -226,7 +229,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } const res = await callClaude({ model: CLAUDE_MODEL_TEXT, max_tokens: 1000, - messages: [{ role: "user", content: buildRecommendPrompt(trained, untrained) }] + messages: [{ role: "user", content: buildRecommendPrompt(trained, untrained, i18n.language) }] }); let data; try { data = await res.json(); } catch { throw new Error(`Serverfeil (${res.status}): Ugyldig svar fra server`); } @@ -255,11 +258,11 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed }
- LOGG ØKT + {t("muscleMap.sectionLabel")}
{/* Top-border stepper */} -
+
{STEP_DEFS.map((s, idx) => { const isActive = stepIndex === idx; const isComplete = stepIndex > idx; @@ -287,19 +290,19 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } {/* Hero */}
-
Ta bilde av
-
tavla.
+
{t("muscleMap.heroLine1")}
+
{t("muscleMap.heroLine2")}

- {images.length > 0 ? `${images.length} bilde${images.length !== 1 ? "r" : ""} valgt` : ""} + {images.length > 0 ? t("muscleMap.imageCount", { count: images.length }) : ""}

{/* Dropzone */} {images.length === 0 ? (
{ e.preventDefault(); dispatch({ type: "SET_DRAGGING", dragging: true }); }} onDragLeave={() => dispatch({ type: "SET_DRAGGING", dragging: false })} onDrop={(e) => { e.preventDefault(); dispatch({ type: "SET_DRAGGING", dragging: false }); handleFiles(e.dataTransfer.files); }} @@ -328,14 +331,14 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } }}>
-

Trykk for å velge bilde

-

eller dra og slipp · JPEG, PNG, WebP

+

{t("muscleMap.dropzoneClick")}

+

{t("muscleMap.dropzoneDrag")}

) : (
{ e.preventDefault(); dispatch({ type: "SET_DRAGGING", dragging: true }); }} onDragLeave={() => dispatch({ type: "SET_DRAGGING", dragging: false })} onDrop={(e) => { e.preventDefault(); dispatch({ type: "SET_DRAGGING", dragging: false }); handleFiles(e.dataTransfer.files); }} @@ -344,9 +347,9 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed }
{images.map((img, idx) => (
- {`Treningsbilde + {t("muscleMap.imageAlt",
@@ -395,7 +398,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } cursor: "pointer", }} > - Mal + {t("muscleMap.useTemplate")}
{/* Tips callout */}
-

Tips

-

God belysning og hele tavla i bildet gir best resultat. Flere bilder støttes.

+

{t("muscleMap.tipsHeading")}

+

{t("muscleMap.tipsBody")}

{sizeError && ( - + )}
{error && ( - + )}
@@ -446,7 +449,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } fontFamily: "var(--cds-font-sans)", fontSize: 14, cursor: "pointer", }} > - Avbryt + {t("common.cancel")}
@@ -474,7 +477,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } {step === "analyzing" && (
@@ -490,7 +493,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } {exercises.length} - øvelser funnet. + {t("muscleMap.foundExercises", { count: exercises.length })}
{/* Tilbake */} @@ -502,7 +505,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } display: "flex", alignItems: "center", gap: 4, }} > - Tilbake + {t("common.back")} {/* I dag / Annen dag segmented pill */} @@ -518,7 +521,7 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } transition: "background 120ms ease", }} > - I dag + {t("muscleMap.today")}
@@ -550,19 +553,19 @@ export default function MuscleMap({ templatePreload, onTemplatePreloadConsumed } }} style={{ marginBottom: 16 }} > - + )} {gymSessions.length > 0 && (