diff --git a/ui/src/locales/de.json b/ui/src/locales/de.json
new file mode 100644
index 0000000..ac6295b
--- /dev/null
+++ b/ui/src/locales/de.json
@@ -0,0 +1,456 @@
+{
+ "_meta": {
+ "flag": "🇩🇪",
+ "name": "Deutsch",
+ "locale": "de-DE",
+ "semiLocale": "de"
+ },
+
+ "app.demoBanner": "Du nutzt gerade die Demo-Version von Fredy. Es werden keine Immo-Sites gescrapt, alle Änderungen werden um Mitternacht zurückgesetzt.",
+
+ "nav.dashboard": "Dashboard",
+ "nav.jobs": "Jobs",
+ "nav.listings": "Inserate",
+ "nav.listingsOverview": "Übersicht",
+ "nav.mapView": "Kartenansicht",
+ "nav.watchlist": "Watchlist",
+ "nav.settings": "Einstellungen",
+ "nav.userManagement": "Benutzerverwaltung",
+ "nav.settingsPage": "Einstellungen",
+ "nav.expandSidebar": "Seitenleiste ausklappen",
+ "nav.collapseSidebar": "Seitenleiste einklappen",
+
+ "login.usernamePlaceholder": "Benutzername",
+ "login.passwordPlaceholder": "Passwort",
+ "login.loginButton": "Anmelden",
+ "login.errorMandatory": "Benutzername und Passwort sind Pflichtfelder.",
+ "login.errorInvalid": "Anmeldung fehlgeschlagen. Bitte überprüfe Benutzername und Passwort.",
+ "login.demoBanner": "Dies ist die Demo-Version von Fredy. Verwende 'demo' als Benutzername & Passwort zum Einloggen.",
+
+ "dashboard.title": "Dashboard",
+ "dashboard.sectionGeneral": "Allgemein",
+ "dashboard.sectionOverview": "Übersicht",
+ "dashboard.sectionProviderInsights": "Anbieter-Einblicke",
+ "dashboard.searchInterval": "Suchintervall",
+ "dashboard.searchIntervalDesc": "Zeitintervall für Job-Ausführung",
+ "dashboard.lastSearch": "Letzte Suche",
+ "dashboard.lastSearchDesc": "Zeitstempel der letzten Ausführung",
+ "dashboard.nextSearch": "Nächste Suche",
+ "dashboard.nextSearchDesc": "Zeitstempel der nächsten Ausführung",
+ "dashboard.searchNow": "Jetzt suchen",
+ "dashboard.searchNowDesc": "Suche sofort starten",
+ "dashboard.searchNowButton": "Jetzt suchen",
+ "dashboard.searchNowStarted": "Fredy-Suche erfolgreich gestartet.",
+ "dashboard.searchNowFailed": "Suche konnte nicht gestartet werden",
+ "dashboard.kpiJobs": "Jobs",
+ "dashboard.kpiJobsDesc": "Gesamtanzahl der Jobs",
+ "dashboard.kpiListings": "Inserate",
+ "dashboard.kpiListingsDesc": "Insgesamt gefundene Inserate",
+ "dashboard.kpiActiveListings": "Aktive Inserate",
+ "dashboard.kpiActiveListingsDesc": "Gesamtanzahl aktiver Inserate",
+ "dashboard.kpiMedianPrice": "Medianpreis",
+ "dashboard.kpiMedianPriceDesc": "Medianpreis der Inserate",
+
+ "jobs.title": "Jobs",
+ "jobs.newJob": "Neuer Job",
+ "jobs.searchPlaceholder": "Suchen",
+ "jobs.filterAll": "Alle",
+ "jobs.filterActive": "Aktiv",
+ "jobs.filterInactive": "Inaktiv",
+ "jobs.sortByName": "Name",
+ "jobs.sortByListings": "Anzahl Inserate",
+ "jobs.sortByStatus": "Status",
+ "jobs.sortPrefix": "Sortieren nach",
+ "jobs.sortAscending": "Aufsteigend",
+ "jobs.sortDescending": "Absteigend",
+ "jobs.tooltipGridView": "Rasteransicht",
+ "jobs.tooltipTableView": "Tabellenansicht",
+ "jobs.empty": "Noch keine Jobs vorhanden...",
+ "jobs.cardListings": "Inserate",
+ "jobs.cardProviders": "Anbieter",
+ "jobs.cardAdapters": "Adapter",
+ "jobs.cardActive": "Aktiv",
+ "jobs.cardSharedReadOnly": "Dieser Job wurde mit dir geteilt (nur lesbar).",
+ "jobs.cardRunning": "LÄUFT",
+ "jobs.popoverRunJob": "Job ausführen",
+ "jobs.popoverEditJob": "Job bearbeiten",
+ "jobs.popoverCloneJob": "Job klonen",
+ "jobs.popoverDeleteListings": "Alle gefundenen Inserate dieses Jobs löschen",
+ "jobs.popoverDeleteJob": "Job löschen",
+ "jobs.toastFinished": "Job abgeschlossen",
+ "jobs.toastRunStarted": "Job-Ausführung gestartet",
+ "jobs.toastRunRequested": "Job-Ausführung angefordert",
+ "jobs.toastAlreadyRunning": "Job läuft bereits",
+ "jobs.toastNotAllowed": "Du bist nicht berechtigt, diesen Job auszuführen",
+ "jobs.toastNotFound": "Job nicht gefunden",
+ "jobs.toastRunFailed": "Job konnte nicht gestartet werden",
+ "jobs.toastStatusChanged": "Job-Status erfolgreich geändert",
+ "jobs.toastDeletedWithListings": "Job und Inserate erfolgreich entfernt",
+ "jobs.toastListingsDeleted": "Inserate erfolgreich entfernt",
+ "jobs.toastDeleteError": "Fehler beim Löschen",
+ "jobs.tableSharedTooltip": "Mit dir geteilt (nur lesbar)",
+ "jobs.tableRunJob": "Job ausführen",
+ "jobs.tableEditJob": "Job bearbeiten",
+ "jobs.tableCloneJob": "Job klonen",
+ "jobs.tableDeleteListings": "Alle gefundenen Inserate löschen",
+ "jobs.tableDeleteJob": "Job löschen",
+
+ "jobs.mutation.editTitle": "Job bearbeiten",
+ "jobs.mutation.createTitle": "Neuen Job erstellen",
+ "jobs.mutation.back": "Zurück",
+ "jobs.mutation.save": "Speichern",
+ "jobs.mutation.cancel": "Abbrechen",
+ "jobs.mutation.saved": "Job erfolgreich gespeichert...",
+ "jobs.mutation.sectionName": "Name",
+ "jobs.mutation.namePlaceholder": "Name",
+ "jobs.mutation.sectionProviders": "Anbieter",
+ "jobs.mutation.providersHelp": "Ein Anbieter ist der Dienst (z. B. ImmoScout24, Kleinanzeigen), den Fredy nach neuen Inseraten durchsucht. Fredy öffnet einen neuen Tab mit der Website des Anbieters. Du musst deine Suchparameter anpassen und auf 'Suchen' klicken. Sobald die Ergebnisse angezeigt werden, kopiere die Browser-URL hier hinein.",
+ "jobs.mutation.addProvider": "Neuen Anbieter hinzufügen",
+ "jobs.mutation.sectionNotifications": "Benachrichtigungs-Adapter",
+ "jobs.mutation.notificationsHelp": "Fredy unterstützt mehrere Wege, dich über neue Inserate zu benachrichtigen. Diese werden als Benachrichtigungs-Adapter bezeichnet. Du kannst zwischen E-Mail, Telegram u. a. wählen.",
+ "jobs.mutation.addNotification": "Neuen Benachrichtigungs-Adapter hinzufügen",
+ "jobs.mutation.sectionBlacklist": "Blacklist",
+ "jobs.mutation.blacklistHelp": "Wenn ein Inserat eines dieser Wörter enthält, wird es herausgefiltert. Gib ein Wort ein und bestätige mit Enter.",
+ "jobs.mutation.blacklistPlaceholder": "Wort zum Filtern hinzufügen...",
+ "jobs.mutation.sectionCriteriaFilter": "Kriterienfilter",
+ "jobs.mutation.criteriaFilterHelp": "Inserate nach bestimmten Kriterien filtern. Nur Zahlen sind erlaubt. Felder können leer gelassen werden, wenn kein Filter gewünscht ist.",
+ "jobs.mutation.criteriaNumberPlaceholder": "Zahl eingeben",
+ "jobs.mutation.filterMaxPrice": "Höchstpreis",
+ "jobs.mutation.filterMinSize": "Mindestgröße (m²)",
+ "jobs.mutation.filterMinRooms": "Mindestzimmeranzahl",
+ "jobs.mutation.sectionAreaFilter": "Gebietsfilter",
+ "jobs.mutation.areaFilterHelp": "Definiere mehrere geografische Gebiete auf der Karte, um Inserate zu filtern. Beginne mit dem Zeichnen durch Klick auf das Quadrat-Symbol oben links auf der Karte. Klicke auf die Karte, um Punkte des Polygons hinzuzufügen. Wähle den ersten Punkt, um das Polygon zu schließen. Klicke danach auf eine freie Kartenfläche, um das Polygon anzuwenden (Farbe wechselt von Gelb zu Blau). Um ein Polygon zu löschen, wähle es zuerst aus und klicke dann auf das Papierkorb-Symbol.",
+ "jobs.mutation.sectionSharing": "Mit Benutzer teilen",
+ "jobs.mutation.sharingHelp": "Du kannst diesen Job mit anderen Benutzern teilen. Diese können die Inserate einsehen, aber nur du (als Ersteller) kannst den Job bearbeiten. Admins sind aus dieser Liste ausgeblendet, da sie Zugriff auf alles haben.",
+ "jobs.mutation.sharingNoUsers": "Keine Benutzer zum Teilen gefunden. Bitte erstelle weitere Nicht-Admin-Benutzer.",
+ "jobs.mutation.sharingSearchPlaceholder": "Benutzer suchen",
+ "jobs.mutation.sectionActivation": "Job-Aktivierung",
+ "jobs.mutation.activationHelp": "Gibt an, ob der Job aktiviert ist. Inaktive Jobs werden ignoriert, wenn Fredy nach neuen Inseraten sucht.",
+ "jobs.deletion.title": "Job löschen",
+ "jobs.deletion.message": "Bist du sicher, dass du diesen Job löschen möchtest? Alle zugehörigen Inserate werden aus der Datenbank entfernt.",
+
+ "listings.title": "Inserate",
+ "listings.watchlistTitle": "Watchlist",
+ "listings.searchPlaceholder": "Suchen",
+ "listings.filterAll": "Alle",
+ "listings.filterActive": "Aktiv",
+ "listings.filterInactive": "Inaktiv",
+ "listings.filterWatched": "Beobachtet",
+ "listings.filterUnwatched": "Nicht beobachtet",
+ "listings.filterStatusPlaceholder": "Status",
+ "listings.filterStatusApplied": "Beworben",
+ "listings.filterStatusRejected": "Abgelehnt",
+ "listings.filterStatusAccepted": "Angenommen",
+ "listings.filterStatusNone": "Kein Status",
+ "listings.filterProviderPlaceholder": "Anbieter",
+ "listings.filterJobPlaceholder": "Job",
+ "listings.sortByJobName": "Job-Name",
+ "listings.sortByDate": "Inserat-Datum",
+ "listings.sortByPrice": "Preis",
+ "listings.sortByProvider": "Anbieter",
+ "listings.sortPrefix": "Sortieren nach",
+ "listings.sortAscending": "Aufsteigend",
+ "listings.sortDescending": "Absteigend",
+ "listings.tooltipGridView": "Rasteransicht",
+ "listings.tooltipTableView": "Tabellenansicht",
+ "listings.empty": "Noch keine Inserate vorhanden...",
+ "listings.toastAddedToWatchlist": "Inserat zur Watchlist hinzugefügt",
+ "listings.toastRemovedFromWatchlist": "Inserat von der Watchlist entfernt",
+ "listings.toastWatchlistError": "Watchlist-Aktion fehlgeschlagen",
+ "listings.toastStatusCleared": "Status zurückgesetzt",
+ "listings.toastStatusMarked": "Als {{status}} markiert",
+ "listings.toastStatusUpdateError": "Status konnte nicht aktualisiert werden",
+ "listings.toastDeleted": "Inserat erfolgreich entfernt",
+ "listings.toastDeleteError": "Fehler beim Löschen des Inserats",
+ "listings.cardInactive": "Inaktiv",
+ "listings.tooltipAddToWatchlist": "Zur Watchlist hinzufügen",
+ "listings.tooltipRemoveFromWatchlist": "Von der Watchlist entfernen",
+ "listings.tooltipOriginalListing": "Original-Inserat",
+ "listings.tooltipViewInFredy": "In Fredy anzeigen",
+ "listings.tooltipRemove": "Entfernen",
+
+ "listing.detail.back": "Zurück",
+ "listing.detail.defaultTitle": "Inserat-Details",
+ "listing.detail.noAddress": "Keine Adresse angegeben",
+ "listing.detail.watch": "Beobachten",
+ "listing.detail.watched": "Beobachtet",
+ "listing.detail.openListing": "Inserat öffnen",
+ "listing.detail.delete": "Löschen",
+ "listing.detail.noImageAlt": "Kein Bild verfügbar",
+ "listing.detail.notesTitle": "Notizen",
+ "listing.detail.notesPlaceholder": "Deine privaten Notizen zu diesem Inserat…",
+ "listing.detail.storeNotes": "Notizen speichern",
+ "listing.detail.detailsTitle": "Details",
+ "listing.detail.descriptionTitle": "Beschreibung",
+ "listing.detail.noDescription": "Keine Beschreibung verfügbar.",
+ "listing.detail.distanceToHome": "Entfernung nach Hause:",
+ "listing.detail.locationTitle": "Lage",
+ "listing.detail.noGeoWarning": "Dieses Inserat hat keine gültigen Geokoordinaten und kann daher nicht auf der Karte angezeigt werden.",
+ "listing.detail.fieldPrice": "Preis",
+ "listing.detail.fieldPriceHelp": "Der Angebotspreis dieses Inserats laut Anbieter.",
+ "listing.detail.fieldSize": "Größe",
+ "listing.detail.fieldSizeHelp": "Wohnfläche des Inserats in Quadratmetern.",
+ "listing.detail.fieldRooms": "Zimmer",
+ "listing.detail.fieldRoomsHelp": "Anzahl der Zimmer im Inserat.",
+ "listing.detail.fieldJob": "Job",
+ "listing.detail.fieldJobHelp": "Der Fredy-Job, der dieses Inserat gefunden hat.",
+ "listing.detail.fieldProvider": "Anbieter",
+ "listing.detail.fieldProviderHelp": "Das Immobilienportal, von dem dieses Inserat gescrapt wurde.",
+ "listing.detail.fieldAdded": "Hinzugefügt",
+ "listing.detail.fieldAddedHelp": "Wann Fredy dieses Inserat erstmals zur Datenbank hinzugefügt hat.",
+ "listing.detail.fieldStatus": "Status",
+ "listing.detail.statusApplied": "Beworben",
+ "listing.detail.statusAccepted": "Akzeptiert",
+ "listing.detail.statusRejected": "Abgelehnt",
+ "listing.detail.statusSetAt": "(gesetzt am {{date}})",
+ "listing.detail.fieldStatusHelp": "Der von dir gesetzte Status für dieses Inserat und wann du ihn gesetzt hast.",
+ "listing.detail.fieldRoomsValue": "{{count}} Zimmer",
+ "listing.detail.mapPopupListingLocation": "Inserat-Standort",
+ "listing.detail.mapPopupHomeAddress": "Heimatadresse",
+ "listing.detail.toastDeleted": "Inserat erfolgreich entfernt",
+ "listing.detail.toastDeleteError": "Fehler beim Löschen des Inserats",
+ "listing.detail.toastWatchlistAdded": "Zur Watchlist hinzugefügt",
+ "listing.detail.toastWatchlistRemoved": "Von der Watchlist entfernt",
+ "listing.detail.toastWatchlistError": "Watchlist-Aktion fehlgeschlagen",
+ "listing.detail.toastNotesSaved": "Notizen gespeichert",
+ "listing.detail.toastNotesError": "Notizen konnten nicht gespeichert werden",
+ "listing.detail.toastLoadError": "Inserat-Details konnten nicht geladen werden",
+
+ "listing.deletion.title": "Inserate löschen",
+ "listing.deletion.message": "Wie möchtest du die ausgewählten Inserate löschen?",
+ "listing.deletion.confirm": "Bestätigen",
+ "listing.deletion.cancel": "Abbrechen",
+ "listing.deletion.softLabel": "Als gelöscht markieren (Soft Delete)",
+ "listing.deletion.softDescription": "Inserate bleiben in der Datenbank, werden aber als ausgeblendet markiert. Sie erscheinen beim nächsten Scraping nicht erneut.",
+ "listing.deletion.hardLabel": "Aus Datenbank entfernen (Hard Delete)",
+ "listing.deletion.hardDescription": "Inserate werden vollständig aus der Datenbank entfernt.",
+ "listing.deletion.hardConsequence": "Folge: Sie könnten beim nächsten Scraping wieder erscheinen, da Fredy nicht weiß, dass sie bereits gefunden wurden.",
+ "listing.deletion.rememberChoice": "Meine Wahl merken und diesen Dialog beim nächsten Mal überspringen",
+
+ "listings.status.none": "Kein",
+ "listings.status.applied": "Beworben",
+ "listings.status.rejected": "Abgelehnt",
+ "listings.status.accepted": "Angenommen",
+ "listings.status.statusLabel": "Status",
+ "listings.status.tooltip": "Verfolge deinen Stand bei diesem Inserat: Beworben, sobald du Kontakt aufgenommen hast, Abgelehnt wenn es nicht geklappt hat, oder Angenommen wenn du es bekommen hast.",
+
+ "map.title": "Kartenansicht",
+ "map.noHomeAddress": "Keine Heimatadresse gesetzt. Konfiguriere sie in den Benutzereinstellungen, um den Entfernungsfilter zu nutzen.",
+ "map.onlyValidAddresses": "Auf dieser Karte werden nur Inserate mit gültigen Adressen angezeigt.",
+ "map.filterJobLabel": "Job",
+ "map.filterJobPlaceholder": "Alle Jobs",
+ "map.filterDistanceLabel": "Entfernung",
+ "map.filterDistanceNone": "Keine",
+ "map.filterPriceLabel": "Preis (€)",
+ "map.filterStyleLabel": "Stil",
+ "map.filterStyleStandard": "Standard",
+ "map.filterStyleSatellite": "Satellit",
+ "map.filter3dBuildings": "3D-Gebäude",
+ "map.popupPrice": "Preis:",
+ "map.popupAddress": "Adresse:",
+ "map.popupJob": "Job:",
+ "map.popupProvider": "Anbieter:",
+ "map.popupSize": "Größe:",
+ "map.popupViewDetails": "Details anzeigen",
+ "map.popupRemove": "Entfernen",
+ "map.popupHomeAddress": "Heimatadresse",
+ "map.noHomeAddressBefore": "Keine Heimadresse gesetzt. Konfiguriere sie in den ",
+ "map.noHomeAddressLink": "Benutzereinstellungen",
+ "map.noHomeAddressAfter": ", um den Entfernungsfilter zu nutzen.",
+ "map.toastDeleted": "Inserat erfolgreich entfernt",
+ "map.toastDeleteError": "Fehler beim Löschen des Inserats",
+
+ "users.title": "Benutzer",
+ "users.newUser": "Neuer Benutzer",
+ "users.tableColumnUser": "Benutzer",
+ "users.tableColumnLastLogin": "Letzter Login",
+ "users.tableColumnJobs": "Jobs",
+ "users.tableColumnMcpToken": "MCP-Token",
+ "users.tableAdminBadge": "ADMIN",
+ "users.emptyState": "Keine Benutzer gefunden.",
+ "users.toastRemoved": "Benutzer erfolgreich entfernt",
+ "users.removalModal.title": "Benutzer entfernen",
+ "users.removalModal.message": "Das Entfernen dieses Benutzers entfernt auch alle zugehörigen Jobs.",
+
+ "users.mutation.editTitle": "Benutzer bearbeiten",
+ "users.mutation.newTitle": "Neuer Benutzer",
+ "users.mutation.back": "Zurück",
+ "users.mutation.save": "Speichern",
+ "users.mutation.cancel": "Abbrechen",
+ "users.mutation.saved": "Benutzer erfolgreich gespeichert...",
+ "users.mutation.sectionUsername": "Benutzername",
+ "users.mutation.usernameHelp": "Der Benutzername für den Login bei Fredy",
+ "users.mutation.usernamePlaceholder": "Benutzername",
+ "users.mutation.sectionPassword": "Passwort",
+ "users.mutation.passwordHelp": "Das Passwort für den Login bei Fredy",
+ "users.mutation.passwordPlaceholder": "Passwort",
+ "users.mutation.sectionRetypePassword": "Passwort wiederholen",
+ "users.mutation.retypePasswordHelp": "Passwort wiederholen, um die Übereinstimmung zu prüfen",
+ "users.mutation.retypePasswordPlaceholder": "Passwort wiederholen",
+ "users.mutation.sectionIsAdmin": "Ist der Benutzer ein Admin?",
+ "users.mutation.isAdminHelp": "Aktivieren, wenn der Benutzer ein Administrator ist",
+
+ "settings.title": "Einstellungen",
+ "settings.tabSystem": "System",
+ "settings.tabExecution": "Ausführung",
+ "settings.tabUserSettings": "Benutzereinstellungen",
+ "settings.tabBackup": "Backup & Wiederherstellung",
+ "settings.save": "Speichern",
+ "settings.port": "Port",
+ "settings.portHelp": "Der Port, auf dem Fredy läuft.",
+ "settings.portPlaceholder": "Port",
+ "settings.baseUrl": "Basis-URL",
+ "settings.baseUrlHelp": "Öffentliche URL, unter der Fredy erreichbar ist (z. B. http://192.168.1.10:9998). Wird für 'In Fredy öffnen'-Links in Benachrichtigungen verwendet.",
+ "settings.baseUrlPlaceholder": "Basis-URL",
+ "settings.sqlitePath": "SQLite-Datenbankpfad",
+ "settings.sqlitePathHelp": "Das Verzeichnis, in dem Fredy seine SQLite-Datenbankdateien speichert.",
+ "settings.sqlitePathWarning": "Das Ändern dieses Pfades kann zu Datenverlust führen. Starte Fredy sofort nach dem Speichern neu.",
+ "settings.sqlitePathPlaceholder": "Datenbankordnerpfad",
+ "settings.analytics": "Analysen",
+ "settings.analyticsHelp": "Anonyme Nutzungsdaten zur Verbesserung von Fredy (Anbieter, Adapter-Namen, Betriebssystem, Node-Version und Architektur).",
+ "settings.analyticsEnable": "Analysen aktivieren",
+ "settings.demoMode": "Demo-Modus",
+ "settings.demoModeHelp": "Im Demo-Modus sucht Fredy nicht nach Immobilien und alle Daten werden um Mitternacht auf Standardwerte zurückgesetzt.",
+ "settings.demoModeEnable": "Demo-Modus aktivieren",
+ "settings.searchInterval": "Suchintervall",
+ "settings.searchIntervalHelp": "Intervall in Minuten für Anfragen an konfigurierte Dienste. Gehe nicht unter 5 Minuten, um nicht als Bot erkannt zu werden.",
+ "settings.searchIntervalPlaceholder": "Intervall in Minuten",
+ "settings.searchIntervalSuffix": "Minuten",
+ "settings.workingHours": "Arbeitszeiten",
+ "settings.workingHoursHelp": "Fredy sucht nur während dieser Zeiten nach Inseraten. Leer lassen, um rund um die Uhr zu suchen.",
+ "settings.workingHoursFrom": "Von",
+ "settings.workingHoursUntil": "Bis",
+ "settings.proxyUrl": "Proxy-URL",
+ "settings.proxyUrlHelp": "Optional. Leitet den Scraping-Browser durch einen Proxy. Server/Rechenzentrum-IPs werden von Anbietern (z. B. Immowelt) unabhängig vom Browser-Fingerprint häufig blockiert. Ein deutscher Wohnproxy lässt Anfragen wie einen normalen Haushalt erscheinen. Format: http://benutzer:passwort@host:port oder socks5://benutzer:passwort@host:port. Leer lassen zum Deaktivieren.",
+ "settings.proxyUrlPlaceholder": "http://benutzer:passwort@host:port",
+ "settings.homeAddress": "Heimatadresse",
+ "settings.homeAddressHelp": "Wird zur Berechnung der Entfernung zwischen deinem Standort und jedem Inserat verwendet. Eine Aktualisierung berechnet die Entfernungen für alle aktiven Inserate neu.",
+ "settings.homeAddressPlaceholder": "Heimatadresse eingeben",
+ "settings.homeAddressGeoError": "Adresse gefunden, konnte aber nicht genau geokodiert werden.",
+ "settings.providerDetails": "Anbieter-Details",
+ "settings.providerDetailsHelp": "Zusätzliche Details (Beschreibung, Attribute, Maklerinfos) für Inserate abrufen. Erfordert einen zusätzlichen API-Aufruf pro Inserat.",
+ "settings.providerDetailsWarning": "Das Aktivieren dieser Funktion erhöht die API-Anfragen an Anbieter erheblich und erhöht das Risiko von Rate-Limiting oder Blockierung. Auf eigene Gefahr verwenden.",
+ "settings.providerDetailsPlaceholder": "Anbieter für Detail-Abruf auswählen...",
+ "settings.providerDetailsUpdated": "Anbieter-Detail-Einstellung aktualisiert.",
+ "settings.providerDetailsUpdateError": "Einstellung konnte nicht aktualisiert werden.",
+ "settings.listingDeletion": "Inserate löschen",
+ "settings.listingDeletionHelp": "Wähle den Standard-Löschmodus. Soft Delete blendet Inserate aus ohne erneutes Scraping; Hard Delete entfernt sie aus der Datenbank.",
+ "settings.listingDeletionSoftLabel": "Als gelöscht markieren (Soft Delete)",
+ "settings.listingDeletionSoftDesc": "Inserate bleiben in der Datenbank, werden aber als ausgeblendet markiert. Sie erscheinen beim nächsten Scraping nicht erneut.",
+ "settings.listingDeletionHardLabel": "Aus Datenbank entfernen (Hard Delete)",
+ "settings.listingDeletionHardDesc": "Inserate werden vollständig aus der Datenbank entfernt.",
+ "settings.listingDeletionHardConsequence": "Folge: Sie könnten beim nächsten Scraping wieder erscheinen, da Fredy nicht weiß, dass sie bereits gefunden wurden.",
+ "settings.listingDeletionSkipPrompt": "Bestätigungsdialog überspringen",
+ "settings.userSettingsSaved": "Einstellungen gespeichert. Entfernungsberechnungen laufen im Hintergrund.",
+ "settings.userSettingsSaveError": "Fehler beim Speichern der Einstellungen",
+ "settings.backupDownload": "Backup herunterladen",
+ "settings.backupRestoreFromZip": "Aus ZIP wiederherstellen",
+ "settings.backupHelp": "Lade ein gezipptes Backup deiner Datenbank herunter oder stelle es aus einem Backup-ZIP wieder her.",
+ "settings.backupSectionName": "Backup & Wiederherstellung",
+ "settings.backupDemoWarning": "Backup und Wiederherstellung sind im Demo-Modus nicht verfügbar.",
+ "settings.backupDownloadError": "Unerwarteter Fehler beim Herunterladen des Backups.",
+ "settings.backupAnalyzeError": "Backup konnte nicht analysiert werden.",
+ "settings.backupRestoreCompleted": "Wiederherstellung abgeschlossen. Bitte starte das Fredy-Backend jetzt neu!",
+ "settings.backupRestoreError": "Unerwarteter Fehler bei der Wiederherstellung des Backups.",
+ "settings.restoreModalTitle": "Datenbank wiederherstellen",
+ "settings.restoreNow": "Jetzt wiederherstellen",
+ "settings.restoreAnyway": "Trotzdem wiederherstellen",
+ "settings.restoreProblemDetected": "Problem erkannt",
+ "settings.restoreMigrationsApplied": "Automatische Migrationen werden angewendet",
+ "settings.restoreCompatible": "Backup ist kompatibel",
+ "settings.restoreMigrationInfo": "Backup-Migration: {{backupMigration}} | Erforderliche Migration: {{requiredMigration}}",
+ "settings.toastIntervalEmpty": "Das Intervall darf nicht leer sein.",
+ "settings.toastPortEmpty": "Der Port darf nicht leer sein.",
+ "settings.toastWorkingHoursIncomplete": "Arbeitszeiten Von und Bis müssen beide gesetzt sein, wenn eines davon gesetzt wurde.",
+ "settings.toastSqlitePathEmpty": "Der SQLite-Datenbankpfad darf nicht leer sein.",
+ "settings.toastSavedReloading": "Einstellungen erfolgreich gespeichert. Der Browser wird in 3 Sekunden neu geladen.",
+ "settings.toastSaveError": "Fehler beim Speichern der Einstellungen.",
+
+ "watchlist.sectionName": "Benachrichtigung für Watchlist",
+ "watchlist.sectionHelp": "Du kannst bei Änderungen an Inseraten auf deiner Watchlist benachrichtigt werden.",
+ "watchlist.noteTitle": "Hinweis",
+ "watchlist.noteDescription": "Du erhältst Benachrichtigungen nur für Inserate auf deiner Watchlist. Um Inserate hinzuzufügen, öffne den Bereich 'Inserate' und markiere die gewünschten.",
+ "watchlist.notifyMeWhen": "Benachrichtige mich wenn:",
+ "watchlist.activityChanges": "Inserat-Status ändert sich (z. B. Inserat wird inaktiv)",
+ "watchlist.priceChanges": "Inserat-Preis ändert sich",
+ "watchlist.notifyMeWith": "Benachrichtige mich per:",
+ "watchlist.selectNotificationMethod": "Benachrichtigungsmethode auswählen",
+ "watchlist.addNotificationTitle": "Benachrichtigungsmethode hinzufügen",
+ "watchlist.addNotificationDescription": "Wenn sich etwas geändert hat, benachrichtigt dich Fredy über den ausgewählten Benachrichtigungs-Adapter. Hinweis: Einige Adapter wie SQLite sind hier nicht verfügbar.",
+
+ "notification.defaultTitle": "Neuen Benachrichtigungs-Adapter hinzufügen",
+ "notification.description": "Wenn Fredy neue Inserate findet, möchten wir dich darüber informieren. Dazu können Benachrichtigungs-Adapter konfiguriert werden. Es gibt mehrere Wege, wie Fredy neue Inserate an dich senden kann. Wähle deinen Kanal...",
+ "notification.selectPlaceholder": "Benachrichtigungs-Adapter auswählen",
+ "notification.try": "Testen",
+ "notification.cancel": "Abbrechen",
+ "notification.save": "Speichern",
+ "notification.trySuccess": "Es scheint geklappt zu haben! Bitte überprüfe deinen Dienst.",
+ "notification.tryError": "Das hat leider nicht funktioniert :-( Ich habe folgenden Fehler erhalten: {{error}}",
+ "notification.errorTitle": "Fehler",
+ "notification.successTitle": "Super!",
+ "notification.validationAllMandatory": "Alle Felder sind Pflichtfelder und müssen ausgefüllt werden.",
+ "notification.validationNumberField": "Ein Zahlenfeld darf nur Zahlen enthalten und muss größer 0 sein.",
+ "notification.validationBooleanField": "Ein Boolean-Feld darf keinen anderen Typ haben.",
+ "notification.infoTitle": "Information",
+ "notification.tableEmptyState": "Keine Benachrichtigungs-Adapter gefunden.",
+ "notification.tableColumnName": "Name",
+
+ "provider.defaultTitle": "Neuen Anbieter hinzufügen",
+ "provider.editTitle": "Bestehenden Anbieter bearbeiten",
+ "provider.save": "Speichern",
+ "provider.description": "Anbieter sind das Herzstück von Fredy. Wir unterstützen mehrere Anbieter wie Immowelt, Immoscout usw. Wähle einen Anbieter aus der Liste. Fredy öffnet dann die URL des Anbieters in einem neuen Tab.",
+ "provider.descriptionStep2": "Du musst deine Suchparameter konfigurieren, so wie du es bei einer normalen Suche auf der Anbieter-Website tun würdest. Wenn die Suchergebnisse angezeigt werden, kopiere die URL und füge sie in das Textfeld ein.",
+ "provider.editDescription": "Du kannst jetzt die URL des Anbieters {{name}} im Eingabefeld unten bearbeiten.",
+ "provider.selectPlaceholder": "Anbieter auswählen",
+ "provider.urlPlaceholder": "Anbieter-URL",
+ "provider.validationSelectAndUrl": "Bitte wähle einen Anbieter aus und kopiere die Browser-URL in das Textfeld, nachdem du deine Suchparameter konfiguriert hast.",
+ "provider.validationInvalidUrl": "Die kopierte URL ist ungültig.",
+ "provider.errorTitle": "Fehler",
+ "provider.tableEmptyState": "Keine Anbieter gefunden.",
+ "provider.tableColumnName": "Name",
+ "provider.tableColumnUrl": "URL",
+ "provider.tableOpenProvider": "Anbieter öffnen",
+
+ "news.videoFallback": "Dein Browser unterstützt das Video-Tag nicht.",
+
+ "version.newVersionAvailable": "Neue Version verfügbar",
+ "version.currentLabel": "Aktuell: {{version}}",
+ "version.releaseNotes": "Versionshinweise",
+ "version.newBadge": "Neu",
+ "version.modalClose": "Schließen",
+ "version.viewOnGithub": "Auf GitHub ansehen",
+ "version.yourVersion": "Deine Version",
+ "version.latestVersion": "Neueste Version",
+
+ "tracking.okText": "Ja! Ich möchte helfen",
+ "tracking.cancelText": "Nein, danke",
+ "tracking.greeting": "Hey 👋",
+ "tracking.paragraph1": "Genug von Popups? Ich auch. Aber dieses hier ist wichtig, und es wird nur einmal erscheinen ;)",
+ "tracking.paragraph2": "Fredy ist völlig kostenlos (und wird es immer bleiben). Wenn du möchtest, kannst du mich über GitHub unterstützen, aber das ist absolut keine Pflicht.",
+ "tracking.paragraph3": "Es wäre jedoch eine große Hilfe, wenn du mir erlaubst, einige Analysedaten zu sammeln. Warte, bevor du auf 'Nein' klickst, lass mich erklären. Wenn du zustimmst, sendet Fredy alle 6 Stunden einen Ping an mein internes Tracking-Projekt. (Wird bald open-source)",
+ "tracking.paragraph4": "Die Daten umfassen: Namen aktiver Adapter/Anbieter, Betriebssystem, Architektur, Node-Version und Sprache. Die Informationen sind vollständig anonym und helfen mir zu verstehen, welche Adapter/Anbieter am häufigsten genutzt werden.",
+ "tracking.thanks": "Danke🤘",
+
+ "permission.title": "Unzureichende Berechtigung :(",
+
+ "footer.madeWith": "Mit ❤️ entwickelt von",
+
+ "dashboard.noData": "Keine Daten",
+
+ "common.save": "Speichern",
+ "common.cancel": "Abbrechen",
+ "common.delete": "Löschen",
+ "common.edit": "Bearbeiten",
+ "common.back": "Zurück",
+ "common.confirm": "Bestätigen",
+ "common.yes": "Ja",
+ "common.no": "Nein",
+ "common.na": "k. A.",
+ "common.loading": "Laden...",
+ "common.ariaGridView": "Rasteransicht",
+ "common.ariaTableView": "Tabellenansicht",
+ "common.startNow": "Jetzt starten",
+ "settings.language": "Sprache",
+ "settings.languageHelp": "Die Sprache der Benutzeroberfläche.",
+ "settings.languageSaveError": "Spracheinstellung konnte nicht gespeichert werden."
+}
diff --git a/ui/src/locales/en.json b/ui/src/locales/en.json
new file mode 100644
index 0000000..6ecd987
--- /dev/null
+++ b/ui/src/locales/en.json
@@ -0,0 +1,456 @@
+{
+ "_meta": {
+ "flag": "🇬🇧",
+ "name": "English",
+ "locale": "en-US",
+ "semiLocale": "en_US"
+ },
+
+ "app.demoBanner": "You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight.",
+
+ "nav.dashboard": "Dashboard",
+ "nav.jobs": "Jobs",
+ "nav.listings": "Listings",
+ "nav.listingsOverview": "Overview",
+ "nav.mapView": "Map View",
+ "nav.watchlist": "Watchlist",
+ "nav.settings": "Settings",
+ "nav.userManagement": "User Management",
+ "nav.settingsPage": "Settings",
+ "nav.expandSidebar": "Expand sidebar",
+ "nav.collapseSidebar": "Collapse sidebar",
+
+ "login.usernamePlaceholder": "Username",
+ "login.passwordPlaceholder": "Password",
+ "login.loginButton": "Login",
+ "login.errorMandatory": "Username and password are mandatory.",
+ "login.errorInvalid": "Login unsuccessful. Please check your username and password.",
+ "login.demoBanner": "This is the demo version of Fredy. Use 'demo' as both the username and password to log in.",
+
+ "dashboard.title": "Dashboard",
+ "dashboard.sectionGeneral": "General",
+ "dashboard.sectionOverview": "Overview",
+ "dashboard.sectionProviderInsights": "Provider Insights",
+ "dashboard.searchInterval": "Search Interval",
+ "dashboard.searchIntervalDesc": "Time interval for job execution",
+ "dashboard.lastSearch": "Last Search",
+ "dashboard.lastSearchDesc": "Last execution timestamp",
+ "dashboard.nextSearch": "Next Search",
+ "dashboard.nextSearchDesc": "Next execution timestamp",
+ "dashboard.searchNow": "Search Now",
+ "dashboard.searchNowDesc": "Run a search now",
+ "dashboard.searchNowButton": "Search now",
+ "dashboard.searchNowStarted": "Successfully triggered Fredy search.",
+ "dashboard.searchNowFailed": "Failed to trigger search",
+ "dashboard.kpiJobs": "Jobs",
+ "dashboard.kpiJobsDesc": "Total number of jobs",
+ "dashboard.kpiListings": "Listings",
+ "dashboard.kpiListingsDesc": "Total listings found",
+ "dashboard.kpiActiveListings": "Active Listings",
+ "dashboard.kpiActiveListingsDesc": "Total active listings",
+ "dashboard.kpiMedianPrice": "Median Price",
+ "dashboard.kpiMedianPriceDesc": "Median Price of listings",
+
+ "jobs.title": "Jobs",
+ "jobs.newJob": "New Job",
+ "jobs.searchPlaceholder": "Search",
+ "jobs.filterAll": "All",
+ "jobs.filterActive": "Active",
+ "jobs.filterInactive": "Inactive",
+ "jobs.sortByName": "Name",
+ "jobs.sortByListings": "Number of Listings",
+ "jobs.sortByStatus": "Status",
+ "jobs.sortPrefix": "Sort by",
+ "jobs.sortAscending": "Ascending",
+ "jobs.sortDescending": "Descending",
+ "jobs.tooltipGridView": "Grid view",
+ "jobs.tooltipTableView": "Table view",
+ "jobs.empty": "No jobs available yet...",
+ "jobs.cardListings": "Listings",
+ "jobs.cardProviders": "Providers",
+ "jobs.cardAdapters": "Adapters",
+ "jobs.cardActive": "Active",
+ "jobs.cardSharedReadOnly": "This job has been shared with you - read only.",
+ "jobs.cardRunning": "RUNNING",
+ "jobs.popoverRunJob": "Run Job",
+ "jobs.popoverEditJob": "Edit a Job",
+ "jobs.popoverCloneJob": "Clone Job",
+ "jobs.popoverDeleteListings": "Delete all found Listings of this Job",
+ "jobs.popoverDeleteJob": "Delete Job",
+ "jobs.toastFinished": "Job finished",
+ "jobs.toastRunStarted": "Job run started",
+ "jobs.toastRunRequested": "Job run requested",
+ "jobs.toastAlreadyRunning": "Job is already running",
+ "jobs.toastNotAllowed": "You are not allowed to run this job",
+ "jobs.toastNotFound": "Job not found",
+ "jobs.toastRunFailed": "Failed to trigger job",
+ "jobs.toastStatusChanged": "Job status successfully changed",
+ "jobs.toastDeletedWithListings": "Job and listings successfully removed",
+ "jobs.toastListingsDeleted": "Listings successfully removed",
+ "jobs.toastDeleteError": "Error performing deletion",
+ "jobs.tableSharedTooltip": "Shared with you - read only",
+ "jobs.tableRunJob": "Run Job",
+ "jobs.tableEditJob": "Edit Job",
+ "jobs.tableCloneJob": "Clone Job",
+ "jobs.tableDeleteListings": "Delete all found Listings",
+ "jobs.tableDeleteJob": "Delete Job",
+
+ "jobs.mutation.editTitle": "Edit Job",
+ "jobs.mutation.createTitle": "Create new Job",
+ "jobs.mutation.back": "Back",
+ "jobs.mutation.save": "Save",
+ "jobs.mutation.cancel": "Cancel",
+ "jobs.mutation.saved": "Job successfully saved...",
+ "jobs.mutation.sectionName": "Name",
+ "jobs.mutation.namePlaceholder": "Name",
+ "jobs.mutation.sectionProviders": "Providers",
+ "jobs.mutation.providersHelp": "A provider is essentially the service (e.g. ImmoScout24, Kleinanzeigen) that Fredy searches for new listings. Fredy will open a new tab pointing to the website of this provider. You have to adjust your search parameter and click on \"Search\". If the results are being shown, copy the browser URL in here.",
+ "jobs.mutation.addProvider": "Add new Provider",
+ "jobs.mutation.sectionNotifications": "Notification Adapters",
+ "jobs.mutation.notificationsHelp": "Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc.",
+ "jobs.mutation.addNotification": "Add new Notification Adapter",
+ "jobs.mutation.sectionBlacklist": "Blacklist",
+ "jobs.mutation.blacklistHelp": "If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter.",
+ "jobs.mutation.blacklistPlaceholder": "Add a word for filtering...",
+ "jobs.mutation.sectionCriteriaFilter": "Criteria Filter",
+ "jobs.mutation.criteriaFilterHelp": "Filter listings by specific criteria. Only numbers are allowed. You can leave fields empty if you don't want to filter by them.",
+ "jobs.mutation.criteriaNumberPlaceholder": "Add a number",
+ "jobs.mutation.filterMaxPrice": "Max Price",
+ "jobs.mutation.filterMinSize": "Min Size (m²)",
+ "jobs.mutation.filterMinRooms": "Min Rooms",
+ "jobs.mutation.sectionAreaFilter": "Area Filter",
+ "jobs.mutation.areaFilterHelp": "Define multiple geographic areas on the map to filter listings. Start drawing by clicking on the square symbol in the top left corner of the map. Click on the map to add points of the polygon. Select the first point to close the polygon. After that, click on a free area of the map to apply this polygon (the color will change from yellow to blue). To delete a polygon, select it first and then click on the trash symbol.",
+ "jobs.mutation.sectionSharing": "Sharing with user",
+ "jobs.mutation.sharingHelp": "You can share this job with other users. They will be able to see the listings, but only (as the creator) you can edit the job. Admins are filtered from this list as they have access to everything.",
+ "jobs.mutation.sharingNoUsers": "No users found to share this Job to. Please create additional non-admin user.",
+ "jobs.mutation.sharingSearchPlaceholder": "Search user",
+ "jobs.mutation.sectionActivation": "Job activation",
+ "jobs.mutation.activationHelp": "Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings.",
+ "jobs.deletion.title": "Delete Job",
+ "jobs.deletion.message": "Are you sure you want to delete this job? All associated listings will be removed from the database.",
+
+ "listings.title": "Listings",
+ "listings.watchlistTitle": "Watchlist",
+ "listings.searchPlaceholder": "Search",
+ "listings.filterAll": "All",
+ "listings.filterActive": "Active",
+ "listings.filterInactive": "Inactive",
+ "listings.filterWatched": "Watched",
+ "listings.filterUnwatched": "Unwatched",
+ "listings.filterStatusPlaceholder": "Status",
+ "listings.filterStatusApplied": "Applied",
+ "listings.filterStatusRejected": "Rejected",
+ "listings.filterStatusAccepted": "Accepted",
+ "listings.filterStatusNone": "No status",
+ "listings.filterProviderPlaceholder": "Provider",
+ "listings.filterJobPlaceholder": "Job",
+ "listings.sortByJobName": "Job Name",
+ "listings.sortByDate": "Listing Date",
+ "listings.sortByPrice": "Price",
+ "listings.sortByProvider": "Provider",
+ "listings.sortPrefix": "Sort by",
+ "listings.sortAscending": "Ascending",
+ "listings.sortDescending": "Descending",
+ "listings.tooltipGridView": "Grid view",
+ "listings.tooltipTableView": "Table view",
+ "listings.empty": "No listings available yet...",
+ "listings.toastAddedToWatchlist": "Listing added to Watchlist",
+ "listings.toastRemovedFromWatchlist": "Listing removed from Watchlist",
+ "listings.toastWatchlistError": "Failed to operate Watchlist",
+ "listings.toastStatusCleared": "Status cleared",
+ "listings.toastStatusMarked": "Marked as {{status}}",
+ "listings.toastStatusUpdateError": "Failed to update status",
+ "listings.toastDeleted": "Listing successfully removed",
+ "listings.toastDeleteError": "Error deleting listing",
+ "listings.cardInactive": "Inactive",
+ "listings.tooltipAddToWatchlist": "Add to Watchlist",
+ "listings.tooltipRemoveFromWatchlist": "Remove from Watchlist",
+ "listings.tooltipOriginalListing": "Original Listing",
+ "listings.tooltipViewInFredy": "View in Fredy",
+ "listings.tooltipRemove": "Remove",
+
+ "listing.detail.back": "Back",
+ "listing.detail.defaultTitle": "Listing Detail",
+ "listing.detail.noAddress": "No address provided",
+ "listing.detail.watch": "Watch",
+ "listing.detail.watched": "Watched",
+ "listing.detail.openListing": "Open listing",
+ "listing.detail.delete": "Delete",
+ "listing.detail.noImageAlt": "No image available",
+ "listing.detail.notesTitle": "Notes",
+ "listing.detail.notesPlaceholder": "Your private notes about this listing…",
+ "listing.detail.storeNotes": "Store notes",
+ "listing.detail.detailsTitle": "Details",
+ "listing.detail.descriptionTitle": "Description",
+ "listing.detail.noDescription": "No description available.",
+ "listing.detail.distanceToHome": "Distance to home:",
+ "listing.detail.locationTitle": "Location",
+ "listing.detail.noGeoWarning": "This listing has no valid geocoordinates, so we cannot show it on the map.",
+ "listing.detail.fieldPrice": "Price",
+ "listing.detail.fieldPriceHelp": "The asking price of this listing, as reported by the provider.",
+ "listing.detail.fieldSize": "Size",
+ "listing.detail.fieldSizeHelp": "Living space of the listing in square meters.",
+ "listing.detail.fieldRooms": "Rooms",
+ "listing.detail.fieldRoomsHelp": "Number of rooms in the listing.",
+ "listing.detail.fieldJob": "Job",
+ "listing.detail.fieldJobHelp": "The Fredy job that found this listing.",
+ "listing.detail.fieldProvider": "Provider",
+ "listing.detail.fieldProviderHelp": "The real estate portal where this listing was scraped from.",
+ "listing.detail.fieldAdded": "Added",
+ "listing.detail.fieldAddedHelp": "When Fredy first added this listing to your database.",
+ "listing.detail.fieldStatus": "Status",
+ "listing.detail.statusApplied": "Applied",
+ "listing.detail.statusAccepted": "Accepted",
+ "listing.detail.statusRejected": "Rejected",
+ "listing.detail.statusSetAt": "(set {{date}})",
+ "listing.detail.fieldStatusHelp": "The status you marked for this listing and when you set it.",
+ "listing.detail.fieldRoomsValue": "{{count}} Rooms",
+ "listing.detail.mapPopupListingLocation": "Listing Location",
+ "listing.detail.mapPopupHomeAddress": "Home Address",
+ "listing.detail.toastDeleted": "Listing successfully removed",
+ "listing.detail.toastDeleteError": "Error deleting listing",
+ "listing.detail.toastWatchlistAdded": "Added to Watchlist",
+ "listing.detail.toastWatchlistRemoved": "Removed from Watchlist",
+ "listing.detail.toastWatchlistError": "Failed to operate Watchlist",
+ "listing.detail.toastNotesSaved": "Notes saved",
+ "listing.detail.toastNotesError": "Failed to save notes",
+ "listing.detail.toastLoadError": "Failed to load listing details",
+
+ "listing.deletion.title": "Delete Listings",
+ "listing.deletion.message": "How would you like to delete the selected listing(s)?",
+ "listing.deletion.confirm": "Confirm",
+ "listing.deletion.cancel": "Cancel",
+ "listing.deletion.softLabel": "Mark as deleted (Soft Delete)",
+ "listing.deletion.softDescription": "Listings are kept in the database but marked as hidden. They will not re-appear during the next scraping session.",
+ "listing.deletion.hardLabel": "Remove from database (Hard Delete)",
+ "listing.deletion.hardDescription": "Listings are completely removed from the database.",
+ "listing.deletion.hardConsequence": "Consequence: They might re-appear when scraping the next time because Fredy won't know they were previously found.",
+ "listing.deletion.rememberChoice": "Remember my choice and skip this dialog next time",
+
+ "listings.status.none": "None",
+ "listings.status.applied": "Applied",
+ "listings.status.rejected": "Rejected",
+ "listings.status.accepted": "Accepted",
+ "listings.status.statusLabel": "Status",
+ "listings.status.tooltip": "Track where you stand with this listing: Applied once you have reached out, Rejected if it did not work out, or Accepted if you got it.",
+
+ "map.title": "Map View",
+ "map.noHomeAddress": "No home address set. Configure it in user settings to use the distance filter.",
+ "map.onlyValidAddresses": "Only listings with valid addresses are shown on this map.",
+ "map.filterJobLabel": "Job",
+ "map.filterJobPlaceholder": "All jobs",
+ "map.filterDistanceLabel": "Distance",
+ "map.filterDistanceNone": "None",
+ "map.filterPriceLabel": "Price (€)",
+ "map.filterStyleLabel": "Style",
+ "map.filterStyleStandard": "Standard",
+ "map.filterStyleSatellite": "Satellite",
+ "map.filter3dBuildings": "3D Buildings",
+ "map.popupPrice": "Price:",
+ "map.popupAddress": "Address:",
+ "map.popupJob": "Job:",
+ "map.popupProvider": "Provider:",
+ "map.popupSize": "Size:",
+ "map.popupViewDetails": "View Details",
+ "map.popupRemove": "Remove",
+ "map.popupHomeAddress": "Home Address",
+ "map.noHomeAddressBefore": "No home address set. Configure it in ",
+ "map.noHomeAddressLink": "user settings",
+ "map.noHomeAddressAfter": " to use the distance filter.",
+ "map.toastDeleted": "Listing successfully removed",
+ "map.toastDeleteError": "Error deleting listing",
+
+ "users.title": "Users",
+ "users.newUser": "New User",
+ "users.tableColumnUser": "User",
+ "users.tableColumnLastLogin": "Last login",
+ "users.tableColumnJobs": "Jobs",
+ "users.tableColumnMcpToken": "MCP Token",
+ "users.tableAdminBadge": "ADMIN",
+ "users.emptyState": "No users found.",
+ "users.toastRemoved": "User successfully removed",
+ "users.removalModal.title": "Removing user",
+ "users.removalModal.message": "Removing this user will also remove all associated jobs.",
+
+ "users.mutation.editTitle": "Edit User",
+ "users.mutation.newTitle": "New User",
+ "users.mutation.back": "Back",
+ "users.mutation.save": "Save",
+ "users.mutation.cancel": "Cancel",
+ "users.mutation.saved": "User successfully saved...",
+ "users.mutation.sectionUsername": "Username",
+ "users.mutation.usernameHelp": "The username used to login to Fredy",
+ "users.mutation.usernamePlaceholder": "Username",
+ "users.mutation.sectionPassword": "Password",
+ "users.mutation.passwordHelp": "The password used to login to Fredy",
+ "users.mutation.passwordPlaceholder": "Password",
+ "users.mutation.sectionRetypePassword": "Retype password",
+ "users.mutation.retypePasswordHelp": "Retype the password to make sure they match",
+ "users.mutation.retypePasswordPlaceholder": "Retype password",
+ "users.mutation.sectionIsAdmin": "Is user an admin?",
+ "users.mutation.isAdminHelp": "Check this if the user is an administrator",
+
+ "settings.title": "Settings",
+ "settings.tabSystem": "System",
+ "settings.tabExecution": "Execution",
+ "settings.tabUserSettings": "User Settings",
+ "settings.tabBackup": "Backup & Restore",
+ "settings.save": "Save",
+ "settings.port": "Port",
+ "settings.portHelp": "The port on which Fredy is running.",
+ "settings.portPlaceholder": "Port",
+ "settings.baseUrl": "Base URL",
+ "settings.baseUrlHelp": "Public URL where Fredy is reachable (e.g. http://192.168.1.10:9998). Used for 'Open in Fredy' links in notifications.",
+ "settings.baseUrlPlaceholder": "Base-Url",
+ "settings.sqlitePath": "SQLite Database Path",
+ "settings.sqlitePathHelp": "The directory where Fredy stores its SQLite database files.",
+ "settings.sqlitePathWarning": "Changing this path may result in data loss. Restart Fredy immediately after saving.",
+ "settings.sqlitePathPlaceholder": "Database folder path",
+ "settings.analytics": "Analytics",
+ "settings.analyticsHelp": "Anonymous usage data to help improve Fredy - provider names, adapter names, OS, Node version, and architecture.",
+ "settings.analyticsEnable": "Enable analytics",
+ "settings.demoMode": "Demo Mode",
+ "settings.demoModeHelp": "In demo mode, Fredy will not search for real estates and all data resets to defaults at midnight.",
+ "settings.demoModeEnable": "Enable demo mode",
+ "settings.searchInterval": "Search Interval",
+ "settings.searchIntervalHelp": "Interval in minutes for running queries against configured services. Do not go below 5 minutes to avoid being detected as a bot.",
+ "settings.searchIntervalPlaceholder": "Interval in minutes",
+ "settings.searchIntervalSuffix": "minutes",
+ "settings.workingHours": "Working Hours",
+ "settings.workingHoursHelp": "Fredy will only search for listings during these hours. Leave empty to search around the clock.",
+ "settings.workingHoursFrom": "From",
+ "settings.workingHoursUntil": "Until",
+ "settings.proxyUrl": "Proxy URL",
+ "settings.proxyUrlHelp": "Optional. Routes the scraping browser through a proxy. Server/datacenter IPs are frequently blocked by providers (e.g. immowelt) regardless of browser fingerprint, a German residential proxy makes requests look like a normal household and is the most effective fix. Format: http://user:pass@host:port or socks5://user:pass@host:port. Leave empty to disable.",
+ "settings.proxyUrlPlaceholder": "http://user:pass@host:port",
+ "settings.homeAddress": "Home Address",
+ "settings.homeAddressHelp": "Used to calculate distances between your location and each listing. Updating this recalculates distances for all active listings.",
+ "settings.homeAddressPlaceholder": "Enter your home address",
+ "settings.homeAddressGeoError": "Address found but could not be geocoded accurately.",
+ "settings.providerDetails": "Provider Details",
+ "settings.providerDetailsHelp": "Fetch additional details (description, attributes, agent info) for listings. Needs an extra API call per listing.",
+ "settings.providerDetailsWarning": "Enabling this significantly increases API requests to providers that have implemented this feature, raising the chance of rate limiting or blocking. Use at your own risk.",
+ "settings.providerDetailsPlaceholder": "Select providers to fetch details from...",
+ "settings.providerDetailsUpdated": "Provider details setting updated.",
+ "settings.providerDetailsUpdateError": "Failed to update setting.",
+ "settings.listingDeletion": "Listing deletion",
+ "settings.listingDeletionHelp": "Choose the default deletion mode. Soft delete hides them without re-scraping; hard delete removes them from the database.",
+ "settings.listingDeletionSoftLabel": "Mark as deleted (Soft Delete)",
+ "settings.listingDeletionSoftDesc": "Listings are kept in the database but marked as hidden. They will not re-appear during the next scraping session.",
+ "settings.listingDeletionHardLabel": "Remove from database (Hard Delete)",
+ "settings.listingDeletionHardDesc": "Listings are completely removed from the database.",
+ "settings.listingDeletionHardConsequence": "Consequence: They might re-appear when scraping the next time because Fredy won't know they were previously found.",
+ "settings.listingDeletionSkipPrompt": "Skip confirmation dialog",
+ "settings.userSettingsSaved": "Settings saved. Distance calculations are running in the background.",
+ "settings.userSettingsSaveError": "Error while saving settings",
+ "settings.backupDownload": "Download Backup",
+ "settings.backupRestoreFromZip": "Restore from Zip",
+ "settings.backupHelp": "Download a zipped backup of your database or restore from a backup zip.",
+ "settings.backupSectionName": "Backup & Restore",
+ "settings.backupDemoWarning": "Backup and restore are not available in demo mode.",
+ "settings.backupDownloadError": "Unexpected error while downloading backup.",
+ "settings.backupAnalyzeError": "Failed to analyze backup.",
+ "settings.backupRestoreCompleted": "Restore completed. Please restart the Fredy backend now!",
+ "settings.backupRestoreError": "Unexpected error while restoring backup.",
+ "settings.restoreModalTitle": "Restore database",
+ "settings.restoreNow": "Restore now",
+ "settings.restoreAnyway": "Restore anyway",
+ "settings.restoreProblemDetected": "Problem detected",
+ "settings.restoreMigrationsApplied": "Automatic migrations will be applied",
+ "settings.restoreCompatible": "Backup is compatible",
+ "settings.restoreMigrationInfo": "Backup migration: {{backupMigration}} | Required migration: {{requiredMigration}}",
+ "settings.toastIntervalEmpty": "Interval may not be empty.",
+ "settings.toastPortEmpty": "Port may not be empty.",
+ "settings.toastWorkingHoursIncomplete": "Working hours to and from must be set if either to or from has been set before.",
+ "settings.toastSqlitePathEmpty": "SQLite db path cannot be empty.",
+ "settings.toastSavedReloading": "Settings stored successfully. We will reload your browser in 3 seconds.",
+ "settings.toastSaveError": "Error while trying to store settings.",
+
+ "watchlist.sectionName": "Notification for Watch List",
+ "watchlist.sectionHelp": "You can get notified for changes on listings from your watch list.",
+ "watchlist.noteTitle": "Note",
+ "watchlist.noteDescription": "You'll receive notifications only for listings that are on your watch list. To add listings to it, open the 'Listings' section and tag the ones you want to follow.",
+ "watchlist.notifyMeWhen": "Notify me when:",
+ "watchlist.activityChanges": "Listing state changes (e.g. listing becomes inactive)",
+ "watchlist.priceChanges": "Listing price changes",
+ "watchlist.notifyMeWith": "Notify me with:",
+ "watchlist.selectNotificationMethod": "Select notification method",
+ "watchlist.addNotificationTitle": "Add notification method",
+ "watchlist.addNotificationDescription": "When something has changed, Fredy will notify you using the selected notification adapter. Note, some adapter like SqLite are not available here.",
+
+ "notification.defaultTitle": "Adding a new Notification Adapter",
+ "notification.description": "When Fredy finds new listings, we like to report them to you. To do so, notification adapter can be configured. There are multiple ways how Fredy can send new listings to you. Chose your weapon...",
+ "notification.selectPlaceholder": "Select a notification adapter",
+ "notification.try": "Try",
+ "notification.cancel": "Cancel",
+ "notification.save": "Save",
+ "notification.trySuccess": "It seems like it worked! Please check your service.",
+ "notification.tryError": "This did not work :-( I've received the following error: {{error}}",
+ "notification.errorTitle": "Error",
+ "notification.successTitle": "Yay!",
+ "notification.validationAllMandatory": "All fields are mandatory and must be set.",
+ "notification.validationNumberField": "A number field cannot contain anything else and must be > 0.",
+ "notification.validationBooleanField": "A boolean field cannot be of a different type.",
+ "notification.infoTitle": "Information",
+ "notification.tableEmptyState": "No notification adapters found.",
+ "notification.tableColumnName": "Name",
+
+ "provider.defaultTitle": "Adding a new Provider",
+ "provider.editTitle": "Editing an existing Provider",
+ "provider.save": "Save",
+ "provider.description": "Provider are the heart of Fredy. We're supporting multiple Provider such as Immowelt, Immoscout etc. Select a provider from the list below. Fredy will then open the provider's url in a new tab.",
+ "provider.descriptionStep2": "You will need to configure your search parameter like you would do when you do a regular search on the provider's website. When the search results are shown on the website, copy the url and paste it into the textfield below.",
+ "provider.editDescription": "You can now edit the {{name}} provider's URL in the input field below.",
+ "provider.selectPlaceholder": "Select a provider",
+ "provider.urlPlaceholder": "Provider Url",
+ "provider.validationSelectAndUrl": "Please select a provider and copy the browser url into the textfield after configuring your search parameter.",
+ "provider.validationInvalidUrl": "The url you have copied is not valid.",
+ "provider.errorTitle": "Error",
+ "provider.tableEmptyState": "No providers found.",
+ "provider.tableColumnName": "Name",
+ "provider.tableColumnUrl": "URL",
+ "provider.tableOpenProvider": "Open Provider",
+
+ "news.videoFallback": "Your browser does not support the video tag.",
+
+ "version.newVersionAvailable": "New version available",
+ "version.currentLabel": "Current: {{version}}",
+ "version.releaseNotes": "Release notes",
+ "version.newBadge": "New",
+ "version.modalClose": "Close",
+ "version.viewOnGithub": "View on GitHub",
+ "version.yourVersion": "Your Version",
+ "version.latestVersion": "Latest Version",
+
+ "tracking.okText": "Yes! I want to help",
+ "tracking.cancelText": "No, thanks",
+ "tracking.greeting": "Hey 👋",
+ "tracking.paragraph1": "Fed up with popups? Yeah, me too. But this one's important, and I promise it will only appear once ;)",
+ "tracking.paragraph2": "Fredy is completely free (and will always remain free). If you'd like, you can support me by donating through my GitHub, but there's absolutely no obligation to do so.",
+ "tracking.paragraph3": "However, it would be a huge help if you'd allow me to collect some analytical data. Wait, before you click \"no\", let me explain. If you agree, Fredy will send a ping once every 6 hours to my internal tracking project. (Will be open-sourced soon)",
+ "tracking.paragraph4": "The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The information is entirely anonymous and helps me understand which adapters/providers are most frequently used.",
+ "tracking.thanks": "Thanks🤘",
+
+ "permission.title": "Insufficient permission :(",
+
+ "footer.madeWith": "Made with ❤️ by",
+
+ "dashboard.noData": "No Data",
+
+ "common.save": "Save",
+ "common.cancel": "Cancel",
+ "common.delete": "Delete",
+ "common.edit": "Edit",
+ "common.back": "Back",
+ "common.confirm": "Confirm",
+ "common.yes": "Yes",
+ "common.no": "No",
+ "common.na": "N/A",
+ "common.loading": "Loading...",
+ "common.ariaGridView": "Grid view",
+ "common.ariaTableView": "Table view",
+ "common.startNow": "Start now",
+ "settings.language": "Language",
+ "settings.languageHelp": "The language used throughout the interface.",
+ "settings.languageSaveError": "Failed to save language preference."
+}
diff --git a/ui/src/services/i18n/i18n.jsx b/ui/src/services/i18n/i18n.jsx
new file mode 100644
index 0000000..fea0be2
--- /dev/null
+++ b/ui/src/services/i18n/i18n.jsx
@@ -0,0 +1,136 @@
+/*
+ * Copyright (c) 2026 by Christian Kellner.
+ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
+ */
+
+import { createContext, useContext, useMemo } from 'react';
+
+// Auto-discover all locale JSON files at build time
+const localeModules = import.meta.glob('../../locales/*.json', { eager: true });
+
+/**
+ * Build resources object: { en: {...translations}, de: {...translations}, ... }
+ * Strips _meta from each locale file.
+ * @type {Record
>}
+ */
+const resources = {};
+
+/**
+ * Build availableLanguages array: [{ code, flag, name, locale }, ...]
+ * Uses _meta from each locale file with fallbacks.
+ * @type {Array<{code: string, flag: string, name: string, locale: string}>}
+ */
+const availableLanguages = [];
+
+/** Maps language code to BCP 47 locale string (e.g. 'de' → 'de-DE') */
+const localeMap = {};
+
+for (const [path, module] of Object.entries(localeModules)) {
+ // Extract locale code from path: '../../locales/en.json' -> 'en'
+ const match = path.match(/\/(\w+)\.json$/);
+ if (!match) continue;
+
+ const code = match[1];
+ const localeData = module.default || module;
+
+ // Extract _meta and build resources
+ const { _meta, ...translations } = localeData;
+ resources[code] = translations;
+
+ // Build availableLanguages entry
+ const flag = _meta?.flag || '';
+ const name = _meta?.name || code;
+ const locale = _meta?.locale || code;
+ const semiLocale = _meta?.semiLocale || null;
+ localeMap[code] = locale;
+ availableLanguages.push({ code, flag, name, locale, semiLocale });
+}
+
+if (availableLanguages.length === 0) {
+ console.warn('i18n: No locale files found in locales/');
+}
+if (!resources.en) {
+ console.error('i18n: English locale (en.json) is required as the fallback language');
+}
+
+/**
+ * Translation context
+ * @type {React.Context<{t: (key: string, vars?: Record) => string, locale: string}>}
+ */
+const TranslationContext = createContext(null);
+
+/**
+ * I18nProvider component
+ * Accepts a language prop and provides a t() function via context.
+ * Falls back to English, then to key itself if translation missing.
+ * Supports {{varName}} interpolation.
+ *
+ * @param {Object} props
+ * @param {string} props.language - Active language code (e.g., 'en', 'de')
+ * @param {React.ReactNode} props.children - Child components
+ * @returns {React.ReactNode}
+ */
+export function I18nProvider({ language = 'en', children }) {
+ /**
+ * Translate a key with optional variable interpolation
+ * @param {string} key - Translation key (e.g., 'nav.dashboard')
+ * @param {Record} [vars] - Variables for {{varName}} interpolation
+ * @returns {string}
+ */
+ const t = (key, vars = {}) => {
+ // Try active language
+ let translation = resources[language]?.[key];
+
+ // Fallback to English
+ if (!translation) {
+ translation = resources.en?.[key];
+ }
+
+ // Fallback to key itself
+ if (!translation) {
+ translation = key;
+ }
+
+ // Interpolate variables: replace {{varName}} with values
+ if (vars && Object.keys(vars).length > 0) {
+ translation = translation.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
+ return vars[varName] !== undefined ? String(vars[varName]) : match;
+ });
+ }
+
+ return translation;
+ };
+
+ const locale = localeMap[language] ?? localeMap.en ?? 'en-US';
+ const value = useMemo(() => ({ t, locale }), [language]);
+
+ return {children} ;
+}
+
+/**
+ * Hook to access the translation function from context.
+ * @returns {(key: string, vars?: Record) => string}
+ */
+export function useTranslation() {
+ const context = useContext(TranslationContext);
+ if (!context) {
+ throw new Error('useTranslation must be used within an I18nProvider');
+ }
+ return context.t;
+}
+
+/**
+ * Hook to access the active BCP 47 locale string (e.g. 'de-DE', 'en-US').
+ * Use this with Intl APIs for locale-aware date/number formatting.
+ * @returns {string}
+ */
+export function useLocale() {
+ const context = useContext(TranslationContext);
+ if (!context) {
+ throw new Error('useLocale must be used within an I18nProvider');
+ }
+ return context.locale;
+}
+
+// Export resources and availableLanguages for other uses
+export { resources, availableLanguages };
diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js
index 795a370..e29982c 100644
--- a/ui/src/services/state/store.js
+++ b/ui/src/services/state/store.js
@@ -379,6 +379,20 @@ export const useFredyState = create(
throw Exception;
}
},
+ async setLanguage(language) {
+ try {
+ await xhrPost('/api/user/settings/language', { language });
+ set((state) => ({
+ userSettings: {
+ ...state.userSettings,
+ settings: { ...state.userSettings.settings, language },
+ },
+ }));
+ } catch (Exception) {
+ console.error('Error while trying to update language setting. Error:', Exception);
+ throw Exception;
+ }
+ },
},
};
diff --git a/ui/src/services/time/timeService.js b/ui/src/services/time/timeService.js
index 091ff07..e352f26 100644
--- a/ui/src/services/time/timeService.js
+++ b/ui/src/services/time/timeService.js
@@ -3,8 +3,8 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
-export function format(ts, showSeconds = true) {
- return new Intl.DateTimeFormat('default', {
+export function format(ts, showSeconds = true, locale = 'default') {
+ return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
diff --git a/ui/src/views/dashboard/Dashboard.jsx b/ui/src/views/dashboard/Dashboard.jsx
index 10df89e..a2d31e2 100644
--- a/ui/src/views/dashboard/Dashboard.jsx
+++ b/ui/src/views/dashboard/Dashboard.jsx
@@ -25,8 +25,11 @@ import Headline from '../../components/headline/Headline.jsx';
import './Dashboard.less';
import { xhrPost } from '../../services/xhr.js';
import { format } from '../../services/time/timeService.js';
+import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
export default function Dashboard() {
+ const t = useTranslation();
+ const locale = useLocale();
const actions = useActions();
const dashboard = useSelector((state) => state.dashboard.data);
React.useEffect(() => {
@@ -38,111 +41,111 @@ export default function Dashboard() {
return (
-
+
-
General
+
{t('dashboard.sectionGeneral')}
}
- description="Time interval for job execution"
+ description={t('dashboard.searchIntervalDesc')}
/>
}
- description="Last execution timestamp"
+ description={t('dashboard.lastSearchDesc')}
/>
}
- description="Next execution timestamp"
+ description={t('dashboard.nextSearchDesc')}
/>
- } description="Run a search now">
+ } description={t('dashboard.searchNowDesc')}>
}
- aria-label="Start now"
+ aria-label={t('common.startNow')}
onClick={async () => {
try {
await xhrPost('/api/jobs/startAll', null);
- Toast.success('Successfully triggered Fredy search.');
+ Toast.success(t('dashboard.searchNowStarted'));
} catch {
- Toast.error('Failed to trigger search');
+ Toast.error(t('dashboard.searchNowFailed'));
}
}}
>
- Search now
+ {t('dashboard.searchNowButton')}
-
Overview
+
{t('dashboard.sectionOverview')}
}
- description="Total number of jobs"
+ description={t('dashboard.kpiJobsDesc')}
/>
}
- description="Total listings found"
+ description={t('dashboard.kpiListingsDesc')}
/>
}
- description="Total active listings"
+ description={t('dashboard.kpiActiveListingsDesc')}
/>
}
- description="Median Price of listings"
+ description={t('dashboard.kpiMedianPriceDesc')}
/>
-
Provider Insights
+
{t('dashboard.sectionProviderInsights')}
diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx
index 9c7e451..87d80bd 100644
--- a/ui/src/views/generalSettings/GeneralSettings.jsx
+++ b/ui/src/views/generalSettings/GeneralSettings.jsx
@@ -6,6 +6,7 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useActions, useSelector, useIsLoading } from '../../services/state/store';
+import { useTranslation, availableLanguages } from '../../services/i18n/i18n.jsx';
import {
Tabs,
@@ -56,10 +57,12 @@ function formatFromTBackend(time) {
const GeneralSettings = function GeneralSettings() {
const actions = useActions();
+ const t = useTranslation();
const [loading, setLoading] = React.useState(true);
const settings = useSelector((state) => state.generalSettings.settings);
const currentUser = useSelector((state) => state.user.currentUser);
+ const language = useSelector((state) => state.userSettings.settings.language);
const [interval, setInterval] = React.useState('');
const [proxyUrl, setProxyUrl] = React.useState('');
@@ -86,6 +89,7 @@ const GeneralSettings = function GeneralSettings() {
const [listingDeleteHard, setListingDeleteHard] = useState(false);
const [listingDeleteSkipPrompt, setListingDeleteSkipPrompt] = useState(false);
const saving = useIsLoading(actions.userSettings.setHomeAddress);
+ const savingLanguage = useIsLoading(actions.userSettings.setLanguage);
const [dataSource, setDataSource] = useState([]);
React.useEffect(() => {
@@ -127,22 +131,22 @@ const GeneralSettings = function GeneralSettings() {
const handleStore = async () => {
if (nullOrEmpty(interval)) {
- Toast.error('Interval may not be empty.');
+ Toast.error(t('settings.toastIntervalEmpty'));
return;
}
if (nullOrEmpty(port)) {
- Toast.error('Port may not be empty.');
+ Toast.error(t('settings.toastPortEmpty'));
return;
}
if (
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
(nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo))
) {
- Toast.error('Working hours to and from must be set if either to or from has been set before.');
+ Toast.error(t('settings.toastWorkingHoursIncomplete'));
return;
}
if (nullOrEmpty(sqlitePath)) {
- Toast.error('SQLite db path cannot be empty.');
+ Toast.error(t('settings.toastSqlitePathEmpty'));
return;
}
try {
@@ -164,11 +168,11 @@ const GeneralSettings = function GeneralSettings() {
if (exception?.json?.message != null) {
Toast.error(exception.json.message);
} else {
- Toast.error('Error while trying to store settings.');
+ Toast.error(t('settings.toastSaveError'));
}
return;
}
- Toast.success('Settings stored successfully. We will reload your browser in 3 seconds.');
+ Toast.success(t('settings.toastSavedReloading'));
setTimeout(() => {
location.reload();
}, 3000);
@@ -179,7 +183,7 @@ const GeneralSettings = function GeneralSettings() {
await downloadBackupZip();
} catch (e) {
console.error(e);
- Toast.error('Unexpected error while downloading backup.');
+ Toast.error(t('settings.backupDownloadError'));
}
}, []);
@@ -190,7 +194,7 @@ const GeneralSettings = function GeneralSettings() {
setRestoreModalVisible(true);
} catch (e) {
console.error(e);
- Toast.error('Failed to analyze backup.');
+ Toast.error(t('settings.backupAnalyzeError'));
}
}, []);
@@ -199,10 +203,10 @@ const GeneralSettings = function GeneralSettings() {
try {
setRestoreBusy(true);
await clientRestore(selectedRestoreFile, force);
- Toast.success('Restore completed. Please restart the Fredy backend now!');
+ Toast.success(t('settings.backupRestoreCompleted'));
} catch (e) {
console.error(e);
- Toast.error(e?.message || 'Unexpected error while restoring backup.');
+ Toast.error(e?.message || t('settings.backupRestoreError'));
} finally {
setRestoreBusy(false);
}
@@ -236,9 +240,9 @@ const GeneralSettings = function GeneralSettings() {
hardDelete: listingDeleteHard,
});
await actions.userSettings.getUserSettings();
- Toast.success('Settings saved. Distance calculations are running in the background.');
+ Toast.success(t('settings.userSettingsSaved'));
} catch (error) {
- Toast.error(error.json?.error || 'Error while saving settings');
+ Toast.error(error.json?.error || t('settings.userSettingsSaveError'));
}
};
@@ -266,7 +270,7 @@ const GeneralSettings = function GeneralSettings() {
return (
-
+
{!loading && (
<>
@@ -274,17 +278,17 @@ const GeneralSettings = function GeneralSettings() {
tab={
- System
+ {t('settings.tabSystem')}
}
itemKey="system"
>
@@ -348,36 +345,30 @@ const GeneralSettings = function GeneralSettings() {
tab={
- Execution
+ {t('settings.tabExecution')}
}
itemKey="execution"
>
-
+
`${value}`.replace(/\D/g, '')}
onChange={(value) => setInterval(value)}
- suffix={'minutes'}
+ suffix={t('settings.searchIntervalSuffix')}
style={{ maxWidth: 200 }}
/>
-
+
{
@@ -386,7 +377,7 @@ const GeneralSettings = function GeneralSettings() {
/>
{
@@ -396,13 +387,10 @@ const GeneralSettings = function GeneralSettings() {
-
+
setProxyUrl(value)}
/>
@@ -410,7 +398,7 @@ const GeneralSettings = function GeneralSettings() {
}>
- Save
+ {t('settings.save')}
@@ -420,42 +408,55 @@ const GeneralSettings = function GeneralSettings() {
tab={
- User Settings
+ {t('settings.tabUserSettings')}
}
itemKey="userSettings"
>
-
+
+ ({
+ label: `${lang.flag} ${lang.name}`,
+ value: lang.code,
+ }))}
+ onChange={async (code) => {
+ try {
+ await actions.userSettings.setLanguage(code);
+ } catch {
+ Toast.error(t('settings.languageSaveError'));
+ }
+ }}
+ />
+
+
+
setAddress(v)}
onSearch={searchAddress}
- placeholder="Enter your home address"
+ placeholder={t('settings.homeAddressPlaceholder')}
style={{ width: '100%' }}
/>
{coords && coords.lat === -1 && (
)}
-
+
@@ -464,47 +465,38 @@ const GeneralSettings = function GeneralSettings() {
style={{ width: '100%' }}
value={Array.isArray(providerDetails) ? providerDetails : []}
optionList={(allProviders ?? []).map((p) => ({ label: p.name, value: p.id }))}
- placeholder="Select providers to fetch details from..."
+ placeholder={t('settings.providerDetailsPlaceholder')}
onChange={async (selected) => {
try {
await actions.userSettings.setProviderDetails(selected);
- Toast.success('Provider details setting updated.');
+ Toast.success(t('settings.providerDetailsUpdated'));
} catch {
- Toast.error('Failed to update setting.');
+ Toast.error(t('settings.providerDetailsUpdateError'));
}
}}
/>
-
+
setListingDeleteHard(e.target.value === 'hard')}
>
- Mark as deleted (Soft Delete)
+ {t('settings.listingDeletionSoftLabel')}
-
- Listings are kept in the database but marked as hidden. They will not re-appear during
- the next scraping session.
-
+ {t('settings.listingDeletionSoftDesc')}
- Remove from database (Hard Delete)
+ {t('settings.listingDeletionHardLabel')}
- Listings are completely removed from the database.
+ {t('settings.listingDeletionHardDesc')}
-
- Consequence: They might re-appear when scraping the next time because Fredy won't know they
- were previously found.
-
+ {t('settings.listingDeletionHardConsequence')}
@@ -514,7 +506,7 @@ const GeneralSettings = function GeneralSettings() {
onChange={(e) => setListingDeleteSkipPrompt(e.target.checked)}
style={{ marginTop: 12 }}
>
- Skip confirmation dialog
+ {t('settings.listingDeletionSkipPrompt')}
@@ -526,7 +518,7 @@ const GeneralSettings = function GeneralSettings() {
onClick={handleSaveUserSettings}
loading={saving}
>
- Save
+ {t('settings.save')}
@@ -536,7 +528,7 @@ const GeneralSettings = function GeneralSettings() {
tab={
- Backup & Restore
+ {t('settings.tabBackup')}
}
itemKey="backup"
@@ -548,13 +540,10 @@ const GeneralSettings = function GeneralSettings() {
type="warning"
closeIcon={null}
style={{ marginBottom: '12px' }}
- description="Backup and restore are not available in demo mode."
+ description={t('settings.backupDemoWarning')}
/>
)}
-
+
- Download Backup
+ {t('settings.backupDownload')}
}
disabled={demoMode && !currentUser?.isAdmin}
>
- Restore from Zip
+ {t('settings.backupRestoreFromZip')}
@@ -589,11 +578,11 @@ const GeneralSettings = function GeneralSettings() {
{restoreModalVisible && (
setRestoreModalVisible(false)}
onOk={() => performRestore(!precheckInfo?.compatible)}
- okText={precheckInfo?.compatible ? 'Restore now' : 'Restore anyway'}
+ okText={precheckInfo?.compatible ? t('settings.restoreNow') : t('settings.restoreAnyway')}
okType={precheckInfo?.compatible ? 'primary' : 'danger'}
confirmLoading={restoreBusy}
>
@@ -602,7 +591,7 @@ const GeneralSettings = function GeneralSettings() {
type="danger"
fullMode={false}
closeIcon={null}
- title={Problem detected
}
+ title={{t('settings.restoreProblemDetected')}
}
description={{precheckInfo?.message}
}
/>
)}
@@ -611,7 +600,7 @@ const GeneralSettings = function GeneralSettings() {
type="warning"
fullMode={false}
closeIcon={null}
- title={Automatic migrations will be applied
}
+ title={{t('settings.restoreMigrationsApplied')}
}
description={{precheckInfo?.message}
}
/>
)}
@@ -620,13 +609,15 @@ const GeneralSettings = function GeneralSettings() {
type="success"
fullMode={false}
closeIcon={null}
- title={Backup is compatible
}
+ title={{t('settings.restoreCompatible')}
}
description={{precheckInfo?.message}
}
/>
)}
- Backup migration: {precheckInfo?.backupMigration ?? 'unknown'} | Required migration:{' '}
- {precheckInfo?.requiredMigration ?? 'unknown'}
+ {t('settings.restoreMigrationInfo', {
+ backupMigration: precheckInfo?.backupMigration ?? 'unknown',
+ requiredMigration: precheckInfo?.requiredMigration ?? 'unknown',
+ })}
)}
diff --git a/ui/src/views/jobs/Jobs.jsx b/ui/src/views/jobs/Jobs.jsx
index e1601bd..4b4792e 100644
--- a/ui/src/views/jobs/Jobs.jsx
+++ b/ui/src/views/jobs/Jobs.jsx
@@ -9,16 +9,18 @@ import { IconPlusCircle } from '@douyinfe/semi-icons';
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
import Headline from '../../components/headline/Headline.jsx';
import './Jobs.less';
+import { useTranslation } from '../../services/i18n/i18n.jsx';
export default function Jobs() {
+ const t = useTranslation();
const navigate = useNavigate();
return (
} onClick={() => navigate('/jobs/new')}>
- New Job
+ {t('jobs.newJob')}
}
/>
diff --git a/ui/src/views/jobs/mutation/JobMutation.jsx b/ui/src/views/jobs/mutation/JobMutation.jsx
index f3869cc..f7f9ad6 100644
--- a/ui/src/views/jobs/mutation/JobMutation.jsx
+++ b/ui/src/views/jobs/mutation/JobMutation.jsx
@@ -27,14 +27,17 @@ import {
IconUser,
IconFilter,
} from '@douyinfe/semi-icons';
-
-const SPEC_FILTERS = [
- { key: 'maxPrice', translation: 'Max Price' },
- { key: 'minSize', translation: 'Min Size (m²)' },
- { key: 'minRooms', translation: 'Min Rooms' },
-];
+import { useTranslation } from '../../../services/i18n/i18n.jsx';
export default function JobMutator() {
+ const t = useTranslation();
+
+ const SPEC_FILTERS = [
+ { key: 'maxPrice', translation: t('jobs.mutation.filterMaxPrice') },
+ { key: 'minSize', translation: t('jobs.mutation.filterMinSize') },
+ { key: 'minRooms', translation: t('jobs.mutation.filterMinRooms') },
+ ];
+
const jobs = useSelector((state) => state.jobsData.jobs);
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
const params = useParams();
@@ -105,7 +108,7 @@ export default function JobMutator() {
jobId: jobToBeEdit?.id || null,
});
await actions.jobsData.getJobs();
- Toast.success('Job successfully saved...');
+ Toast.success(t('jobs.mutation.saved'));
navigate('/jobs');
} catch (Exception) {
console.error(Exception.json.message);
@@ -146,7 +149,7 @@ export default function JobMutator() {
)}
}
@@ -154,17 +157,17 @@ export default function JobMutator() {
theme="borderless"
style={{ color: '#909090' }}
>
- Back
+ {t('jobs.mutation.back')}
}
/>
}
+ title={
+ {t('provider.errorTitle')}
+ }
style={{ marginBottom: '1rem' }}
description={validationMessage}
/>
)}
{providerToEdit != null ? (
-
- You can now edit the {providerToEdit.name} provider's URL in the input field below.
-
+ {t('provider.editDescription', { name: providerToEdit.name })}
) : (
<>
-
- Provider are the of Fredy. We're supporting multiple Provider
- such as Immowelt, Kalaydo etc. Select a provider from the list below.
-
- Fredy will then open the provider's url in a new tab.
-
-
- You will need to configure your search parameter like you would do when you do a regular search on the
- provider's website.
-
- When the search results are shown on the website, copy the url and paste it into the textfield below.
-
+ {t('provider.description')}
+ {t('provider.descriptionStep2')}
>
)}
Listing Location${listing.address}
`))
+ .setPopup(
+ new maplibregl.Popup({ offset: 25 }).setHTML(
+ `${t('listing.detail.mapPopupListingLocation')} ${listing.address}
`,
+ ),
+ )
.addTo(map.current);
if (homeAddress?.coords) {
new maplibregl.Marker({ color: 'red' })
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
- .setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`Home Address ${homeAddress.address}
`))
+ .setPopup(
+ new maplibregl.Popup({ offset: 25 }).setHTML(
+ `${t('listing.detail.mapPopupHomeAddress')} ${homeAddress.address}
`,
+ ),
+ )
.addTo(map.current);
const bounds = getBoundsFromCoords([
@@ -261,10 +272,10 @@ export default function ListingDetail() {
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
}
await xhrDelete('/api/listings/', { ids: [listing.id], hardDelete });
- Toast.success('Listing successfully removed');
+ Toast.success(t('listing.detail.toastDeleted'));
navigate('/listings');
} catch (e) {
- Toast.error(e.message || 'Error deleting listing');
+ Toast.error(e.message || t('listing.detail.toastDeleteError'));
} finally {
setDeleteModalVisible(false);
}
@@ -273,11 +284,13 @@ export default function ListingDetail() {
const handleWatch = async () => {
try {
await xhrPost('/api/listings/watch', { listingId: listing.id });
- Toast.success(listing.isWatched === 1 ? 'Removed from Watchlist' : 'Added to Watchlist');
+ Toast.success(
+ listing.isWatched === 1 ? t('listing.detail.toastWatchlistRemoved') : t('listing.detail.toastWatchlistAdded'),
+ );
actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to operate Watchlist:', e);
- Toast.error('Failed to operate Watchlist');
+ Toast.error(t('listing.detail.toastWatchlistError'));
}
};
@@ -285,10 +298,10 @@ export default function ListingDetail() {
try {
await actions.listingsData.setListingStatus(listing.id, next);
await actions.listingsData.getListing(listingId);
- Toast.success(next ? `Marked as ${next}` : 'Status cleared');
+ Toast.success(next ? t('listings.toastStatusMarked', { status: next }) : t('listings.toastStatusCleared'));
} catch (e) {
console.error('Failed to update status:', e);
- Toast.error('Failed to update status');
+ Toast.error(t('listings.toastStatusUpdateError'));
}
};
@@ -298,10 +311,10 @@ export default function ListingDetail() {
try {
await actions.listingsData.setListingNotes(listing.id, notesDraft);
await actions.listingsData.getListing(listingId);
- Toast.success('Notes saved');
+ Toast.success(t('listing.detail.toastNotesSaved'));
} catch (e) {
console.error('Failed to save notes:', e);
- Toast.error('Failed to save notes');
+ Toast.error(t('listing.detail.toastNotesError'));
} finally {
setNotesSaving(false);
}
@@ -317,69 +330,74 @@ export default function ListingDetail() {
if (!listing) return null;
- const statusLabel = listing.status?.status
- ? listing.status.status.charAt(0).toUpperCase() + listing.status.status.slice(1)
- : null;
+ const statusKeyMap = {
+ applied: 'listing.detail.statusApplied',
+ accepted: 'listing.detail.statusAccepted',
+ rejected: 'listing.detail.statusRejected',
+ };
+ const statusLabel = listing.status?.status ? t(statusKeyMap[listing.status.status] ?? listing.status.status) : null;
const data = [
{
- key: 'Price',
+ key: t('listing.detail.fieldPrice'),
value: listing.price ? (
{formatEuroPrice(listing.price)}
) : (
- 'N/A'
+ t('common.na')
),
Icon: ,
- helpText: 'The asking price of this listing, as reported by the provider.',
+ helpText: t('listing.detail.fieldPriceHelp'),
},
{
- key: 'Size',
- value: listing.size ? `${listing.size} m²` : 'N/A',
+ key: t('listing.detail.fieldSize'),
+ value: listing.size ? `${listing.size} m²` : t('common.na'),
Icon: ,
- helpText: 'Living space of the listing in square meters.',
+ helpText: t('listing.detail.fieldSizeHelp'),
},
{
- key: 'Rooms',
- value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
+ key: t('listing.detail.fieldRooms'),
+ value: listing.rooms ? t('listing.detail.fieldRoomsValue', { count: listing.rooms }) : t('common.na'),
Icon: ,
- helpText: 'Number of rooms in the listing.',
+ helpText: t('listing.detail.fieldRoomsHelp'),
},
{
- key: 'Job',
+ key: t('listing.detail.fieldJob'),
value: listing.job_name,
Icon: ,
- helpText: 'The Fredy job that found this listing.',
+ helpText: t('listing.detail.fieldJobHelp'),
},
{
- key: 'Provider',
+ key: t('listing.detail.fieldProvider'),
value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
Icon: ,
- helpText: 'The real estate portal where this listing was scraped from.',
+ helpText: t('listing.detail.fieldProviderHelp'),
},
{
- key: 'Added',
- value: timeService.format(listing.created_at),
+ key: t('listing.detail.fieldAdded'),
+ value: timeService.format(listing.created_at, true, locale),
Icon: ,
- helpText: 'When Fredy first added this listing to your database.',
+ helpText: t('listing.detail.fieldAddedHelp'),
},
];
if (statusLabel) {
data.push({
- key: 'Status',
- value: listing.status?.setAt ? `${statusLabel} (set ${timeService.format(listing.status.setAt)})` : statusLabel,
+ key: t('listing.detail.fieldStatus'),
+ value: listing.status?.setAt
+ ? `${statusLabel} ${t('listing.detail.statusSetAt', { date: timeService.format(listing.status.setAt, true, locale) })}`
+ : statusLabel,
Icon: ,
- helpText: 'The status you marked for this listing and when you set it.',
+ helpText: t('listing.detail.fieldStatusHelp'),
});
}
return (
} onClick={() => navigate(-1)} theme="borderless" style={{ color: '#909090' }}>
- Back
+ {t('listing.detail.back')}
}
/>
@@ -398,7 +416,7 @@ export default function ListingDetail() {
{listing.address}
) : (
-
No address provided
+
{t('listing.detail.noAddress')}
)}
@@ -408,12 +426,12 @@ export default function ListingDetail() {
theme="borderless"
className={`listing-detail__watch-btn${listing.isWatched === 1 ? ' listing-detail__watch-btn--active' : ''}`}
>
- {listing.isWatched === 1 ? 'Watched' : 'Watch'}
+ {listing.isWatched === 1 ? t('listing.detail.watched') : t('listing.detail.watch')}
- Open listing
+ {t('listing.detail.openListing')}
}
@@ -427,7 +445,7 @@ export default function ListingDetail() {
theme="light"
type="danger"
>
- Delete
+ {t('listing.detail.delete')}
@@ -439,7 +457,7 @@ export default function ListingDetail() {
>
}
+ fallback={ }
style={{ width: '100%', height: '100%' }}
preview={!!listing.image_url}
/>
@@ -447,12 +465,12 @@ export default function ListingDetail() {
- Notes
+ {t('listing.detail.notesTitle')}
@@ -474,7 +492,7 @@ export default function ListingDetail() {
- Details
+ {t('listing.detail.detailsTitle')}
{data.map((item, index) => (
@@ -490,10 +508,10 @@ export default function ListingDetail() {
- Description
+ {t('listing.detail.descriptionTitle')}
- {listing.description || 'No description available.'}
+ {listing.description || t('listing.detail.noDescription')}
{listing.distance_to_destination && (
@@ -501,7 +519,7 @@ export default function ListingDetail() {
- Distance to home:
+ {t('listing.detail.distanceToHome')}
{listing.distance_to_destination} m
>
@@ -512,12 +530,12 @@ export default function ListingDetail() {
-
Location
+
{t('listing.detail.locationTitle')}
{!hasGeo ? (
) : (
diff --git a/ui/src/views/listings/Listings.jsx b/ui/src/views/listings/Listings.jsx
index fa835d8..8d08d13 100644
--- a/ui/src/views/listings/Listings.jsx
+++ b/ui/src/views/listings/Listings.jsx
@@ -5,12 +5,14 @@
import ListingsOverview from '../../components/listings/ListingsOverview.jsx';
import Headline from '../../components/headline/Headline.jsx';
+import { useTranslation } from '../../services/i18n/i18n.jsx';
/**
* @param {{ mode?: 'all' | 'watchlist' }} props
*/
export default function Listings({ mode = 'all' }) {
- const title = mode === 'watchlist' ? 'Watchlist' : 'Listings';
+ const t = useTranslation();
+ const title = mode === 'watchlist' ? t('listings.watchlistTitle') : t('listings.title');
return (
<>
diff --git a/ui/src/views/listings/Map.jsx b/ui/src/views/listings/Map.jsx
index a7c81f5..8e5f867 100644
--- a/ui/src/views/listings/Map.jsx
+++ b/ui/src/views/listings/Map.jsx
@@ -22,12 +22,14 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Map from '../../components/map/Map.jsx';
import Headline from '../../components/headline/Headline.jsx';
+import { useTranslation } from '../../services/i18n/i18n.jsx';
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
const { Text } = Typography;
export default function MapView() {
+ const t = useTranslation();
const mapContainer = useRef(null);
const map = useRef(null);
const markers = useRef([]);
@@ -63,10 +65,10 @@ export default function MapView() {
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
}
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
- Toast.success('Listing successfully removed');
+ Toast.success(t('map.toastDeleted'));
fetchListings();
} catch (error) {
- Toast.error(error.message || 'Error deleting listing');
+ Toast.error(error.message || t('map.toastDeleteError'));
} finally {
setDeleteModalVisible(false);
setListingToDelete(null);
@@ -236,7 +238,7 @@ export default function MapView() {
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
.setPopup(
new maplibregl.Popup({ offset: 25 }).setHTML(
- ``,
+ ``,
),
)
.addTo(map.current);
@@ -313,11 +315,11 @@ export default function MapView() {
/>
${listing.title}
-
Price: ${listing.price ? listing.price + ' €' : 'N/A'}
-
Address: ${listing.address || 'N/A'}
-
Job: ${listing.job_name || 'N/A'}
-
Provider: ${capitalizedProvider}
-
Size: ${listing.size != null ? `${listing.size} m²` : 'N/A'}
+
${t('map.popupPrice')} ${listing.price ? listing.price + ' €' : t('common.na')}
+
${t('map.popupAddress')} ${listing.address || t('common.na')}
+
${t('map.popupJob')} ${listing.job_name || t('common.na')}
+
${t('map.popupProvider')} ${capitalizedProvider}
+
${t('map.popupSize')} ${listing.size != null ? `${listing.size} m²` : t('common.na')}