diff --git a/README.md b/README.md index 5332255..a027373 100755 --- a/README.md +++ b/README.md @@ -240,6 +240,37 @@ If you have to refresh the fixtures (every once in a while needed because the pr yarn run download-fixtures ``` +## Adding a new language + +Fredy's UI is fully multilingual. Translation files live in `ui/src/locales/`. To add a new language, create a single JSON file there, no code changes required. + +**Example: `ui/src/locales/fr.json`** +```json +{ + "_meta": { + "flag": "🇫🇷", + "name": "Français", + "locale": "fr-FR", + "semiLocale": "fr" + }, + "nav.dashboard": "Tableau de bord", + "common.save": "Enregistrer", + ... +} +``` + +The `_meta` fields: + +| Field | Description | +|---|---| +| `flag` | Unicode flag emoji shown in the language selector | +| `name` | Display name shown in the language selector | +| `locale` | BCP 47 locale string used for date and number formatting (e.g. `fr-FR`) | +| `semiLocale` | Semi UI locale key for component-level strings (date pickers, pagination, etc.) | + +> **Important:** `semiLocale` must exactly match a locale filename from the Semi UI locale sources (without the `.js` extension). See the [available Semi UI locales on GitHub](https://github.com/DouyinFE/semi-design/tree/main/packages/semi-ui/locale/source) for the full list of supported keys. + +After adding the file, rebuild the frontend (`yarn build:frontend` or restart the dev server) and the new language will appear automatically in **Settings → User Settings → Language**. ------------------------------------------------------------------------ diff --git a/lib/api/routes/userSettingsRoute.js b/lib/api/routes/userSettingsRoute.js index c97febd..84838c7 100644 --- a/lib/api/routes/userSettingsRoute.js +++ b/lib/api/routes/userSettingsRoute.js @@ -168,4 +168,21 @@ export default async function userSettingsPlugin(fastify) { return reply.code(500).send({ error: error.message }); } }); + + fastify.post('/language', async (request, reply) => { + const userId = request.session.currentUser; + const { language } = request.body; + + if (typeof language !== 'string' || language.trim() === '') { + return reply.code(400).send({ error: 'language must be a non-empty string.' }); + } + + try { + upsertSettings({ language }, userId); + return { success: true }; + } catch (error) { + logger.error('Error updating language setting', error); + return reply.code(500).send({ error: error.message }); + } + }); } diff --git a/package.json b/package.json index a2d5ba9..7b6512d 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "22.3.3", + "version": "22.4.0", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 69d776f..77dcf50 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -18,7 +18,7 @@ import Jobs from './views/jobs/Jobs'; import './App.less'; import TrackingModal from './components/tracking/TrackingModal.jsx'; -import { Banner } from '@douyinfe/semi-ui-19'; +import { Banner, LocaleProvider } from '@douyinfe/semi-ui-19'; import VersionBanner from './components/version/VersionBanner.jsx'; import Listings from './views/listings/Listings.jsx'; import MapView from './views/listings/Map.jsx'; @@ -29,6 +29,17 @@ import WatchlistManagement from './views/listings/management/WatchlistManagement import Dashboard from './views/dashboard/Dashboard.jsx'; import ListingDetail from './views/listings/ListingDetail.jsx'; import NewsModal from './components/news/NewsModal.jsx'; +import { I18nProvider, availableLanguages } from './services/i18n/i18n.jsx'; + +const semiLocaleModules = import.meta.glob('/node_modules/@douyinfe/semi-ui-19/lib/es/locale/source/*.js', { + eager: true, +}); + +const semiLocales = {}; +for (const [path, mod] of Object.entries(semiLocaleModules)) { + const name = path.match(/\/source\/(\w+)\.js$/)?.[1]; + if (name) semiLocales[name] = mod.default ?? mod; +} export default function FredyApp() { const actions = useActions(); @@ -36,6 +47,7 @@ export default function FredyApp() { const currentUser = useSelector((state) => state.user.currentUser); const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate); const settings = useSelector((state) => state.generalSettings.settings); + const language = useSelector((state) => state.userSettings.settings.language); useEffect(() => { async function init() { @@ -63,79 +75,89 @@ export default function FredyApp() { const isAdmin = () => currentUser != null && currentUser.isAdmin; const { Sider, Content } = Layout; - return loading ? null : needsLogin() ? ( - - } /> - } /> - - ) : ( - - - - - - - {versionUpdate?.newVersion && } - {settings.demoMode && ( - <> - -
- - )} - {settings.analyticsEnabled === null && !settings.demoMode && } - {!settings.demoMode && } + return loading ? null : ( + + l.code === (language ?? 'en'))?.semiLocale] ?? semiLocales['en_US'] + } + > + {needsLogin() ? ( - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - {/* Permission-aware routes */} - - - - } - /> - - - - } - /> - - - - } - /> - } /> - } /> - - } /> + } /> + } /> -
- -
-
+ ) : ( + + + + + + + {versionUpdate?.newVersion && } + {settings.demoMode && ( + <> + +
+ + )} + {settings.analyticsEnabled === null && !settings.demoMode && } + {!settings.demoMode && } + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Permission-aware routes */} + + + + } + /> + + + + } + /> + + + + } + /> + } /> + } /> + + } /> + +
+ +
+
+ )} + + ); } diff --git a/ui/src/components/ListingDeletionModal.jsx b/ui/src/components/ListingDeletionModal.jsx index 2b878f6..3377e0f 100644 --- a/ui/src/components/ListingDeletionModal.jsx +++ b/ui/src/components/ListingDeletionModal.jsx @@ -5,6 +5,7 @@ import { useState, useEffect } from 'react'; import { Modal, Radio, RadioGroup, Typography, Checkbox } from '@douyinfe/semi-ui-19'; +import { useTranslation } from '../services/i18n/i18n.jsx'; const { Text } = Typography; @@ -12,11 +13,14 @@ const ListingDeletionModal = ({ visible, onConfirm, onCancel, - title = 'Delete Listings', + title, showOptions = true, - message = 'How would you like to delete the selected listing(s)?', + message, defaultDeleteType = 'soft', }) => { + const t = useTranslation(); + const resolvedTitle = title ?? t('listing.deletion.title'); + const resolvedMessage = message ?? t('listing.deletion.message'); const [deleteType, setDeleteType] = useState('soft'); const [remember, setRemember] = useState(false); @@ -37,47 +41,41 @@ const ListingDeletionModal = ({ return (
- {message} + {resolvedMessage}
{showOptions && ( <> setDeleteType(e.target.value)} style={{ width: '100%' }}>
- Mark as deleted (Soft Delete) + {t('listing.deletion.softLabel')}
- - Listings are kept in the database but marked as hidden. They will not re-appear during the next - scraping session. - + {t('listing.deletion.softDescription')}
- Remove from database (Hard Delete) + {t('listing.deletion.hardLabel')}
- Listings are completely removed from the database. + {t('listing.deletion.hardDescription')}
- - Consequence: They might re-appear when scraping the next time because Fredy won't know they were - previously found. - + {t('listing.deletion.hardConsequence')}
setRemember(e.target.checked)} style={{ marginTop: 16 }}> - Remember my choice and skip this dialog next time + {t('listing.deletion.rememberChoice')} )} diff --git a/ui/src/components/cards/PieChartCard.jsx b/ui/src/components/cards/PieChartCard.jsx index 6c8c973..f598fb6 100644 --- a/ui/src/components/cards/PieChartCard.jsx +++ b/ui/src/components/cards/PieChartCard.jsx @@ -8,10 +8,12 @@ import { Pie } from 'react-chartjs-2'; import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title as ChartTitle } from 'chart.js'; import './ChartCard.less'; +import { useTranslation } from '../../services/i18n/i18n.jsx'; ChartJS.register(ArcElement, Tooltip, Legend, ChartTitle); export default function PieChartCard({ data = [] }) { + const t = useTranslation(); const { labels, values } = React.useMemo(() => { if (data && typeof data === 'object' && !Array.isArray(data)) { const lbls = Array.isArray(data.labels) ? data.labels : []; @@ -92,6 +94,12 @@ export default function PieChartCard({ data = [] }) { const isEmpty = !labels || labels.length === 0 || !values || values.length === 0; return ( - <>{isEmpty ?
No Data
: } + <> + {isEmpty ? ( +
{t('dashboard.noData')}
+ ) : ( + + )} + ); } diff --git a/ui/src/components/footer/FredyFooter.jsx b/ui/src/components/footer/FredyFooter.jsx index 712dfb5..0c2fec9 100644 --- a/ui/src/components/footer/FredyFooter.jsx +++ b/ui/src/components/footer/FredyFooter.jsx @@ -6,16 +6,18 @@ import './FredyFooter.less'; import { useSelector } from '../../services/state/store.js'; import { Layout } from '@douyinfe/semi-ui-19'; +import { useTranslation } from '../../services/i18n/i18n.jsx'; export default function FredyFooter() { + const t = useTranslation(); const { Footer } = Layout; const version = useSelector((state) => state.versionUpdate.versionUpdate); return (