feat: support i18n (#345)
* add i18next * language selector * add chinese translation to general settings * fix: type error Signed-off-by: Innei <i@innei.in> * fix: selection style Signed-off-by: Innei <i@innei.in> * refactor: init i18n and language map Signed-off-by: Innei <i@innei.in> * feat: intelligence Signed-off-by: Innei <i@innei.in> * refactor: i18n dx hmr Signed-off-by: Innei <i@innei.in> * chore: lockfile Signed-off-by: Innei <i@innei.in> * chore: code style Signed-off-by: Innei <i@innei.in> * cleanup Signed-off-by: Innei <i@innei.in> --------- Signed-off-by: Innei <i@innei.in> Co-authored-by: Innei <i@innei.in>
This commit is contained in:
parent
9125a49fcc
commit
53c1c6639f
|
|
@ -6,15 +6,12 @@
|
|||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"],
|
||||
// ["tw\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
|
||||
["tw`([^`]*)`", "([^`]*)"]
|
||||
],
|
||||
|
||||
// If you do not want to autofix some rules on save
|
||||
// You can put this in your user settings or workspace settings
|
||||
"eslint.codeActionsOnSave.rules": [
|
||||
|
|
@ -25,20 +22,43 @@
|
|||
"!arrow-body-style",
|
||||
"*"
|
||||
],
|
||||
|
||||
// If you want to silent stylistic rules
|
||||
// You can put this in your user settings or workspace settings
|
||||
"eslint.rules.customizations": [
|
||||
{ "rule": "@stylistic/*", "severity": "off", "fixable": true },
|
||||
{ "rule": "tailwindcss/classnames-order", "severity": "off" },
|
||||
{ "rule": "antfu/consistent-list-newline", "severity": "off" },
|
||||
{ "rule": "hyoban/jsx-attribute-spacing", "severity": "off" },
|
||||
{ "rule": "simple-import-sort/*", "severity": "off" },
|
||||
{ "rule": "prefer-const", "severity": "off" },
|
||||
{ "rule": "unused-imports/no-unused-imports", "severity": "off" }
|
||||
{
|
||||
"rule": "@stylistic/*",
|
||||
"severity": "off",
|
||||
"fixable": true
|
||||
},
|
||||
{
|
||||
"rule": "tailwindcss/classnames-order",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "antfu/consistent-list-newline",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "hyoban/jsx-attribute-spacing",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "simple-import-sort/*",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "prefer-const",
|
||||
"severity": "off"
|
||||
},
|
||||
{
|
||||
"rule": "unused-imports/no-unused-imports",
|
||||
"severity": "off"
|
||||
}
|
||||
],
|
||||
"cSpell.words": ["rsshub"],
|
||||
"editor.foldingImportsByDefault": true,
|
||||
"commentTranslate.hover.enabled": false,
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.keystyle": "nested"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
{
|
||||
"words": {
|
||||
"search": "Search",
|
||||
"import": "Import",
|
||||
"discover": "Discover",
|
||||
"language": "Language"
|
||||
},
|
||||
"signin": {
|
||||
"continue_with_github": "Continue with GitHub",
|
||||
"continue_with_google": "Continue with Google",
|
||||
"sign_in_to": "Sign in to"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"app": "App",
|
||||
"launch_at_login": "Launch at login",
|
||||
"timeline": "Timeline",
|
||||
"show_unread_on_launch": {
|
||||
"description": "Show unread content on launch",
|
||||
"label": "Show unread content on launch"
|
||||
},
|
||||
"rebuild_database": {
|
||||
"warning": {
|
||||
"line1": "Rebuilding the database will clear all your local data.",
|
||||
"line2": "Are you sure you want to continue?"
|
||||
},
|
||||
"label": "Rebuild Database",
|
||||
"description": "If you are experiencing rendering issues, rebuilding the database may solve them.",
|
||||
"button": "Rebuild",
|
||||
"title": "Rebuild Database"
|
||||
},
|
||||
"group_by_date": {
|
||||
"label": "Group by date",
|
||||
"description": "Group entries by date."
|
||||
},
|
||||
"mark_as_read": {
|
||||
"scroll": {
|
||||
"label": "Mark as read when scrolling",
|
||||
"description": "Automatically mark entries as read when scrolled out of the view."
|
||||
},
|
||||
"hover": {
|
||||
"label": "Mark as read when hovering",
|
||||
"description": "Automatically mark entries as read when hovered."
|
||||
},
|
||||
"render": {
|
||||
"label": "Mark as read when in the view",
|
||||
"description": "Automatically mark single-level entries (e.g. social media posts, pictures, video views) as read when they enter the view."
|
||||
}
|
||||
},
|
||||
"privacy_data": "Privacy & Data",
|
||||
"data_persist": {
|
||||
"label": "Persist data for offline usage",
|
||||
"description": "Persist data locally to enable offline access and local search."
|
||||
},
|
||||
"send_anonymous_data": {
|
||||
"label": "Send anonymous data",
|
||||
"description": "By opting to send anonymized telemetry data, you contribute to improving the overall user experience of Follow."
|
||||
},
|
||||
"voices": "Voices"
|
||||
},
|
||||
"integration": {
|
||||
"title": "Integration",
|
||||
"eagle": {
|
||||
"title": "Eagle",
|
||||
"enable": {
|
||||
"label": "Enable",
|
||||
"description": "Display 'Save media to Eagle' button when available."
|
||||
}
|
||||
},
|
||||
"readwise": {
|
||||
"title": "Readwise",
|
||||
"enable": {
|
||||
"label": "Enable",
|
||||
"description": "Display 'Save to Readwise' button when available."
|
||||
},
|
||||
"token": {
|
||||
"label": "Readwise Access Token",
|
||||
"description": "You can get it here: readwise.io/access_token."
|
||||
}
|
||||
},
|
||||
"instapaper": {
|
||||
"title": "Instapaper",
|
||||
"enable": {
|
||||
"label": "Enable",
|
||||
"description": "Display 'Save to Instapaper' button when available."
|
||||
},
|
||||
"username": {
|
||||
"label": "Instapaper Username"
|
||||
},
|
||||
"password": {
|
||||
"label": "Instapaper Password"
|
||||
}
|
||||
},
|
||||
"tip": "Tip: Your sensitive data is stored locally and is not uploaded to the server."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"name": "English"
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"name": "简体中文(部分完成)"
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"words": {
|
||||
"search": "搜索",
|
||||
"import": "导入",
|
||||
"discover": "发现",
|
||||
"language": "语言"
|
||||
},
|
||||
"signin": {
|
||||
"continue_with_github": "使用 GitHub 登录",
|
||||
"continue_with_google": "使用 Google 登录",
|
||||
"sign_in_to": "登录到"
|
||||
},
|
||||
"settings": {
|
||||
"general": {
|
||||
"app": "应用程序",
|
||||
"data_persist": {
|
||||
"description": "在本地保留数据以启用离线访问和本地搜索。",
|
||||
"label": "保留数据以供离线使用"
|
||||
},
|
||||
"group_by_date": {
|
||||
"description": "按日期对条目进行分组。",
|
||||
"label": "按日期分组"
|
||||
},
|
||||
"launch_at_login": "登录时启动",
|
||||
"mark_as_read": {
|
||||
"hover": {
|
||||
"description": "悬停时自动将条目标记为已读。",
|
||||
"label": "悬停时标记为已读"
|
||||
},
|
||||
"render": {
|
||||
"description": "当单级条目(例如社交媒体帖子、图片、视频视图)进入视图时自动将其标记为已读。",
|
||||
"label": "在视图中标记为已读"
|
||||
},
|
||||
"scroll": {
|
||||
"description": "当滚动出视图时自动将条目标记为已读。",
|
||||
"label": "滚动时标记为已读"
|
||||
}
|
||||
},
|
||||
"privacy_data": "隐私",
|
||||
"rebuild_database": {
|
||||
"button": "重建",
|
||||
"description": "如果您遇到渲染问题,重建数据库可能会解决这些问题。",
|
||||
"label": "重建数据库",
|
||||
"title": "重建数据库",
|
||||
"warning[0]": "重建数据库将清除所有本地数据。",
|
||||
"warning[1]": "您确定要继续吗?",
|
||||
"warning": {
|
||||
"line1": "重建数据库将清除所有本地数据。",
|
||||
"line2": "您确定要继续吗?"
|
||||
}
|
||||
},
|
||||
"send_anonymous_data": {
|
||||
"description": "通过选择发送匿名遥测数据,您可以为改善“关注”的整体用户体验做出贡献。",
|
||||
"label": "发送匿名数据"
|
||||
},
|
||||
"show_unread_on_launch": {
|
||||
"description": "启动时显示未读内容",
|
||||
"label": "启动时显示未读内容"
|
||||
},
|
||||
"timeline": "时间轴",
|
||||
"voices": "声音"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -94,6 +94,7 @@
|
|||
"fuse.js": "7.0.0",
|
||||
"hast-util-to-jsx-runtime": "2.3.0",
|
||||
"hast-util-to-text": "4.0.2",
|
||||
"i18next": "^23.15.1",
|
||||
"idb-keyval": "6.2.1",
|
||||
"immer": "10.1.1",
|
||||
"jotai": "2.9.3",
|
||||
|
|
@ -112,6 +113,7 @@
|
|||
"react-fast-marquee": "1.6.5",
|
||||
"react-hook-form": "7.53.0",
|
||||
"react-hotkeys-hook": "4.5.1",
|
||||
"react-i18next": "^15.0.1",
|
||||
"react-intersection-observer": "9.13.1",
|
||||
"react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1",
|
||||
"react-router-dom": "6.26.1",
|
||||
|
|
|
|||
|
|
@ -206,6 +206,9 @@ importers:
|
|||
hast-util-to-text:
|
||||
specifier: 4.0.2
|
||||
version: 4.0.2
|
||||
i18next:
|
||||
specifier: ^23.15.1
|
||||
version: 23.15.1
|
||||
idb-keyval:
|
||||
specifier: 6.2.1
|
||||
version: 6.2.1
|
||||
|
|
@ -260,6 +263,9 @@ importers:
|
|||
react-hotkeys-hook:
|
||||
specifier: 4.5.1
|
||||
version: 4.5.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-i18next:
|
||||
specifier: ^15.0.1
|
||||
version: 15.0.1(i18next@23.15.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-intersection-observer:
|
||||
specifier: 9.13.1
|
||||
version: 9.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
|
@ -5309,6 +5315,9 @@ packages:
|
|||
html-escaper@3.0.3:
|
||||
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
|
||||
|
||||
html-void-elements@3.0.0:
|
||||
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
|
||||
|
||||
|
|
@ -5340,6 +5349,9 @@ packages:
|
|||
humps@2.0.1:
|
||||
resolution: {integrity: sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==}
|
||||
|
||||
i18next@23.15.1:
|
||||
resolution: {integrity: sha512-wB4abZ3uK7EWodYisHl/asf8UYEhrI/vj/8aoSsrj/ZDxj4/UXPOa1KvFt1Fq5hkUHquNqwFlDprmjZ8iySgYA==}
|
||||
|
||||
iconv-corefoundation@1.1.7:
|
||||
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
|
||||
engines: {node: ^8.11.2 || >=10}
|
||||
|
|
@ -6953,6 +6965,19 @@ packages:
|
|||
react: '>=16.8.1'
|
||||
react-dom: '>=16.8.1'
|
||||
|
||||
react-i18next@15.0.1:
|
||||
resolution: {integrity: sha512-NwxLqNM6CLbeGA9xPsjits0EnXdKgCRSS6cgkgOdNcPXqL+1fYNl8fBg1wmnnHvFy812Bt4IWTPE9zjoPmFj3w==}
|
||||
peerDependencies:
|
||||
i18next: '>= 23.2.3'
|
||||
react: '>= 16.8.0'
|
||||
react-dom: '*'
|
||||
react-native: '*'
|
||||
peerDependenciesMeta:
|
||||
react-dom:
|
||||
optional: true
|
||||
react-native:
|
||||
optional: true
|
||||
|
||||
react-intersection-observer@9.13.1:
|
||||
resolution: {integrity: sha512-tSzDaTy0qwNPLJHg8XZhlyHTgGW6drFKTtvjdL+p6um12rcnp8Z5XstE+QNBJ7c64n5o0Lj4ilUleA41bmDoMw==}
|
||||
peerDependencies:
|
||||
|
|
@ -8052,6 +8077,10 @@ packages:
|
|||
jsdom:
|
||||
optional: true
|
||||
|
||||
void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
wcwidth@1.0.1:
|
||||
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
|
||||
|
||||
|
|
@ -14331,6 +14360,10 @@ snapshots:
|
|||
|
||||
html-escaper@3.0.3: {}
|
||||
|
||||
html-parse-stringify@3.0.1:
|
||||
dependencies:
|
||||
void-elements: 3.1.0
|
||||
|
||||
html-void-elements@3.0.0: {}
|
||||
|
||||
htmlparser2@9.1.0:
|
||||
|
|
@ -14370,6 +14403,10 @@ snapshots:
|
|||
|
||||
humps@2.0.1: {}
|
||||
|
||||
i18next@23.15.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.25.4
|
||||
|
||||
iconv-corefoundation@1.1.7:
|
||||
dependencies:
|
||||
cli-truncate: 2.1.0
|
||||
|
|
@ -16175,6 +16212,15 @@ snapshots:
|
|||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-i18next@15.0.1(i18next@23.15.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.25.4
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 23.15.1
|
||||
react: 18.3.1
|
||||
optionalDependencies:
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-intersection-observer@9.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
|
@ -17375,6 +17421,8 @@ snapshots:
|
|||
- supports-color
|
||||
- terser
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
wcwidth@1.0.1:
|
||||
dependencies:
|
||||
defaults: 1.0.4
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import type resources from "./resources"
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
resources: (typeof resources)["en"]
|
||||
defaultNS: "translation"
|
||||
// if you see an error like: "Argument of type 'DefaultTFuncReturn' is not assignable to parameter of type xyz"
|
||||
// set returnNull to false (and also in the i18next init options)
|
||||
// returnNull: false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import en from "../../../../locales/en.json"
|
||||
import lang_en from "../../../../locales/modules/lang/en.json"
|
||||
import lang_zhCN from "../../../../locales/modules/lang/zh_CN.json"
|
||||
import zhCN from "../../../../locales/zh_CN.json"
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
translation: en,
|
||||
lang: lang_en,
|
||||
},
|
||||
zh_CN: {
|
||||
translation: zhCN,
|
||||
lang: lang_zhCN,
|
||||
},
|
||||
}
|
||||
export default resources
|
||||
|
|
@ -6,6 +6,7 @@ import { createSettingAtom } from "./helper"
|
|||
const createDefaultSettings = (): GeneralSettings => ({
|
||||
// App
|
||||
appLaunchOnStartup: false,
|
||||
language: "en",
|
||||
// Data control
|
||||
dataPersist: true,
|
||||
sendAnonymousData: true,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { ErrorBoundary } from "@sentry/react"
|
|||
import type { FC, PropsWithChildren } from "react"
|
||||
import { createElement, useCallback } from "react"
|
||||
|
||||
import type { ErrorComponentType } from "../errors"
|
||||
import { getErrorFallback } from "../errors"
|
||||
import type { ErrorComponentType } from "../errors/enum"
|
||||
|
||||
export interface AppErrorBoundaryProps extends PropsWithChildren {
|
||||
height?: number | string
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
export enum ErrorComponentType {
|
||||
Modal = "Modal",
|
||||
Page = "Page",
|
||||
|
||||
// Feed
|
||||
FeedFoundCanBeFollow = "FeedFoundCanBeFollow",
|
||||
FeedNotFound = "FeedNotFound",
|
||||
}
|
||||
|
|
@ -1,17 +1,9 @@
|
|||
import { ErrorComponentType } from "./enum"
|
||||
import { FeedFoundCanBeFollowErrorFallback } from "./FeedFoundCanBeFollowErrorFallback"
|
||||
import { FeedNotFoundErrorFallback } from "./FeedNotFound"
|
||||
import { ModalErrorFallback } from "./ModalError"
|
||||
import { PageErrorFallback } from "./PageError"
|
||||
|
||||
export enum ErrorComponentType {
|
||||
Modal = "Modal",
|
||||
Page = "Page",
|
||||
|
||||
// Feed
|
||||
FeedFoundCanBeFollow = "FeedFoundCanBeFollow",
|
||||
FeedNotFound = "FeedNotFound",
|
||||
}
|
||||
|
||||
export const ErrorFallbackMap = {
|
||||
[ErrorComponentType.Modal]: ModalErrorFallback,
|
||||
[ErrorComponentType.Page]: PageErrorFallback,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useUISettingKey } from "@renderer/atoms/settings/ui"
|
|||
import { AppErrorBoundary } from "@renderer/components/common/AppErrorBoundary"
|
||||
import { SafeFragment } from "@renderer/components/common/Fragment"
|
||||
import { m } from "@renderer/components/common/Motion"
|
||||
import { ErrorComponentType } from "@renderer/components/errors"
|
||||
import { ErrorComponentType } from "@renderer/components/errors/enum"
|
||||
import { isElectronBuild } from "@renderer/constants"
|
||||
import { useSwitchHotKeyScope } from "@renderer/hooks/common/useSwitchHotkeyScope"
|
||||
import { nextFrame, stopPropagation } from "@renderer/lib/dom"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Kbd } from "@renderer/components/ui/kbd/Kbd"
|
||||
import { HotKeyScopeMap } from "@renderer/constants"
|
||||
import { apiClient } from "@renderer/lib/api-fetch"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import { subscription as subscriptionQuery } from "@renderer/queries/subscriptions"
|
||||
import type { SubscriptionFlatModel } from "@renderer/store/subscription"
|
||||
import { subscriptionActions } from "@renderer/store/subscription"
|
||||
import { feedUnreadActions } from "@renderer/store/unread"
|
||||
|
|
@ -16,7 +16,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void })
|
|||
useMutation({
|
||||
mutationFn: async (subscription: SubscriptionFlatModel) =>
|
||||
subscriptionActions.unfollow(subscription.feedId).then((feed) => {
|
||||
Queries.subscription.byView(subscription.view).invalidate()
|
||||
subscriptionQuery.byView(subscription.view).invalidate()
|
||||
feedUnreadActions.updateByFeedId(subscription.feedId, 0)
|
||||
|
||||
if (!subscription) return
|
||||
|
|
@ -32,7 +32,7 @@ export const useDeleteSubscription = ({ onSuccess }: { onSuccess?: () => void })
|
|||
},
|
||||
})
|
||||
|
||||
Queries.subscription.byView(subscription.view).invalidate()
|
||||
subscriptionQuery.byView(subscription.view).invalidate()
|
||||
feedUnreadActions.fetchUnreadByView(subscription.view)
|
||||
|
||||
toast.dismiss(toastId)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { getGeneralSettings } from "@renderer/atoms/settings/general"
|
||||
import { EventBus } from "@renderer/lib/event-bus"
|
||||
import i18next from "i18next"
|
||||
import { initReactI18next } from "react-i18next"
|
||||
|
||||
import resources from "./@types/resources"
|
||||
|
||||
export const defaultNS = "translation"
|
||||
|
||||
export const fallbackLanguage = "en"
|
||||
export const initI18n = async () => {
|
||||
const { language } = getGeneralSettings()
|
||||
|
||||
await i18next.use(initReactI18next).init({
|
||||
lng: language,
|
||||
fallbackLng: fallbackLanguage,
|
||||
defaultNS,
|
||||
ns: [defaultNS],
|
||||
debug: true,
|
||||
resources,
|
||||
|
||||
backend: [],
|
||||
})
|
||||
}
|
||||
|
||||
export const currentSupportedLanguages = Object.keys(resources)
|
||||
export const languageCodeToName = Object.fromEntries(
|
||||
currentSupportedLanguages.map((lang) => [lang, resources[lang].lang["name"]]),
|
||||
)
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on("i18n-update", ({ file, content }: { file: string; content: string }) => {
|
||||
const resources = JSON.parse(content)
|
||||
|
||||
// `file` is absolute path e.g. /Users/innei/git/follow/locales/en.json
|
||||
// Absolute path e.g. /Users/innei/git/follow/locales/modules/<module-name>/en.json
|
||||
|
||||
// 1. parse root language
|
||||
if (!file.includes("locales/modules")) {
|
||||
const lang = file.split("/").pop()?.replace(".json", "")
|
||||
if (!lang) return
|
||||
i18next.addResourceBundle(lang, defaultNS, resources, true, true)
|
||||
i18next.reloadResources(lang, defaultNS)
|
||||
} else {
|
||||
const nsName = file.match(/locales\/modules\/(.+?)\//)?.[1]
|
||||
|
||||
if (!nsName) return
|
||||
const lang = file.split("/").pop()?.replace(".json", "")
|
||||
if (!lang) return
|
||||
i18next.addResourceBundle(lang, nsName, resources, true, true)
|
||||
i18next.reloadResources(lang, nsName)
|
||||
}
|
||||
|
||||
import.meta.env.DEV && EventBus.dispatch("I18N_UPDATE", "")
|
||||
})
|
||||
}
|
||||
|
||||
declare module "@renderer/lib/event-bus" {
|
||||
interface CustomEvent {
|
||||
I18N_UPDATE: string
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { repository } from "@pkg"
|
|||
import { getUISettings } from "@renderer/atoms/settings/ui"
|
||||
import { isElectronBuild } from "@renderer/constants"
|
||||
import { browserDB } from "@renderer/database"
|
||||
import { initI18n } from "@renderer/i18n"
|
||||
import { settingSyncQueue } from "@renderer/modules/settings/helper/sync-queue"
|
||||
import {
|
||||
ElectronCloseEvent,
|
||||
|
|
@ -91,6 +92,8 @@ export const initializeApp = async () => {
|
|||
const { dataPersist: enabledDataPersist, sendAnonymousData } = getGeneralSettings()
|
||||
|
||||
initSentry()
|
||||
await initI18n()
|
||||
|
||||
if (sendAnonymousData) initPostHog()
|
||||
|
||||
let dataHydratedTime: undefined | number
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { LoginRuntime } from "@renderer/lib/auth"
|
|||
import { loginHandler } from "@renderer/lib/auth"
|
||||
import { stopPropagation } from "@renderer/lib/dom"
|
||||
import { m } from "framer-motion"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface LoginModalContentProps {
|
||||
runtime?: LoginRuntime
|
||||
|
|
@ -16,6 +17,8 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
|
|||
|
||||
const { canClose = true, runtime } = props
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="center flex h-full" onClick={canClose ? modal.dismiss : undefined}>
|
||||
<m.div
|
||||
|
|
@ -24,7 +27,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
|
|||
{...modalMontionConfig}
|
||||
>
|
||||
<div className="mb-8 mt-4 text-center align-middle font-sans text-2xl font-bold leading-relaxed">
|
||||
<span className="text-xl">Sign in to </span>
|
||||
<span className="text-xl">{t("signin.sign_in_to")}</span>
|
||||
<span className="center flex translate-y-px gap-2 font-theme text-accent">
|
||||
<FollowIcon className="size-4" />
|
||||
{APP_NAME}
|
||||
|
|
@ -37,7 +40,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
|
|||
loginHandler("github", runtime)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-github-cute-fi mr-2 text-xl" /> Continue with GitHub
|
||||
<i className="i-mgc-github-cute-fi mr-2 text-xl" /> {t("signin.continue_with_github")}
|
||||
</Button>
|
||||
<Button
|
||||
className="h-[48px] w-[320px] rounded-[8px] bg-blue-500 font-sans text-base text-white hover:bg-blue-500/90 focus:!border-blue-500/80 focus:!ring-blue-500/80"
|
||||
|
|
@ -45,7 +48,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
|
|||
loginHandler("google", runtime)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-google-cute-fi mr-2 text-xl" /> Continue with Google
|
||||
<i className="i-mgc-google-cute-fi mr-2 text-xl" /> {t("signin.continue_with_google")}
|
||||
</Button>
|
||||
</div>
|
||||
</m.div>
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ import { FeedViewType } from "@renderer/lib/enum"
|
|||
import { getFetchErrorMessage, toastFetchError } from "@renderer/lib/error-parser"
|
||||
import { getNewIssueUrl } from "@renderer/lib/issues"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import { useFeed } from "@renderer/queries/feed"
|
||||
import { feed as feedQuery, useFeed } from "@renderer/queries/feed"
|
||||
import { subscription as subscriptionQuery } from "@renderer/queries/subscriptions"
|
||||
import { useFeedByIdOrUrl } from "@renderer/store/feed"
|
||||
import { useSubscriptionByFeedId } from "@renderer/store/subscription"
|
||||
import { feedUnreadActions } from "@renderer/store/unread"
|
||||
|
|
@ -199,18 +199,18 @@ const FeedInnerForm = ({
|
|||
},
|
||||
onSuccess: (_, variables) => {
|
||||
if (isSubscribed && variables.view !== `${subscription?.view}`) {
|
||||
Queries.subscription.byView(subscription?.view).invalidate()
|
||||
tipcClient?.invalidateQuery(Queries.subscription.byView(subscription?.view).key)
|
||||
subscriptionQuery.byView(subscription?.view).invalidate()
|
||||
tipcClient?.invalidateQuery(subscriptionQuery.byView(subscription?.view).key)
|
||||
feedUnreadActions.fetchUnreadByView(subscription?.view)
|
||||
}
|
||||
Queries.subscription.byView(Number.parseInt(variables.view)).invalidate()
|
||||
tipcClient?.invalidateQuery(Queries.subscription.byView(Number.parseInt(variables.view)).key)
|
||||
subscriptionQuery.byView(Number.parseInt(variables.view)).invalidate()
|
||||
tipcClient?.invalidateQuery(subscriptionQuery.byView(Number.parseInt(variables.view)).key)
|
||||
feedUnreadActions.fetchUnreadByView(Number.parseInt(variables.view))
|
||||
|
||||
const feedId = feed.id
|
||||
if (feedId) {
|
||||
Queries.feed.byId({ id: feedId }).invalidate()
|
||||
tipcClient?.invalidateQuery(Queries.feed.byId({ id: feedId }).key)
|
||||
feedQuery.byId({ id: feedId }).invalidate()
|
||||
tipcClient?.invalidateQuery(feedQuery.byId({ id: feedId }).key)
|
||||
}
|
||||
toast(isSubscribed ? "🎉 Updated." : "🎉 Followed.", {
|
||||
duration: 1000,
|
||||
|
|
@ -241,9 +241,7 @@ const FeedInnerForm = ({
|
|||
followMutation.mutate(values)
|
||||
}
|
||||
|
||||
const categories = useAuthQuery(
|
||||
Queries.subscription.categories(Number.parseInt(form.watch("view"))),
|
||||
)
|
||||
const categories = useAuthQuery(subscriptionQuery.categories(Number.parseInt(form.watch("view"))))
|
||||
|
||||
// useEffect(() => {
|
||||
// if (feed.isSuccess) nextFrame(() => buttonRef.current?.focus());
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ export const SettingTabbedSegment: Component<{
|
|||
export const SettingDescription: Component = ({ children, className }) => (
|
||||
<small
|
||||
className={cn(
|
||||
"block w-4/5 text-balance text-[13px] leading-tight text-gray-400 dark:text-neutral-500",
|
||||
"block w-4/5 text-[13px] leading-tight text-gray-400 dark:text-neutral-500",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { setGeneralSetting, useGeneralSettingValue } from "@renderer/atoms/settings/general"
|
||||
import {
|
||||
setGeneralSetting,
|
||||
useGeneralSettingSelector,
|
||||
useGeneralSettingValue,
|
||||
} from "@renderer/atoms/settings/general"
|
||||
import { createSetting } from "@renderer/atoms/settings/helper"
|
||||
import {
|
||||
createDefaultSettings,
|
||||
|
|
@ -14,11 +18,13 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@renderer/components/ui/select"
|
||||
import { currentSupportedLanguages, fallbackLanguage, languageCodeToName } from "@renderer/i18n"
|
||||
import { initPostHog } from "@renderer/initialize/posthog"
|
||||
import { tipcClient } from "@renderer/lib/client"
|
||||
import { clearLocalPersistStoreData } from "@renderer/store/utils/clear"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCallback, useEffect } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { SettingsTitle } from "../title"
|
||||
|
||||
|
|
@ -27,6 +33,7 @@ const { defineSettingItem, SettingBuilder } = createSetting(
|
|||
setGeneralSetting,
|
||||
)
|
||||
export const SettingGeneral = () => {
|
||||
const { t } = useTranslation()
|
||||
useEffect(() => {
|
||||
tipcClient?.getLoginItemSettings().then((settings) => {
|
||||
setGeneralSetting("appLaunchOnStartup", settings.openAtLogin)
|
||||
|
|
@ -40,6 +47,7 @@ export const SettingGeneral = () => {
|
|||
}, [])
|
||||
|
||||
const { present } = useModalStack()
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsTitle />
|
||||
|
|
@ -48,44 +56,43 @@ export const SettingGeneral = () => {
|
|||
settings={[
|
||||
{
|
||||
type: "title",
|
||||
value: "App",
|
||||
disabled: !window.electron,
|
||||
value: t("settings.general.app"),
|
||||
},
|
||||
{
|
||||
disabled: !window.electron,
|
||||
label: "Launch Follow at Login",
|
||||
key: "appLaunchOnStartup",
|
||||
|
||||
defineSettingItem("appLaunchOnStartup", {
|
||||
label: t("settings.general.launch_at_login"),
|
||||
disabled: !tipcClient,
|
||||
onChange(value) {
|
||||
saveLoginSetting(value)
|
||||
},
|
||||
},
|
||||
}),
|
||||
LanguageSelector,
|
||||
{
|
||||
type: "title",
|
||||
value: "timeline",
|
||||
value: t("settings.general.timeline"),
|
||||
},
|
||||
defineSettingItem("unreadOnly", {
|
||||
label: "Show unread content on launch",
|
||||
description: "Display only unread content when the app is launched.",
|
||||
label: t("settings.general.show_unread_on_launch.label"),
|
||||
description: t("settings.general.show_unread_on_launch.description"),
|
||||
}),
|
||||
defineSettingItem("groupByDate", {
|
||||
label: "Group by date",
|
||||
description: "Group entries by date.",
|
||||
label: t("settings.general.group_by_date.label"),
|
||||
description: t("settings.general.group_by_date.description"),
|
||||
}),
|
||||
|
||||
{ type: "title", value: "unread" },
|
||||
|
||||
defineSettingItem("scrollMarkUnread", {
|
||||
label: "Mark as read when scrolling",
|
||||
description: "Automatically mark entries as read when scrolled out of the view.",
|
||||
label: t("settings.general.mark_as_read.scroll.label"),
|
||||
description: t("settings.general.mark_as_read.scroll.description"),
|
||||
}),
|
||||
defineSettingItem("hoverMarkUnread", {
|
||||
label: "Mark as read when hovering",
|
||||
description: "Automatically mark entries as read when hovered.",
|
||||
label: t("settings.general.mark_as_read.hover.label"),
|
||||
description: t("settings.general.mark_as_read.hover.description"),
|
||||
}),
|
||||
defineSettingItem("renderMarkUnread", {
|
||||
label: "Mark as read when in the view",
|
||||
description:
|
||||
"Automatically mark single-level entries (e.g., social media posts, pictures, video views) as read when they enter the view.",
|
||||
label: t("settings.general.mark_as_read.render.label"),
|
||||
description: t("settings.general.mark_as_read.render.description"),
|
||||
}),
|
||||
|
||||
{ type: "title", value: "TTS", disabled: !window.electron },
|
||||
|
|
@ -99,18 +106,17 @@ export const SettingGeneral = () => {
|
|||
// }),
|
||||
{
|
||||
type: "title",
|
||||
value: "Privacy & Data",
|
||||
value: t("settings.general.privacy_data"),
|
||||
},
|
||||
|
||||
defineSettingItem("dataPersist", {
|
||||
label: "Persist data for offline usage",
|
||||
description: "Persist data locally to enable offline access and local search.",
|
||||
label: t("settings.general.data_persist.label"),
|
||||
description: t("settings.general.data_persist.description"),
|
||||
}),
|
||||
|
||||
defineSettingItem("sendAnonymousData", {
|
||||
label: "Send anonymous data",
|
||||
description:
|
||||
"By opting to send anonymized telemetry data, you contribute to improving the overall user experience of Follow.",
|
||||
label: t("settings.general.send_anonymous_data.label"),
|
||||
description: t("settings.general.send_anonymous_data.description"),
|
||||
onChange(value) {
|
||||
setGeneralSetting("sendAnonymousData", value)
|
||||
if (value) {
|
||||
|
|
@ -121,18 +127,16 @@ export const SettingGeneral = () => {
|
|||
}
|
||||
},
|
||||
}),
|
||||
|
||||
{
|
||||
label: "Rebuild Database",
|
||||
label: t("settings.general.rebuild_database.label"),
|
||||
action: async () => {
|
||||
present({
|
||||
title: "Rebuild Database",
|
||||
title: t("settings.general.rebuild_database.title"),
|
||||
clickOutsideToDismiss: true,
|
||||
content: () => (
|
||||
<div className="text-sm">
|
||||
<p>Rebuilding the database will clear all your local data.</p>
|
||||
<p>Are you sure you want to continue?</p>
|
||||
|
||||
<p>{t("settings.general.rebuild_database.warning.line1")}</p>
|
||||
<p>{t("settings.general.rebuild_database.warning.line2")}</p>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
className="px-3 text-red-500"
|
||||
|
|
@ -149,9 +153,8 @@ export const SettingGeneral = () => {
|
|||
),
|
||||
})
|
||||
},
|
||||
description:
|
||||
"If you are experiencing rendering issues, rebuilding the database may solve them.",
|
||||
buttonText: "Rebuild",
|
||||
description: t("settings.general.rebuild_database.description"),
|
||||
buttonText: t("settings.general.rebuild_database.button"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
@ -161,6 +164,7 @@ export const SettingGeneral = () => {
|
|||
}
|
||||
|
||||
export const VoiceSelector = () => {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useQuery({
|
||||
queryFn: () => tipcClient?.getVoices(),
|
||||
queryKey: ["voices"],
|
||||
|
|
@ -169,7 +173,7 @@ export const VoiceSelector = () => {
|
|||
|
||||
return (
|
||||
<div className="-mt-1 mb-3 flex items-center justify-between">
|
||||
<span className="shrink-0 text-sm font-medium">Voices</span>
|
||||
<span className="shrink-0 text-sm font-medium">{t("settings.general.voices")}</span>
|
||||
<Select
|
||||
defaultValue={createDefaultSettings().voice}
|
||||
value={voice}
|
||||
|
|
@ -191,3 +195,36 @@ export const VoiceSelector = () => {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const LanguageSelector = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const language = useGeneralSettingSelector((state) => state.language)
|
||||
|
||||
const finalRenderLanguage = currentSupportedLanguages.includes(language)
|
||||
? language
|
||||
: fallbackLanguage
|
||||
return (
|
||||
<div className="mb-3 mt-4 flex items-center justify-between">
|
||||
<span className="shrink-0 text-sm font-medium">{t("words.language")}</span>
|
||||
<Select
|
||||
defaultValue={finalRenderLanguage}
|
||||
value={finalRenderLanguage}
|
||||
onValueChange={(value) => {
|
||||
setGeneralSetting("language", value as string)
|
||||
i18n.changeLanguage(value as string)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="item-aligned">
|
||||
{currentSupportedLanguages.map((lang) => (
|
||||
<SelectItem key={lang} value={lang}>
|
||||
{languageCodeToName[lang]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
SimpleIconsInstapaper,
|
||||
SimpleIconsReadwise,
|
||||
} from "@renderer/components/ui/platform-icon/icons"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { SettingsTitle } from "../title"
|
||||
|
||||
|
|
@ -16,109 +17,101 @@ const { defineSettingItem, SettingBuilder } = createSetting(
|
|||
useIntegrationSettingValue,
|
||||
setIntegrationSetting,
|
||||
)
|
||||
export const SettingIntegration = () => (
|
||||
<>
|
||||
<SettingsTitle />
|
||||
<div className="mt-4">
|
||||
<SettingBuilder
|
||||
settings={[
|
||||
{
|
||||
type: "title",
|
||||
value: (
|
||||
<span className="flex items-center gap-2 font-bold">
|
||||
<SimpleIconsEagle />
|
||||
Eagle
|
||||
</span>
|
||||
),
|
||||
},
|
||||
defineSettingItem("enableEagle", {
|
||||
label: "Enable",
|
||||
description: (
|
||||
<>
|
||||
Display <i>Save media to Eagle</i> button when available.
|
||||
</>
|
||||
),
|
||||
}),
|
||||
{
|
||||
type: "title",
|
||||
value: (
|
||||
<span className="flex items-center gap-2 font-bold">
|
||||
<SimpleIconsReadwise />
|
||||
Readwise
|
||||
</span>
|
||||
),
|
||||
},
|
||||
defineSettingItem("enableReadwise", {
|
||||
label: "Enable",
|
||||
description: (
|
||||
<>
|
||||
Display <i>Save to Readwise</i> button when available.
|
||||
</>
|
||||
),
|
||||
}),
|
||||
defineSettingItem("readwiseToken", {
|
||||
label: "Readwise Access Token",
|
||||
vertical: true,
|
||||
type: "password",
|
||||
description: (
|
||||
<>
|
||||
You can get it here:{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
className="underline"
|
||||
rel="noreferrer noopener"
|
||||
href="https://readwise.io/access_token"
|
||||
>
|
||||
readwise.io/access_token
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
}),
|
||||
{
|
||||
type: "title",
|
||||
value: (
|
||||
<span className="flex items-center gap-2 font-bold">
|
||||
<SimpleIconsInstapaper />
|
||||
Instapaper
|
||||
</span>
|
||||
),
|
||||
},
|
||||
defineSettingItem("enableInstapaper", {
|
||||
label: "Enable",
|
||||
description: (
|
||||
<>
|
||||
Display <i>Save to Instapaper</i> button when available.
|
||||
</>
|
||||
),
|
||||
}),
|
||||
defineSettingItem("instapaperUsername", {
|
||||
label: "Instapaper Username",
|
||||
componentProps: {
|
||||
labelClassName: "w-[150px]",
|
||||
export const SettingIntegration = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
<SettingsTitle />
|
||||
<div className="mt-4">
|
||||
<SettingBuilder
|
||||
settings={[
|
||||
{
|
||||
type: "title",
|
||||
value: (
|
||||
<span className="flex items-center gap-2 font-bold">
|
||||
<SimpleIconsEagle />
|
||||
{t("settings.integration.eagle.title")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
}),
|
||||
defineSettingItem("instapaperPassword", {
|
||||
label: "Instapaper Password",
|
||||
type: "password",
|
||||
componentProps: {
|
||||
labelClassName: "w-[150px]",
|
||||
defineSettingItem("enableEagle", {
|
||||
label: t("settings.integration.eagle.enable.label"),
|
||||
description: t("settings.integration.eagle.enable.description"),
|
||||
}),
|
||||
{
|
||||
type: "title",
|
||||
value: (
|
||||
<span className="flex items-center gap-2 font-bold">
|
||||
<SimpleIconsReadwise />
|
||||
{t("settings.integration.readwise.title")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
}),
|
||||
defineSettingItem("enableReadwise", {
|
||||
label: t("settings.integration.readwise.enable.label"),
|
||||
description: t("settings.integration.readwise.enable.description"),
|
||||
}),
|
||||
defineSettingItem("readwiseToken", {
|
||||
label: t("settings.integration.readwise.token.label"),
|
||||
vertical: true,
|
||||
type: "password",
|
||||
description: (
|
||||
<>
|
||||
{t("settings.integration.readwise.token.description")}{" "}
|
||||
<a
|
||||
target="_blank"
|
||||
className="underline"
|
||||
rel="noreferrer noopener"
|
||||
href="https://readwise.io/access_token"
|
||||
>
|
||||
readwise.io/access_token
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
}),
|
||||
{
|
||||
type: "title",
|
||||
value: (
|
||||
<span className="flex items-center gap-2 font-bold">
|
||||
<SimpleIconsInstapaper />
|
||||
{t("settings.integration.instapaper.title")}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
defineSettingItem("enableInstapaper", {
|
||||
label: t("settings.integration.instapaper.enable.label"),
|
||||
description: t("settings.integration.instapaper.enable.description"),
|
||||
}),
|
||||
defineSettingItem("instapaperUsername", {
|
||||
label: t("settings.integration.instapaper.username.label"),
|
||||
componentProps: {
|
||||
labelClassName: "w-[150px]",
|
||||
},
|
||||
}),
|
||||
defineSettingItem("instapaperPassword", {
|
||||
label: t("settings.integration.instapaper.password.label"),
|
||||
type: "password",
|
||||
componentProps: {
|
||||
labelClassName: "w-[150px]",
|
||||
},
|
||||
}),
|
||||
|
||||
BottomTip,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
BottomTip,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const BottomTip = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Divider />
|
||||
<p className="opacity-60">
|
||||
<small>Tip: Your sensitive data is stored locally and is not uploaded to the server.</small>
|
||||
<small>{t("settings.integration.tip")}</small>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { DiscoverImport } from "@renderer/modules/discover/import"
|
|||
import { Recommendations } from "@renderer/modules/discover/recommendations"
|
||||
import { DiscoverRSS3 } from "@renderer/modules/discover/rss3-form"
|
||||
import { createElement } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
|
||||
import { useSubViewTitle } from "../hooks"
|
||||
|
|
@ -38,10 +39,11 @@ const tabs = [
|
|||
|
||||
export function Component() {
|
||||
const [search, setSearch] = useSearchParams()
|
||||
const { t } = useTranslation()
|
||||
useSubViewTitle("Discover")
|
||||
return (
|
||||
<>
|
||||
<div className="text-2xl font-bold">Discover</div>
|
||||
<div className="text-2xl font-bold">{t("words.discover")}</div>
|
||||
<Tabs
|
||||
value={search.get("type") || "search"}
|
||||
onValueChange={(val) => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { getUISettings, setUISetting } from "@renderer/atoms/settings/ui"
|
|||
import { setFeedColumnShow, useFeedColumnShow } from "@renderer/atoms/sidebar"
|
||||
import { useLoginModalShow, useWhoami } from "@renderer/atoms/user"
|
||||
import { AppErrorBoundary } from "@renderer/components/common/AppErrorBoundary"
|
||||
import { ErrorComponentType } from "@renderer/components/errors"
|
||||
import { ErrorComponentType } from "@renderer/components/errors/enum"
|
||||
import { PanelSplitter } from "@renderer/components/ui/divider"
|
||||
import { DeclarativeModal } from "@renderer/components/ui/modal/stacked/declarative-modal"
|
||||
import { NoopChildren } from "@renderer/components/ui/modal/stacked/utils"
|
||||
|
|
@ -174,7 +174,6 @@ const FeedResponsiveResizerContainer = ({
|
|||
useEffect(() => {
|
||||
let timer: any
|
||||
if (feedColumnShow) {
|
||||
|
||||
timer = setTimeout(() => {
|
||||
setDelayShowSplitter(true)
|
||||
}, 200)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { EventBus } from "@renderer/lib/event-bus"
|
||||
import i18next from "i18next"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { I18nextProvider } from "react-i18next"
|
||||
|
||||
export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const [currentI18NInstance, update] = useState(i18next)
|
||||
if (import.meta.env.DEV)
|
||||
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/rules-of-hooks
|
||||
useEffect(
|
||||
() =>
|
||||
EventBus.subscribe("I18N_UPDATE", () => {
|
||||
update(i18next.cloneInstance())
|
||||
}),
|
||||
[],
|
||||
)
|
||||
return <I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { HotkeysProvider } from "react-hotkeys-hook"
|
|||
|
||||
import { ContextMenuProvider } from "./context-menu-provider"
|
||||
import { EventProvider } from "./event-provider"
|
||||
import { I18nProvider } from "./i18n-provider"
|
||||
import { InvalidateQueryProvider } from "./invalidate-query-provider"
|
||||
import { StableRouterProvider } from "./stable-router-provider"
|
||||
import { SettingSync } from "./ui-setting-sync"
|
||||
|
|
@ -30,14 +31,16 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
|
|||
<PersistQueryClientProvider persistOptions={persistConfig} client={queryClient}>
|
||||
<HotkeysProvider initiallyActiveScopes={HotKeyScopeMap.Home}>
|
||||
<Provider store={jotaiStore}>
|
||||
<EventProvider />
|
||||
<UserProvider />
|
||||
<SettingSync />
|
||||
<ModalStackProvider />
|
||||
<ContextMenuProvider />
|
||||
<StableRouterProvider />
|
||||
{import.meta.env.DEV && <Devtools />}
|
||||
{children}
|
||||
<I18nProvider>
|
||||
<EventProvider />
|
||||
<UserProvider />
|
||||
<SettingSync />
|
||||
<ModalStackProvider />
|
||||
<ContextMenuProvider />
|
||||
<StableRouterProvider />
|
||||
{import.meta.env.DEV && <Devtools />}
|
||||
{children}
|
||||
</I18nProvider>
|
||||
</Provider>
|
||||
</HotkeysProvider>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export interface GeneralSettings {
|
||||
appLaunchOnStartup: boolean
|
||||
language: string
|
||||
dataPersist: boolean
|
||||
sendAnonymousData: boolean
|
||||
unreadOnly: boolean
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { readFileSync } from "node:fs"
|
||||
import { resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
|
|
@ -97,6 +98,24 @@ export default ({ mode }) => {
|
|||
|
||||
devPrint(),
|
||||
|
||||
{
|
||||
name: "custom-i18n-hmr",
|
||||
handleHotUpdate({ file, server }) {
|
||||
if (file.endsWith(".json") && file.includes("locales")) {
|
||||
server.ws.send({
|
||||
type: "custom",
|
||||
event: "i18n-update",
|
||||
data: {
|
||||
file,
|
||||
content: readFileSync(file, "utf-8"),
|
||||
},
|
||||
})
|
||||
|
||||
// return empty array to prevent the default HMR
|
||||
return []
|
||||
}
|
||||
},
|
||||
},
|
||||
process.env.ANALYZER && analyzer(),
|
||||
],
|
||||
define: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue