diff --git a/apps/expo/app/(tabs)/library/index.tsx b/apps/expo/app/(tabs)/library/index.tsx index 7d0d2a5279..6ffb7ada09 100644 --- a/apps/expo/app/(tabs)/library/index.tsx +++ b/apps/expo/app/(tabs)/library/index.tsx @@ -26,6 +26,7 @@ import { usePreferencesStore } from '~/stores' import { useSelectionStore } from '~/stores/selection' export default function Screen() { + const { t } = useTranslate() // Note: The id is a workaround for https://github.com/drizzle-team/drizzle-orm/issues/2660 const { id, increment, sortConfig, sourceFilter } = useDownloadsState( useShallow((state) => ({ @@ -89,11 +90,11 @@ export default function Screen() { // We create a sectioned list by grouping by series, then flatten it so that we have something like: // ["Series 1", item1, item2, "Series 2", item3, item4] // See https://shopify.github.io/flash-list/docs/guides/section-list/ - const grouped = groupBy(data, (item) => item.series_refs?.name || 'Unknown') + const grouped = groupBy(data, (item) => item.series_refs?.name || t('common.unknown')) return Object.entries(grouped).flatMap(([seriesName, items]) => { return [seriesName, ...items] }) - }, [sortConfig, data]) + }, [sortConfig, data, t]) const renderItem = useCallback(({ item }: { item: (typeof data)[0] | string }) => { if (typeof item === 'string') { diff --git a/apps/expo/app/(tabs)/settings/usage/index.tsx b/apps/expo/app/(tabs)/settings/usage/index.tsx index 266f92b495..99e887ca65 100644 --- a/apps/expo/app/(tabs)/settings/usage/index.tsx +++ b/apps/expo/app/(tabs)/settings/usage/index.tsx @@ -67,7 +67,7 @@ export default function Screen() { {savedServers.length > 0 && ( {savedServers.map((server) => ( () @@ -76,7 +77,7 @@ export default function Screen() { ) if (!record && !!updatedAt) { - throw new Error('Downloaded file not found') + throw new Error(t('errors.downloadedFileNotFound')) } if (!record) { @@ -102,6 +103,7 @@ type ReaderProps = { } function Reader({ record, bookmarks, annotations }: ReaderProps) { + const { t } = useTranslate() const downloadedFile = useMemo(() => record.downloaded_files, [record]) const unsyncedProgress = useMemo(() => record.read_progress, [record]) @@ -144,12 +146,10 @@ function Reader({ record, bookmarks, annotations }: ReaderProps) { }) console.error('Failed to initialize streamer:', error) setStreamerError( - error instanceof Error - ? error - : new Error('Failed to initialize streamer. Please reach out for support'), + error instanceof Error ? error : new Error(t('errors.streamerInitializationFailed')), ) } - }, [book.id, downloadedFile.serverId, downloadedFile.uri]) + }, [book.id, downloadedFile.serverId, downloadedFile.uri, t]) useEffect( () => { diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/_layout.tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/_layout.tsx index 656f2b7985..4438d1ff74 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/_layout.tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/_layout.tsx @@ -3,10 +3,12 @@ import { NativeTabs } from 'expo-router/unstable-native-tabs' import { useOPDSLegacyFeedContext } from '~/context/opdsLegacy' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' // TODO(opds): Support favorites and add a tab for it export default function TabLayout() { + const { t } = useTranslate() const { sdk } = useSDK() const { hasSearch } = useOPDSLegacyFeedContext() @@ -26,7 +28,7 @@ export default function TabLayout() { labelVisibilityMode="labeled" > - Feed + {t('opds.feed')} {hasSearch && ( - Search + {t('opds.search')} )} diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/[url].tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/[url].tsx index d211a42b93..0a07bee163 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/[url].tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/[url].tsx @@ -13,10 +13,11 @@ import { useLegacyOPDSEntrySize } from '~/components/opdsLegacy/useLegacyOPDSEnt import RefreshControl from '~/components/RefreshControl' import { FullScreenLoader } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' -import { useLegacyOPDSFeed } from '~/lib/hooks' +import { useLegacyOPDSFeed, useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' export default function Screen() { + const { t } = useTranslate() const { url: feedURL } = useLocalSearchParams<{ url: string }>() const { feed, // The current page feed @@ -43,14 +44,14 @@ export default function Screen() { } } - if (showLoader) return + if (showLoader) return if (isLoading) return null if (!feed || !!error) return if (!entries.length) { - return + return } return ( diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/_layout.tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/_layout.tsx index aaa18b4939..e6230031d4 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/_layout.tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/_layout.tsx @@ -2,9 +2,11 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Layout() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) return ( @@ -18,7 +20,7 @@ export default function Layout() { name="index" options={{ headerShown: true, - title: 'Feeds', + title: t('opds.feeds'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', headerLargeTitle: Platform.OS === 'ios', diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/index.tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/index.tsx index da4c0bff88..80dcb55da7 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/feed/index.tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/feed/index.tsx @@ -14,10 +14,11 @@ import RefreshControl from '~/components/RefreshControl' import { FullScreenLoader } from '~/components/ui' import { useOPDSLegacyFeedContext } from '~/context/opdsLegacy' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' -import { useLegacyOPDSFeed } from '~/lib/hooks' +import { useLegacyOPDSFeed, useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' export default function Screen() { + const { t } = useTranslate() const { activeServer } = useActiveServer() const { catalogMeta } = useOPDSLegacyFeedContext() const { @@ -35,7 +36,7 @@ export default function Screen() { const { numColumns } = useLegacyOPDSEntrySize() useDynamicHeader({ - title: activeServer?.name || 'OPDS Feed', + title: activeServer?.name || t('opds.feedTitle'), headerLeft: () => , headerRight: () => , }) @@ -46,7 +47,7 @@ export default function Screen() { } } - if (showLoader) return + if (showLoader) return if (isLoading) return null diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/search/[query].tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/search/[query].tsx index 9dbe97c62f..4823896c12 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/search/[query].tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/search/[query].tsx @@ -15,11 +15,12 @@ import RefreshControl from '~/components/RefreshControl' import { FullScreenLoader } from '~/components/ui' import { useOPDSLegacyFeedContext } from '~/context/opdsLegacy' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' -import { useLegacyOPDSFeed } from '~/lib/hooks' +import { useLegacyOPDSFeed, useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' import { constructLegacySearchURL } from '~/lib/opdsUtils' export default function Screen() { + const { t } = useTranslate() const { query } = useLocalSearchParams<{ query: string }>() const { searchDoc } = useOPDSLegacyFeedContext() const { numColumns } = useLegacyOPDSEntrySize() @@ -42,7 +43,7 @@ export default function Screen() { const showLoader = useShowSlowLoader(isLoading) useDynamicHeader({ - title: query || 'Search Results', + title: query || t('opds.searchResults'), headerLeft: () => , headerRight: () => , }) @@ -53,14 +54,14 @@ export default function Screen() { } } - if (showLoader) return + if (showLoader) return if (isLoading) return null if (!feed || !!error) return if (!entries.length) { - return + return } return ( diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/search/_layout.tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/search/_layout.tsx index f3ff560ad5..ab232127fa 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/search/_layout.tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/search/_layout.tsx @@ -2,9 +2,11 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Layout() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) return ( @@ -18,7 +20,7 @@ export default function Layout() { name="index" options={{ headerShown: true, - title: 'Search', + title: t('opds.search'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', }} diff --git a/apps/expo/app/opds-legacy/[id]/(tabs)/search/index.tsx b/apps/expo/app/opds-legacy/[id]/(tabs)/search/index.tsx index dfff2aaeff..234b9eadb2 100644 --- a/apps/expo/app/opds-legacy/[id]/(tabs)/search/index.tsx +++ b/apps/expo/app/opds-legacy/[id]/(tabs)/search/index.tsx @@ -9,9 +9,11 @@ import { SearchHistoryAndFavorites } from '~/components/search/SearchHistoryAndF import { Text } from '~/components/ui' import { useOPDSLegacyFeedContext } from '~/context/opdsLegacy' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useSearchStore } from '~/stores/search' export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -53,7 +55,7 @@ export default function Screen() { headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', headerSearchBarOptions: { - placeholder: 'Search', + placeholder: t('opds.search'), onChangeText: (e: NativeSyntheticEvent) => setQuery(e.nativeEvent.text), shouldShowHintSearchIcon: true, @@ -67,7 +69,7 @@ export default function Screen() { textColor: colors.foreground.DEFAULT, }, }) - }, [navigation, setQuery, onSearch, colors]) + }, [navigation, setQuery, onSearch, colors, t]) if (!isInputFocused) { return ( @@ -76,11 +78,11 @@ export default function Screen() { - Search the feed + {t('opds.searchFeed')} - - Enter a search query to find content in this OPDS feed + + {t('opds.searchFeedDescription')} diff --git a/apps/expo/app/opds-legacy/[id]/_layout.tsx b/apps/expo/app/opds-legacy/[id]/_layout.tsx index 5e1932f0d6..cd4bfa6c44 100644 --- a/apps/expo/app/opds-legacy/[id]/_layout.tsx +++ b/apps/expo/app/opds-legacy/[id]/_layout.tsx @@ -15,6 +15,7 @@ import { } from '~/context/opdsLegacy' import { getOPDSInstance, isOPDSAuthError } from '~/lib/sdk/auth' import { usePreferencesStore, useSavedServers } from '~/stores' +import { useTranslate } from '~/lib/hooks' import { useCacheStore } from '~/stores/cache' type OPDSFeedProviderProps = { @@ -22,6 +23,7 @@ type OPDSFeedProviderProps = { } function OPDSFeedProvider({ children }: OPDSFeedProviderProps) { + const { t } = useTranslate() const { sdk } = useSDK() const { activeServer } = useActiveServer() const { onUnauthenticatedResponse } = useClientContext() @@ -75,7 +77,7 @@ function OPDSFeedProvider({ children }: OPDSFeedProviderProps) { ) if (isCatalogLoading && !catalog) { - return + return } return ( @@ -92,6 +94,7 @@ function OPDSFeedProvider({ children }: OPDSFeedProviderProps) { // minimal smart phones where theres just a few buttons for the essentials etc. Definitely something to revisit // for the v2 flow, as I think that can be much prettier than it currently is export default function Screen() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) const { savedServers, getServerConfig } = useSavedServers() @@ -132,8 +135,8 @@ export default function Screen() { const onAuthError = useCallback(() => { removeInstanceFromCache(`${serverID}-opds`) - throw new Error('This OPDS server requires authentication') - }, [serverID, removeInstanceFromCache]) + throw new Error(t('opds.authenticationRequired')) + }, [serverID, removeInstanceFromCache, t]) if (!activeServer) { // @ts-expect-error: It's fine diff --git a/apps/expo/app/opds-legacy/[id]/read.tsx b/apps/expo/app/opds-legacy/[id]/read.tsx index 756962f811..3e76f7d500 100644 --- a/apps/expo/app/opds-legacy/[id]/read.tsx +++ b/apps/expo/app/opds-legacy/[id]/read.tsx @@ -13,6 +13,7 @@ import { useResolveURL } from '~/components/opds/utils' import { OPDSLegacyStreamingContextValue } from '~/context/opdsLegacy' import { db, downloadedFiles, readProgress, syncStatus } from '~/db' import { useReaderStore } from '~/stores' +import { useTranslate } from '~/lib/hooks' import { useBookPreferences, useBookTimer } from '~/stores/reader' type Params = Omit & { @@ -21,6 +22,7 @@ type Params = Omit() @@ -69,10 +71,10 @@ export default function Screen() { if (streamingURL.includes('{pageNumber}')) { return streamingURL.replace('{pageNumber}', actualPageNumber.toString()) } else { - throw new Error('Unsupported streaming URL format') + throw new Error(t('opds.unsupportedStreamingUrl')) } }, - [resolveUrl, contextValue.streamingURL], + [resolveUrl, contextValue.streamingURL, t], ) const book = useMemo( diff --git a/apps/expo/app/opds/[id]/(tabs)/_layout.tsx b/apps/expo/app/opds/[id]/(tabs)/_layout.tsx index 64dc940bf4..2dec7d562e 100644 --- a/apps/expo/app/opds/[id]/(tabs)/_layout.tsx +++ b/apps/expo/app/opds/[id]/(tabs)/_layout.tsx @@ -3,10 +3,12 @@ import { NativeTabs } from 'expo-router/unstable-native-tabs' import { useOPDSFeedContext } from '~/context/opds' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' // TODO(opds): Support favorites and add a tab for it export default function TabLayout() { + const { t } = useTranslate() const { sdk } = useSDK() const { hasSearch } = useOPDSFeedContext() @@ -26,7 +28,7 @@ export default function TabLayout() { labelVisibilityMode="labeled" > - Feeds + {t('opds.feeds')} {hasSearch && ( - Search + {t('opds.search')} )} diff --git a/apps/expo/app/opds/[id]/(tabs)/feed/[url].tsx b/apps/expo/app/opds/[id]/(tabs)/feed/[url].tsx index 3a5a397818..617d7b612b 100644 --- a/apps/expo/app/opds/[id]/(tabs)/feed/[url].tsx +++ b/apps/expo/app/opds/[id]/(tabs)/feed/[url].tsx @@ -5,9 +5,11 @@ import BackLink from '~/components/BackLink' import { MaybeErrorFeed, OPDSFeed } from '~/components/opds' import { useOPDSFeed } from '~/components/opds/useOPDSFeed' import { FullScreenLoader } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' export default function Screen() { + const { t } = useTranslate() const { url: feedURL } = useLocalSearchParams<{ url: string }>() const { @@ -30,7 +32,7 @@ export default function Screen() { headerLeft: () => , }) - if (showLoader) return + if (showLoader) return if (isLoading) return null diff --git a/apps/expo/app/opds/[id]/(tabs)/feed/_layout.tsx b/apps/expo/app/opds/[id]/(tabs)/feed/_layout.tsx index aaa18b4939..e6230031d4 100644 --- a/apps/expo/app/opds/[id]/(tabs)/feed/_layout.tsx +++ b/apps/expo/app/opds/[id]/(tabs)/feed/_layout.tsx @@ -2,9 +2,11 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Layout() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) return ( @@ -18,7 +20,7 @@ export default function Layout() { name="index" options={{ headerShown: true, - title: 'Feeds', + title: t('opds.feeds'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', headerLargeTitle: Platform.OS === 'ios', diff --git a/apps/expo/app/opds/[id]/(tabs)/feed/index.tsx b/apps/expo/app/opds/[id]/(tabs)/feed/index.tsx index 6ffd4c6e44..e0db01f24a 100644 --- a/apps/expo/app/opds/[id]/(tabs)/feed/index.tsx +++ b/apps/expo/app/opds/[id]/(tabs)/feed/index.tsx @@ -5,9 +5,11 @@ import BackLink from '~/components/BackLink' import { MaybeErrorFeed, OPDSFeed } from '~/components/opds' import { FullScreenLoader } from '~/components/ui' import { useOPDSFeedContext } from '~/context/opds' +import { useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' export default function Screen() { + const { t } = useTranslate() const { activeServer } = useActiveServer() const { catalog: feed, isLoading, error, refetch } = useOPDSFeedContext() const [isRefetching, onRefetch] = useRefetch(refetch) @@ -18,7 +20,7 @@ export default function Screen() { headerLeft: () => , }) - if (showLoader) return + if (showLoader) return if (isLoading) return null diff --git a/apps/expo/app/opds/[id]/(tabs)/search/[query].tsx b/apps/expo/app/opds/[id]/(tabs)/search/[query].tsx index 725199e53f..c1258b2562 100644 --- a/apps/expo/app/opds/[id]/(tabs)/search/[query].tsx +++ b/apps/expo/app/opds/[id]/(tabs)/search/[query].tsx @@ -7,10 +7,12 @@ import EmptyState from '~/components/EmptyState' import { MaybeErrorFeed, OPDSFeed } from '~/components/opds' import { PaginationTarget } from '~/components/opds/useOPDSFeed' import { useOPDSFeedContext } from '~/context/opds' +import { useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' import { constructSearchURL } from '~/lib/opdsUtils' export default function Screen() { + const { t } = useTranslate() const { query } = useLocalSearchParams<{ query: string }>() const { sdk } = useSDK() const { searchURL } = useOPDSFeedContext() @@ -32,7 +34,7 @@ export default function Screen() { const [isRefetching, onRefetch] = useRefetch(refetch) useDynamicHeader({ - title: query || 'Search Results', + title: query || t('opds.searchResults'), headerLeft: () => , }) @@ -42,7 +44,7 @@ export default function Screen() { !feed?.groups?.length && !feed?.publications?.length && !feed?.navigation?.length if (emptyFeed) { - return + return } if (!feed || !!error) { diff --git a/apps/expo/app/opds/[id]/(tabs)/search/_layout.tsx b/apps/expo/app/opds/[id]/(tabs)/search/_layout.tsx index f3ff560ad5..ab232127fa 100644 --- a/apps/expo/app/opds/[id]/(tabs)/search/_layout.tsx +++ b/apps/expo/app/opds/[id]/(tabs)/search/_layout.tsx @@ -2,9 +2,11 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Layout() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) return ( @@ -18,7 +20,7 @@ export default function Layout() { name="index" options={{ headerShown: true, - title: 'Search', + title: t('opds.search'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', }} diff --git a/apps/expo/app/opds/[id]/(tabs)/search/index.tsx b/apps/expo/app/opds/[id]/(tabs)/search/index.tsx index 426d046e1d..793c7cd00a 100644 --- a/apps/expo/app/opds/[id]/(tabs)/search/index.tsx +++ b/apps/expo/app/opds/[id]/(tabs)/search/index.tsx @@ -9,9 +9,11 @@ import { SearchHistoryAndFavorites } from '~/components/search/SearchHistoryAndF import { Text } from '~/components/ui' import { useOPDSFeedContext } from '~/context/opds' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useSearchStore } from '~/stores/search' export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -53,7 +55,7 @@ export default function Screen() { headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', headerSearchBarOptions: { - placeholder: 'Search', + placeholder: t('opds.search'), onChangeText: (e: NativeSyntheticEvent) => setQuery(e.nativeEvent.text), shouldShowHintSearchIcon: true, @@ -67,7 +69,7 @@ export default function Screen() { textColor: colors.foreground.DEFAULT, }, }) - }, [navigation, setQuery, onSearch, colors]) + }, [navigation, setQuery, onSearch, colors, t]) if (!isInputFocused) { return ( @@ -76,11 +78,11 @@ export default function Screen() { - Search the feed + {t('opds.searchFeed')} - - Enter a search query to find content in this OPDS feed + + {t('opds.searchFeedDescription')} diff --git a/apps/expo/app/opds/[id]/_layout.tsx b/apps/expo/app/opds/[id]/_layout.tsx index 73052e99cb..cc6f7be47c 100644 --- a/apps/expo/app/opds/[id]/_layout.tsx +++ b/apps/expo/app/opds/[id]/_layout.tsx @@ -16,6 +16,7 @@ import { FullScreenLoader } from '~/components/ui' import { feedHasSearch, getSearchURL, OPDSFeedContext } from '~/context/opds' import { getOPDSInstance, isOPDSAuthError } from '~/lib/sdk/auth' import { usePreferencesStore, useSavedServers } from '~/stores' +import { useTranslate } from '~/lib/hooks' import { useCacheStore } from '~/stores/cache' type OPDSFeedProviderProps = { @@ -24,6 +25,7 @@ type OPDSFeedProviderProps = { } function OPDSFeedProvider({ children, isAuthPending }: OPDSFeedProviderProps) { + const { t } = useTranslate() const { sdk } = useSDK() const { activeServer } = useActiveServer() const { onUnauthenticatedResponse } = useClientContext() @@ -66,13 +68,13 @@ function OPDSFeedProvider({ children, isAuthPending }: OPDSFeedProviderProps) { ) if (isCatalogLoading && !catalog) { - return + return } const isAuthError = isOPDSAuthError(error) if (isAuthError || isAuthPending) { - return + return } return {children} diff --git a/apps/expo/app/opds/[id]/publication/index.tsx b/apps/expo/app/opds/[id]/publication/index.tsx index b4f855b281..cd6663477c 100644 --- a/apps/expo/app/opds/[id]/publication/index.tsx +++ b/apps/expo/app/opds/[id]/publication/index.tsx @@ -66,10 +66,10 @@ export default function Screen() { const navigation = useNavigation() useLayoutEffect(() => { navigation.setOptions({ - title: title || 'Publication', + title: title || t('common.publication'), // headerRight: () => , }) - }, [navigation, url, title, metadata]) + }, [navigation, url, title, metadata, t]) const menuFragment = usePublicationMenu({ publicationUrl: url, @@ -201,11 +201,13 @@ export default function Screen() { const isCompleted = !!(percentageCompleted && percentageCompleted >= 1) if (isCompleted) { - return + return ( + + ) } else { return ( ) @@ -265,7 +267,7 @@ export default function Screen() { - {title || 'Untitled'} + {title || t('common.unknownTitle')} {seriesText && ( @@ -288,7 +290,7 @@ export default function Screen() { } disabled={!canStream || !isSupportedStream} > - Stream + {t('opds.stream')} {!isDownloaded && ( )} @@ -313,7 +315,7 @@ export default function Screen() { {progression.locator.locations?.position && ( 0 @@ -324,7 +326,7 @@ export default function Screen() { )} {progression.locator.locations?.totalProgression != null && ( )} @@ -339,23 +341,23 @@ export default function Screen() { {!downloadURL - ? 'No download link available for this publication' - : `Unsupported file format: ${acquisitionLink?.type || 'unknown'}`} + ? t('opds.noDownloadLink') + : t('opds.unsupportedFileFormat', { + format: acquisitionLink?.type || t('common.unknown'), + })} )} {!canStream && ( - This publication lacks a defined reading order and cannot be streamed + {t('opds.noReadingOrder')} )} {!isSupportedStream && ( - - This publication contains unsupported media types and cannot be streamed yet - + {t('opds.unsupportedMedia')} )} @@ -367,15 +369,15 @@ export default function Screen() { - {!!publisher && } - {volume != null && } - {issue != null && } - {!!numberOfPages && } + {!!publisher && } + {volume != null && } + {issue != null && } + {!!numberOfPages && } ({ label: subject.label, onPress: () => goToFeedLink(getFirstLink(subject.links)), @@ -391,7 +393,7 @@ export default function Screen() { - {belongsToSeries?.name || 'Series Books'} + {belongsToSeries?.name || t('opds.seriesBooks')} {seriesUrl && } @@ -412,7 +414,7 @@ export default function Screen() { - {belongsToCollection?.name || 'Collection Books'} + {belongsToCollection?.name || t('opds.collectionBooks')} {collectionUrl && } @@ -429,19 +431,21 @@ export default function Screen() { )} - - {subtitle && } - {language && } - {readingDirection && } + + {subtitle && } + {language && } + {readingDirection && ( + + )} {modified && ( )} {published && ( )} diff --git a/apps/expo/app/server/[id]/(tabs)/_layout.tsx b/apps/expo/app/server/[id]/(tabs)/_layout.tsx index 71265f5b6d..b49a09ed84 100644 --- a/apps/expo/app/server/[id]/(tabs)/_layout.tsx +++ b/apps/expo/app/server/[id]/(tabs)/_layout.tsx @@ -42,12 +42,12 @@ export default function TabLayout() { // This can happen if the client is "newer" than the server and is trying to use an endpoint that doesn't exist. // We should probably inform the user that they need to update their server. // For now, throw to trigger the error boundary - throw new Error('Incompatible server', { cause: error }) + throw new Error(t('errors.incompatibleServer'), { cause: error }) } } else if (error?.message === 'Malformed response received from server') { - throw new Error('Incompatible server', { cause: error }) + throw new Error(t('errors.incompatibleServer'), { cause: error }) } - }, [error, onUnauthenticatedResponse]) + }, [error, onUnauthenticatedResponse, t]) const showClubs = bookClubsEnabled && checkPermission(UserPermission.AccessBookClub) diff --git a/apps/expo/app/server/[id]/(tabs)/browse/_layout.tsx b/apps/expo/app/server/[id]/(tabs)/browse/_layout.tsx index d9285a2156..4ab0bc21dc 100644 --- a/apps/expo/app/server/[id]/(tabs)/browse/_layout.tsx +++ b/apps/expo/app/server/[id]/(tabs)/browse/_layout.tsx @@ -2,15 +2,17 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' export default function Layout() { + const { t } = useTranslate() return ( !state.reduceAnimations) const { activeServer: { id: serverID }, @@ -25,7 +27,7 @@ export default function Layout() { name="index" options={{ headerShown: true, - headerTitle: 'Clubs', + headerTitle: t('bookClub.clubs'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', headerLargeTitle: true, @@ -41,7 +43,7 @@ export default function Layout() { name="invites" options={{ headerShown: true, - title: 'Invites', + title: t('bookClub.invites'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', animation: animationEnabled ? 'default' : 'none', @@ -55,7 +57,7 @@ export default function Layout() { name="create" options={{ headerShown: true, - title: 'Create Club', + title: t('bookClub.createClub'), headerTransparent: Platform.OS === 'ios', headerBlurEffect: IS_IOS_26_PLUS ? undefined : 'regular', presentation: 'formSheet', diff --git a/apps/expo/app/server/[id]/(tabs)/clubs/create.tsx b/apps/expo/app/server/[id]/(tabs)/clubs/create.tsx index 0e22b21687..931b5efcf8 100644 --- a/apps/expo/app/server/[id]/(tabs)/clubs/create.tsx +++ b/apps/expo/app/server/[id]/(tabs)/clubs/create.tsx @@ -10,6 +10,7 @@ import { toast } from 'sonner-native' import { useActiveServer } from '~/components/activeServer' import { Icon, Input, Switch, Text } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' const createMutation = graphql(` mutation CreateBookClubMobile($input: CreateBookClubInput!) { @@ -21,6 +22,7 @@ const createMutation = graphql(` `) export default function Screen() { + const { t } = useTranslate() const router = useRouter() const { activeServer: { id: serverID }, @@ -51,11 +53,11 @@ export default function Screen() { router.replace(`/server/${serverID}/clubs/${result.createBookClub.id}`) } } catch (error) { - toast.error('Failed to create club', { - description: error instanceof Error ? error.message : 'An unknown error occurred', + toast.error(t('bookClub.createFailed'), { + description: error instanceof Error ? error.message : t('errors.unknown'), }) } - }, [canSubmit, createClub, description, isPrivate, name, router, serverID]) + }, [canSubmit, createClub, description, isPrivate, name, router, serverID, t]) const navigation = useNavigation() useLayoutEffect(() => { @@ -82,8 +84,8 @@ export default function Screen() { > setName(e.nativeEvent.text)} autoCapitalize="words" @@ -91,8 +93,8 @@ export default function Screen() { /> setDescription(e.nativeEvent.text)} multiline @@ -102,7 +104,7 @@ export default function Screen() { {/* FIXME: No idea why I need mt here, something weird with switches */} - Private + {t('bookClub.private')} diff --git a/apps/expo/app/server/[id]/(tabs)/clubs/index.tsx b/apps/expo/app/server/[id]/(tabs)/clubs/index.tsx index 23b7561e45..b83599315d 100644 --- a/apps/expo/app/server/[id]/(tabs)/clubs/index.tsx +++ b/apps/expo/app/server/[id]/(tabs)/clubs/index.tsx @@ -14,6 +14,7 @@ import { BookClubCard } from '~/components/bookClub' import ListEmpty from '~/components/ListEmpty' import RefreshControl from '~/components/RefreshControl' import { Icon } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookClubsScreen { @@ -28,6 +29,7 @@ const query = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -87,10 +89,7 @@ export default function Screen() { contentInsetAdjustmentBehavior="always" ItemSeparatorComponent={() => } ListEmptyComponent={ - + } refreshControl={} /> diff --git a/apps/expo/app/server/[id]/(tabs)/clubs/invites.tsx b/apps/expo/app/server/[id]/(tabs)/clubs/invites.tsx index f015e4c05a..db6064d06e 100644 --- a/apps/expo/app/server/[id]/(tabs)/clubs/invites.tsx +++ b/apps/expo/app/server/[id]/(tabs)/clubs/invites.tsx @@ -4,6 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { useActiveServer } from '~/components/activeServer' import ListEmpty from '~/components/ListEmpty' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookClubInvitesScreen { @@ -33,6 +34,7 @@ type Invitation = NonNullable< > export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -52,10 +54,7 @@ export default function Screen() { if (!invitations.length) { return ( - + ) } diff --git a/apps/expo/app/server/[id]/(tabs)/search/[query].tsx b/apps/expo/app/server/[id]/(tabs)/search/[query].tsx index b44070c18c..610704a7a0 100644 --- a/apps/expo/app/server/[id]/(tabs)/search/[query].tsx +++ b/apps/expo/app/server/[id]/(tabs)/search/[query].tsx @@ -14,6 +14,7 @@ import EmptyState from '~/components/EmptyState' import { ILibrarySearchItemFragment, LibrarySearchItem } from '~/components/library' import { ISeriesSearchItemFragment, SeriesSearchItem } from '~/components/series' import { Heading, Text } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' const mediaQuery = graphql(` @@ -68,6 +69,7 @@ const libraryQuery = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { query } = useLocalSearchParams<{ query: string }>() const { activeServer: { id: serverID }, @@ -142,8 +144,8 @@ export default function Screen() { if (noResults) { return ( ) } @@ -151,17 +153,17 @@ export default function Screen() { return ( {!!bookResults?.media.nodes.length && ( - - Books + + {t('search.results.books')} {getHasMore(bookResults?.media.pageInfo) && ( - See More + {t('search.results.seeMore')} )} @@ -190,8 +192,8 @@ export default function Screen() { {!!seriesResults?.series.nodes.length && ( - - Series + + {t('search.results.series')} {/* {getHasMore(seriesResults?.series.pageInfo) && ( See More @@ -223,8 +225,8 @@ export default function Screen() { {!!librariesResults?.libraries.nodes.length && ( - - Libraries + + {t('search.results.libraries')} {/* {getHasMore(seriesResults?.series.pageInfo) && ( See More diff --git a/apps/expo/app/server/[id]/(tabs)/search/_layout.tsx b/apps/expo/app/server/[id]/(tabs)/search/_layout.tsx index 06b2086614..802d3ad6a2 100644 --- a/apps/expo/app/server/[id]/(tabs)/search/_layout.tsx +++ b/apps/expo/app/server/[id]/(tabs)/search/_layout.tsx @@ -2,8 +2,10 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' export default function Layout() { + const { t } = useTranslate() return ( !state.reduceAnimations) @@ -266,11 +268,11 @@ export default function Screen() { } if (isAutoAuthenticating) { - return + return } if (!sdk) { - return + return } return ( diff --git a/apps/expo/app/server/[id]/books/[bookId]/read.tsx b/apps/expo/app/server/[id]/books/[bookId]/read.tsx index 256dcd490e..e52ece93f2 100644 --- a/apps/expo/app/server/[id]/books/[bookId]/read.tsx +++ b/apps/expo/app/server/[id]/books/[bookId]/read.tsx @@ -29,6 +29,7 @@ import { useSyncOnlineToOfflineAnnotations, useSyncOnlineToOfflineBookmarks, useSyncOnlineToOfflineProgress, + useTranslate, } from '~/lib/hooks' import { intoReadiumLocator, ReadiumLocator } from '~/modules/readium' import { usePreferencesStore, useReaderStore } from '~/stores' @@ -262,6 +263,7 @@ type Params = { // TODO(reading): support incognito, not using it here lol export default function Screen() { + const { t } = useTranslate() useKeepAwake() const { bookId } = useLocalSearchParams() @@ -279,7 +281,7 @@ export default function Screen() { const preferNativePdfReader = usePreferencesStore((store) => Boolean(store.preferNativePdf)) if (!book) { - throw new Error('Book not found') + throw new Error(t('errors.bookNotFound')) } // TODO: Swap to suspense when available diff --git a/apps/expo/app/server/[id]/books/index.tsx b/apps/expo/app/server/[id]/books/index.tsx index 76aa15724a..9287d4d54d 100644 --- a/apps/expo/app/server/[id]/books/index.tsx +++ b/apps/expo/app/server/[id]/books/index.tsx @@ -16,6 +16,7 @@ import { useListSizing } from '~/components/listLayout' import RefreshControl from '~/components/RefreshControl' import { Button, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { BookFilterContext, createBookFilterStore, useInitialBookFilters } from '~/stores/filters' import { useBooksLayout } from '~/stores/layout' @@ -64,6 +65,7 @@ const statsQuery = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -154,16 +156,18 @@ export default function Screen() { refreshControl={} ListEmptyComponent={ {isFiltered && ( )} } diff --git a/apps/expo/app/server/[id]/books/search[q].tsx b/apps/expo/app/server/[id]/books/search[q].tsx index 1bf80cd7a1..51b2f36637 100644 --- a/apps/expo/app/server/[id]/books/search[q].tsx +++ b/apps/expo/app/server/[id]/books/search[q].tsx @@ -10,6 +10,7 @@ import BackLink from '~/components/BackLink' import { BookListItem } from '~/components/book' import { useGridItemSize } from '~/components/listLayout/grid/useGridItemSize' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookSearchScreen($filter: MediaFilterInput!, $pagination: Pagination!) { @@ -45,6 +46,7 @@ export const prefetchBookSearch = (sdk: Api, client: QueryClient, search: string } export default function Screen() { + const { t } = useTranslate() const { q: searchQuery } = useLocalSearchParams<{ q: string }>() const navigation = useNavigation() @@ -53,11 +55,11 @@ export default function Screen() { useLayoutEffect(() => { navigation.setOptions({ headerShown: true, - headerTitle: 'Search Results', + headerTitle: t('search.results.title'), headerBackButtonMenuEnabled: true, headerLeft: () => , }) - }, [navigation]) + }, [navigation, t]) const filter = useMemo( () => ({ diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/_layout.tsx b/apps/expo/app/server/[id]/clubs/[clubId]/_layout.tsx index c1ff08b034..513912fa69 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/_layout.tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/_layout.tsx @@ -6,6 +6,7 @@ import { Platform } from 'react-native' import BackLink from '~/components/BackLink' import { BookClubContext } from '~/components/bookClub/context' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' const clubContextQuery = graphql(` @@ -21,6 +22,7 @@ const clubContextQuery = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { clubId } = useLocalSearchParams<{ clubId: string }>() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) @@ -53,14 +55,14 @@ export default function Screen() { options={{ presentation: 'modal', headerShown: true, - title: 'Club Settings', + title: t('bookClub.clubSettings'), }} /> diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/archive/_layout.tsx b/apps/expo/app/server/[id]/clubs/[clubId]/archive/_layout.tsx index 7f85261735..9323c1e85d 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/archive/_layout.tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/archive/_layout.tsx @@ -2,9 +2,11 @@ import { Stack } from 'expo-router' import { Platform } from 'react-native' import BackLink from '~/components/BackLink' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' export default function Screen() { + const { t } = useTranslate() const animationEnabled = usePreferencesStore((state) => !state.reduceAnimations) return ( @@ -13,7 +15,7 @@ export default function Screen() { name="index" options={{ headerShown: true, - title: 'Past Discussions', + title: t('bookClub.pastDiscussions'), headerLeft: Platform.OS === 'android' ? undefined : () => , }} /> diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/archive/index.tsx b/apps/expo/app/server/[id]/clubs/[clubId]/archive/index.tsx index 8fd5c22c6d..781dca4d6c 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/archive/index.tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/archive/index.tsx @@ -12,6 +12,7 @@ import { PastBookGridItem } from '~/components/bookClub' import { usePastDiscussionSize } from '~/components/bookClub/usePastDiscussionSize' import ListEmpty from '~/components/ListEmpty' import RefreshControl from '~/components/RefreshControl' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookClubPastDiscussions($bookClubId: ID!) { @@ -30,6 +31,7 @@ const query = graphql(` type BookClubBook = BookClubPastDiscussionsQuery['previousBookClubDiscussions'][number]['book'] export default function Screen() { + const { t } = useTranslate() const { clubId } = useLocalSearchParams<{ clubId: string }>() const { data, refetch } = useSuspenseGraphQL(query, ['bookClubPastDiscussions', clubId], { @@ -101,7 +103,10 @@ export default function Screen() { contentContainerStyle={{ paddingHorizontal, paddingVertical: 16 }} ItemSeparatorComponent={() => } ListEmptyComponent={ - + } // TODO: Support both archive discussions without books and with books renderItem={({ item }) => diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/archive/past-book/[bookId].tsx b/apps/expo/app/server/[id]/clubs/[clubId]/archive/past-book/[bookId].tsx index afb0080a0f..58bb8b0484 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/archive/past-book/[bookId].tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/archive/past-book/[bookId].tsx @@ -10,6 +10,7 @@ import { useActiveServer } from '~/components/activeServer' import { DiscussionListItem } from '~/components/bookClub/discussion' import ListEmpty from '~/components/ListEmpty' import { Card } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookClubPastBookScreen($bookId: ID!) { @@ -27,6 +28,7 @@ const query = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { bookId } = useLocalSearchParams<{ bookId: string }>() const { activeServer: { id: serverID }, @@ -43,9 +45,9 @@ export default function Screen() { useEffect(() => { if (!bookName) return navigation.setOptions({ - title: `${bookName} - Archive`, + title: t('bookClub.archiveTitle', { book: bookName }), }) - }, [bookName, navigation]) + }, [bookName, navigation, t]) // TODO(book-club): FlashList most likely return ( @@ -53,7 +55,7 @@ export default function Screen() { {discussions.length > 0 && ( - + {discussions.map((discussion) => ( @@ -61,7 +63,7 @@ export default function Screen() { ))} )} - {discussions.length === 0 && } + {discussions.length === 0 && } diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/discussion/[roomId]/thread/[messageId].tsx b/apps/expo/app/server/[id]/clubs/[clubId]/discussion/[roomId]/thread/[messageId].tsx index 3a6cf08144..cea6dec627 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/discussion/[roomId]/thread/[messageId].tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/discussion/[roomId]/thread/[messageId].tsx @@ -9,6 +9,7 @@ import { KeyboardAvoidingView } from 'react-native-keyboard-controller' import { useBookClubContext } from '~/components/bookClub/context' import { DiscussionRoom, Message, type MessageData } from '~/components/bookClub/discussion' import type { EmojiSelection } from '~/components/emoji/types' +import { useTranslate } from '~/lib/hooks' const parentMessageQuery = graphql(` query ThreadParentMessage($id: ID!) { @@ -127,6 +128,7 @@ const discussionQuery = graphql(` `) export default function ThreadScreen() { + const { t } = useTranslate() const { roomId, messageId } = useLocalSearchParams<{ roomId: string messageId: string @@ -151,9 +153,9 @@ export default function ThreadScreen() { useLayoutEffect(() => { navigation.setOptions({ headerShown: true, - title: 'Thread', + title: t('bookClub.thread'), }) - }, [navigation]) + }, [navigation, t]) const { data: parentData } = useSuspenseGraphQL( parentMessageQuery, @@ -229,7 +231,7 @@ export default function ThreadScreen() { const threadHeader = useMemo(() => { if (!parentMessage) return undefined return ( - + () const router = useRouter() const { @@ -97,10 +99,13 @@ export default function Screen() { const renderProgression = () => { if (currentBookCompletedAt) { + const date = intlFormat(new Date(currentBookCompletedAt), { + month: 'long', + year: 'numeric', + }) return ( - Completed{' '} - {intlFormat(new Date(currentBookCompletedAt), { month: 'long', year: 'numeric' })} + {t('bookClub.completedAt', { date })} ) } else if (activeProgress) { @@ -110,18 +115,20 @@ export default function Screen() { if (decimal != null) { return ( - You are {decimal.toFixed(2)}% through the book! + {t('bookClub.progressThroughBook', { percentage: decimal.toFixed(2) })} ) } const startedAt = new Date(activeProgress.startedAt) return ( - Started {intlFormat(startedAt, { month: 'long', year: 'numeric' })} + {t('bookClub.startedAt', { + date: intlFormat(startedAt, { month: 'long', year: 'numeric' }), + })} ) } else { - return You have not started this book yet + return {t('bookClub.notStarted')} } } @@ -138,7 +145,7 @@ export default function Screen() { {club.moderators.length > 0 && ( - Moderated by + {t('bookClub.moderatedBy')} @@ -146,7 +153,7 @@ export default function Screen() { )} {club.pinnedDiscussions.length > 0 && ( - + {club.pinnedDiscussions.map((discussion) => ( @@ -179,7 +186,11 @@ export default function Screen() { } > - {currentBookCompletedAt ? 'See book' : activeProgress ? 'Continue' : 'Start'} + {currentBookCompletedAt + ? t('bookClub.seeBook') + : activeProgress + ? t('common.continue') + : t('bookClub.start')} @@ -188,9 +199,9 @@ export default function Screen() { )} {club.currentBook?.discussions.map((discussion) => ( diff --git a/apps/expo/app/server/[id]/clubs/[clubId]/settings.tsx b/apps/expo/app/server/[id]/clubs/[clubId]/settings.tsx index 6f84d8903a..f985ab78e6 100644 --- a/apps/expo/app/server/[id]/clubs/[clubId]/settings.tsx +++ b/apps/expo/app/server/[id]/clubs/[clubId]/settings.tsx @@ -5,6 +5,7 @@ import { ScrollView } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { Text } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query BookClubSettings($id: ID!) { @@ -47,6 +48,7 @@ const leaveClubMutation = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { clubId } = useLocalSearchParams<{ clubId: string }>() const { data } = useSuspenseGraphQL(query, ['bookClubById', clubId, 'settings'], { @@ -57,8 +59,8 @@ export default function Screen() { return ( - - TODO: Make me + + {t('bookClub.settingsNotImplemented')} ) diff --git a/apps/expo/app/server/[id]/files/[path].tsx b/apps/expo/app/server/[id]/files/[path].tsx index 050a2673df..01989bbd07 100644 --- a/apps/expo/app/server/[id]/files/[path].tsx +++ b/apps/expo/app/server/[id]/files/[path].tsx @@ -8,8 +8,10 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { FileExplorerGridItem } from '~/components/fileExplorer' import { Heading, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' export default function Screen() { + const { t } = useTranslate() const params = useLocalSearchParams<{ path: string friendlyName?: string @@ -18,7 +20,7 @@ export default function Screen() { const friendlyName = params.friendlyName const navigation = useNavigation() - const basename = rootPath?.split('/').filter(Boolean).pop() ?? 'Files' + const basename = rootPath?.split('/').filter(Boolean).pop() ?? t('common.files') useEffect(() => { navigation.setOptions({ headerTitle: friendlyName || basename, @@ -49,11 +51,11 @@ export default function Screen() { const render = () => { if (errorMessage) { return ( - + - Something went wrong + {t('errors.somethingWentWrong')} - {errorMessage} + {errorMessage} ) } else { diff --git a/apps/expo/app/server/[id]/libraries/[id].tsx b/apps/expo/app/server/[id]/libraries/[id].tsx index ab36600527..4944c8e64d 100644 --- a/apps/expo/app/server/[id]/libraries/[id].tsx +++ b/apps/expo/app/server/[id]/libraries/[id].tsx @@ -18,6 +18,7 @@ import RefreshControl from '~/components/RefreshControl' import SeriesListItem from '~/components/series/SeriesListItem' import { Button, FullScreenLoader, RefreshButton, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' import { createSeriesFilterStore, SeriesFilterContext } from '~/stores/filters' import { useSeriesLayout } from '~/stores/layout' @@ -67,6 +68,7 @@ const seriesQuery = graphql(` type Node = LibrarySeriesScreenQuery['series']['nodes'][number] export default function Screen() { + const { t } = useTranslate() const { id } = useLocalSearchParams<{ id: string }>() const { data: { libraryById: library }, @@ -189,11 +191,9 @@ export default function Screen() { ) : ( @@ -204,7 +204,7 @@ export default function Screen() { size="lg" onPress={() => resetFilters()} > - Clear Filters + {t('common.clearFilters')} )} @@ -215,7 +215,7 @@ export default function Screen() { onPress={() => handleRefetch()} isRefreshing={isRefetching} > - Refresh + {t('common.refresh')} } diff --git a/apps/expo/app/server/[id]/libraries/index.tsx b/apps/expo/app/server/[id]/libraries/index.tsx index d76a9bad99..ba35b8ff74 100644 --- a/apps/expo/app/server/[id]/libraries/index.tsx +++ b/apps/expo/app/server/[id]/libraries/index.tsx @@ -12,6 +12,7 @@ import ListEmpty from '~/components/ListEmpty' import RefreshControl from '~/components/RefreshControl' import { RefreshButton, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' const query = graphql(` query LibrariesScreen($pagination: Pagination) { @@ -33,6 +34,7 @@ const query = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -108,8 +110,8 @@ export default function Screen() { } ListEmptyComponent={ handleRefetch()} isRefreshing={isRefetching} > - Refresh + {t('common.refresh')} } diff --git a/apps/expo/app/server/[id]/series/[id].tsx b/apps/expo/app/server/[id]/series/[id].tsx index fb9624abcb..1a0a298ab2 100644 --- a/apps/expo/app/server/[id]/series/[id].tsx +++ b/apps/expo/app/server/[id]/series/[id].tsx @@ -20,6 +20,7 @@ import RefreshControl from '~/components/RefreshControl' import { SeriesOverviewSheet, usePrefetchSeriesOverview } from '~/components/series' import { Button, RefreshButton, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useDownloadSeries } from '~/lib/hooks/db/downloadSeries' import { useDynamicHeader } from '~/lib/hooks/useDynamicHeader' import { BookFilterContext, createBookFilterStore } from '~/stores/filters' @@ -72,6 +73,7 @@ const booksQuery = graphql(` type Node = SeriesBooksScreenQuery['media']['nodes'][number] export default function Screen() { + const { t } = useTranslate() const navigationState = useNavigationState((state) => state.routes) const { id } = useLocalSearchParams<{ id: string }>() const { @@ -197,7 +199,9 @@ export default function Screen() { } ListEmptyComponent={ {isFiltered && ( @@ -207,7 +211,7 @@ export default function Screen() { variant="secondary" onPress={() => resetFilters()} > - Clear Filters + {t('common.clearFilters')} )} handleRefetch()} isRefreshing={isRefetching} > - Refresh + {t('common.refresh')} } diff --git a/apps/expo/app/server/[id]/series/index.tsx b/apps/expo/app/server/[id]/series/index.tsx index 907909567f..84cdcffd3d 100644 --- a/apps/expo/app/server/[id]/series/index.tsx +++ b/apps/expo/app/server/[id]/series/index.tsx @@ -16,6 +16,7 @@ import { SeriesListHeader } from '~/components/series/listHeader' import SeriesListItem from '~/components/series/SeriesListItem' import { Button, FullScreenLoader, RefreshButton, Text } from '~/components/ui' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useSeriesFilterStore } from '~/stores/filters' import { useSeriesLayout } from '~/stores/layout' @@ -64,6 +65,7 @@ const statsQuery = graphql(` `) export default function Screen() { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -160,7 +162,9 @@ export default function Screen() { ) : ( {isFiltered && ( @@ -170,7 +174,7 @@ export default function Screen() { size="lg" onPress={() => resetFilters()} > - Clear Filters + {t('common.clearFilters')} )} @@ -181,7 +185,7 @@ export default function Screen() { onPress={() => handleRefetch()} isRefreshing={isRefetching} > - Refresh + {t('common.refresh')} } diff --git a/apps/expo/components/ServerAuthDialog.tsx b/apps/expo/components/ServerAuthDialog.tsx index 8306b16915..034d101056 100644 --- a/apps/expo/components/ServerAuthDialog.tsx +++ b/apps/expo/components/ServerAuthDialog.tsx @@ -11,6 +11,7 @@ import urlJoin from 'url-join' import { z } from 'zod' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { startOidcLogin } from '~/lib/sdk/auth' import { useUserStore } from '~/stores' @@ -24,6 +25,7 @@ type ServerAuthDialogProps = { } export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogProps) { + const { t } = useTranslate() const setUser = useUserStore((state) => state.setUser) const { activeServer } = useActiveServer() const oidcConfig = useOidcConfig() @@ -40,6 +42,10 @@ export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogPr const hasAuthSucceeded = useRef(false) const colors = useColors() + const schema = z.object({ + password: z.string().min(1, { message: t('auth.passwordRequired') }), + username: z.string().min(1, { message: t('auth.usernameRequired') }), + }) const { control, @@ -105,7 +111,7 @@ export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogPr }, [activeServer.url, setUser, onClose]) if (!isClaimed && !isCheckingClaimed) { - throw new Error('Not supported yet') + throw new Error(t('errors.notSupported')) } return ( @@ -135,12 +141,12 @@ export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogPr }} render={({ field: { onChange, onBlur, value } }) => ( ( - Password + + {t('common.password')} + - Login + {t('auth.login')} {oidcConfig?.enabled && ( <> - Or + {t('auth.or')} @@ -216,7 +224,7 @@ export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogPr roundness="full" variant="secondary" > - Login with OIDC + {t('auth.loginWithOidc')} )} @@ -227,8 +235,7 @@ export default function ServerAuthDialog({ isOpen, onClose }: ServerAuthDialogPr ) } -const schema = z.object({ - password: z.string().min(1, { message: 'Password must be at least 2 characters long' }), - username: z.string().min(1, { message: 'Username is required' }), -}) -type LoginSchema = z.infer +type LoginSchema = { + password: string + username: string +} diff --git a/apps/expo/components/Unimplemented.tsx b/apps/expo/components/Unimplemented.tsx index 8a8710ec1b..1b17bdf53f 100644 --- a/apps/expo/components/Unimplemented.tsx +++ b/apps/expo/components/Unimplemented.tsx @@ -1,39 +1,40 @@ import { useRouter } from 'expo-router' import { View } from 'react-native' +import { useTranslate } from '~/lib/hooks' + import Owl, { useOwlHeaderOffset } from './Owl' import { Button, Heading, Text } from './ui' -const DEFAULT_TEXT = 'This feature is not yet implemented. Please check back later!' - type Props = { message?: string } -export default function Unimplemented({ message = DEFAULT_TEXT }: Props) { +export default function Unimplemented({ message }: Props) { + const { t } = useTranslate() const emptyContainerStyle = useOwlHeaderOffset() const router = useRouter() return ( - - Coming Soon! + + {t('unimplemented.title')} - {message} + {message ?? t('unimplemented.description')} - + diff --git a/apps/expo/components/activeServer/home/ReadingNow.tsx b/apps/expo/components/activeServer/home/ReadingNow.tsx index fd360498f2..16b00d0c7c 100644 --- a/apps/expo/components/activeServer/home/ReadingNow.tsx +++ b/apps/expo/components/activeServer/home/ReadingNow.tsx @@ -396,7 +396,7 @@ function ReadingNowItem({ book }: ReadingNowItemProps) { > {data.readProgress?.updatedAt ? formatDistanceToNow(new Date(data.readProgress?.updatedAt), { addSuffix: true }) - : 'unknown time ago'} + : t('common.unknownTimeAgo')} diff --git a/apps/expo/components/activeServer/home/RecentlyAddedBooks.tsx b/apps/expo/components/activeServer/home/RecentlyAddedBooks.tsx index 4f6d5f5134..7628876b91 100644 --- a/apps/expo/components/activeServer/home/RecentlyAddedBooks.tsx +++ b/apps/expo/components/activeServer/home/RecentlyAddedBooks.tsx @@ -81,7 +81,7 @@ function RecentlyAddedBooks() { ItemSeparatorComponent={() => } ListEmptyComponent={ - {t('stumpServer.recentlyAddedBooks.emptyText)')} + {t('stumpServer.recentlyAddedBooks.emptyText')} } /> diff --git a/apps/expo/components/appSettings/preferences/AllowDownscaling.tsx b/apps/expo/components/appSettings/preferences/AllowDownscaling.tsx index 35f6b754d6..84d74a21d9 100644 --- a/apps/expo/components/appSettings/preferences/AllowDownscaling.tsx +++ b/apps/expo/components/appSettings/preferences/AllowDownscaling.tsx @@ -3,11 +3,13 @@ import { View } from 'react-native' import { useShallow } from 'zustand/react/shallow' import { Switch } from '~/components/ui' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import AppSettingsRow from '../AppSettingsRow' export default function AllowDownscaling() { + const { t } = useTranslate() const { allowDownscaling, patch } = usePreferencesStore( useShallow((state) => ({ allowDownscaling: state.allowDownscaling, @@ -18,10 +20,10 @@ export default function AllowDownscaling() { return ( patch({ allowDownscaling: !allowDownscaling })} > - + patch({ allowDownscaling: checked })} diff --git a/apps/expo/components/book/BookSearchItem.tsx b/apps/expo/components/book/BookSearchItem.tsx index df55969d4c..8973999e21 100644 --- a/apps/expo/components/book/BookSearchItem.tsx +++ b/apps/expo/components/book/BookSearchItem.tsx @@ -2,10 +2,9 @@ import { useSDK } from '@stump/client' import { formatBytes } from '@stump/client' import { FragmentType, graphql, useFragment } from '@stump/graphql' import { useRouter } from 'expo-router' -import pluralize from 'pluralize' import { Pressable, View } from 'react-native' -import { useDisplay } from '~/lib/hooks' +import { useDisplay, useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { useActiveServer } from '../activeServer' @@ -49,6 +48,7 @@ type Props = { } export default function BookSearchItem({ book }: Props) { + const { t } = useTranslate() const { sdk } = useSDK() const { activeServer: { id: serverID }, @@ -94,7 +94,8 @@ export default function BookSearchItem({ book }: Props) { {data.resolvedName} - {formatBytes(data.size)} • {data.pages} {pluralize('page', data.pages)} + {formatBytes(data.size)} • {data.pages}{' '} + {t(data.pages === 1 ? 'common.page' : 'common.pages')} diff --git a/apps/expo/components/book/BooksAfterCursor.tsx b/apps/expo/components/book/BooksAfterCursor.tsx index d0656cc3b1..8be16a7103 100644 --- a/apps/expo/components/book/BooksAfterCursor.tsx +++ b/apps/expo/components/book/BooksAfterCursor.tsx @@ -4,7 +4,7 @@ import { graphql } from '@stump/graphql' import { View } from 'react-native' import { ON_END_REACHED_THRESHOLD } from '~/lib/constants' -import { useListItemSize } from '~/lib/hooks' +import { useListItemSize, useTranslate } from '~/lib/hooks' import { useActiveServer } from '../activeServer' import { ListLabel } from '../ui' @@ -36,6 +36,7 @@ type Props = { } export function BooksAfterCursor({ cursor }: Props) { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -64,7 +65,7 @@ export function BooksAfterCursor({ cursor }: Props) { return ( - Up Next + {t('reader.nextUp')} } diff --git a/apps/expo/components/book/listHeader/SeriesBooksListHeader.tsx b/apps/expo/components/book/listHeader/SeriesBooksListHeader.tsx index ae2e0ca365..d43c98489c 100644 --- a/apps/expo/components/book/listHeader/SeriesBooksListHeader.tsx +++ b/apps/expo/components/book/listHeader/SeriesBooksListHeader.tsx @@ -9,6 +9,7 @@ import { useStumpServer } from '~/components/activeServer' import { useEntityListHeader } from '~/components/filter/EntityListHeader' import { ActionDef } from '~/components/filter/types' import { MiniEntityStatCards } from '~/components/stats' +import { useTranslate } from '~/lib/hooks' import { useBooksFilterMenu } from './BooksFilterMenu' import { useSeriesBooksSortAndDisplayMenu } from './SeriesBooksSortAndDisplayMenu' @@ -32,6 +33,7 @@ type Props = { } export function SeriesBooksListHeader({ seriesId, layoutKey, stats, additionalActions }: Props) { + const { t } = useTranslate() const client = useQueryClient() const { mutate: scanSeries } = useGraphQLMutation(scanMutation, { onSuccess: () => { @@ -52,7 +54,7 @@ export function SeriesBooksListHeader({ seriesId, layoutKey, stats, additionalAc const result: ActionDef[] = [ { key: 'overview', - label: 'Overview', + label: t('common.overview'), icon: { ios: 'info.circle', android: Info }, onPress: additionalActions.onShowOverview, }, @@ -61,7 +63,7 @@ export function SeriesBooksListHeader({ seriesId, layoutKey, stats, additionalAc if (checkPermission(UserPermission.ScanLibrary)) { result.push({ key: 'scan', - label: 'Scan Series', + label: t('entityActions.scanSeries'), icon: { ios: 'document.viewfinder', android: ScanLine }, onPress: () => scanSeries({ id: seriesId }), }) @@ -70,15 +72,15 @@ export function SeriesBooksListHeader({ seriesId, layoutKey, stats, additionalAc if (checkPermission(UserPermission.DownloadFile)) { result.push({ key: 'download', - label: 'Download Series', + label: t('entityActions.downloadSeries'), icon: { ios: 'arrow.down.circle', android: DownloadCloud }, onPress: () => { Alert.alert( - 'Download Series', - 'Are you sure you want to enqueue the download for this entire series?', + t('entityActions.downloadSeries'), + t('entityActions.downloadSeriesConfirmation'), [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Download', onPress: additionalActions.onDownloadSeries }, + { text: t('common.cancel'), style: 'cancel' }, + { text: t('common.download'), onPress: additionalActions.onDownloadSeries }, ], ) }, @@ -86,7 +88,7 @@ export function SeriesBooksListHeader({ seriesId, layoutKey, stats, additionalAc } return result - }, [additionalActions, checkPermission, scanSeries, seriesId]) + }, [additionalActions, checkPermission, scanSeries, seriesId, t]) const sortMenu = useSeriesBooksSortAndDisplayMenu({ layoutKey, diff --git a/apps/expo/components/book/overview/AndroidBookMenu.tsx b/apps/expo/components/book/overview/AndroidBookMenu.tsx index 17afb85853..58c5b63066 100644 --- a/apps/expo/components/book/overview/AndroidBookMenu.tsx +++ b/apps/expo/components/book/overview/AndroidBookMenu.tsx @@ -25,6 +25,7 @@ import { Text, } from '~/components/ui' import { cn } from '~/lib/utils' +import { useTranslate } from '~/lib/hooks' type Props = { book: BookMenuFragment @@ -47,6 +48,7 @@ export default function AndroidBookMenu({ deleteCurrentSession, deleteReadHistory, }: Props) { + const { t } = useTranslate() const router = useRouter() const insets = useSafeAreaInsets() const contentInsets = { @@ -63,7 +65,7 @@ export default function AndroidBookMenu({ return ( - diff --git a/apps/expo/components/book/reader/epub/controls/ColumnCount.tsx b/apps/expo/components/book/reader/epub/controls/ColumnCount.tsx index 12638df178..ce8073aeec 100644 --- a/apps/expo/components/book/reader/epub/controls/ColumnCount.tsx +++ b/apps/expo/components/book/reader/epub/controls/ColumnCount.tsx @@ -27,7 +27,7 @@ export default function ColumnCount() { } return ( - + store.setSettings({ verticalText: checked })} - accessibilityLabel="Toggle Vertical Text" + accessibilityLabel={t('reader.toggleVerticalText')} /> diff --git a/apps/expo/components/book/reader/epub/controls/ThemeSelect.tsx b/apps/expo/components/book/reader/epub/controls/ThemeSelect.tsx index a5a82e40f3..a886f65988 100644 --- a/apps/expo/components/book/reader/epub/controls/ThemeSelect.tsx +++ b/apps/expo/components/book/reader/epub/controls/ThemeSelect.tsx @@ -5,6 +5,7 @@ import * as ContextMenu from 'zeego/context-menu' import { useShallow } from 'zustand/react/shallow' import { Icon } from '~/components/ui/icon' +import { useTranslate } from '~/lib/hooks' import { IS_IOS_26_PLUS } from '~/lib/constants' import { useColorScheme } from '~/lib/useColorScheme' import { cn } from '~/lib/utils' @@ -61,6 +62,7 @@ type ThemePreviewButtonProps = { } const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreviewButtonProps) => { + const { t } = useTranslate() const { onSelect, deleteTheme, addTheme } = useEpubThemesStore( useShallow((store) => ({ onSelect: store.selectTheme, @@ -71,21 +73,21 @@ const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreview const openCustomizeTheme = useEpubSheetStore((state) => state.openCustomizeTheme) const handleDuplicate = useCallback(() => { - const newName = `${name} copy` + const newName = t('reader.themeCopyName', { name }) addTheme(newName, config) onSelect(newName) - }, [name, config, addTheme, onSelect]) + }, [name, config, addTheme, onSelect, t]) const handleDelete = useCallback(() => { if (themeNames.length <= 1) { - Alert.alert('Error', 'You must have at least one theme') + Alert.alert(t('common.error'), t('reader.minimumThemeError')) return } - Alert.alert('Delete Theme', `Are you sure you want to delete '${name}'?`, [ - { text: 'Cancel', style: 'cancel' }, + Alert.alert(t('reader.deleteTheme'), t('reader.deleteThemeConfirmation', { name }), [ + { text: t('common.cancel'), style: 'cancel' }, { - text: 'Delete', + text: t('common.delete'), style: 'destructive', onPress: () => { if (isActive) { @@ -101,7 +103,7 @@ const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreview }, }, ]) - }, [themeNames, name, deleteTheme, isActive, onSelect]) + }, [themeNames, name, deleteTheme, isActive, onSelect, t]) return ( @@ -110,7 +112,7 @@ const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreview @@ -118,7 +120,7 @@ const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreview openCustomizeTheme({ mode: 'edit', name })}> - Edit Theme + {t('reader.editTheme')} @@ -126,12 +128,12 @@ const ThemePreviewButton = ({ name, config, isActive, themeNames }: ThemePreview ios={{ name: 'plus.square.on.square' }} androidIconName="ic_menu_add" /> - Duplicate + {t('reader.duplicateTheme')} - Delete + {t('common.delete')} @@ -142,7 +144,7 @@ const NewThemeButton = () => { const openCustomizeTheme = useEpubSheetStore((state) => state.openCustomizeTheme) return ( openCustomizeTheme({ mode: 'create' })}> - + diff --git a/apps/expo/components/book/reader/epub/controls/customTheme/CustomizeTheme.tsx b/apps/expo/components/book/reader/epub/controls/customTheme/CustomizeTheme.tsx index 23a55f39e2..384271cec2 100644 --- a/apps/expo/components/book/reader/epub/controls/customTheme/CustomizeTheme.tsx +++ b/apps/expo/components/book/reader/epub/controls/customTheme/CustomizeTheme.tsx @@ -117,7 +117,7 @@ export default function CustomizeTheme({ onCancel, mode = 'edit', theme: namedTh const trimmedName = name.trim() if (!trimmedName) { - Alert.alert('Error', 'Please enter a theme name') + Alert.alert(t('common.error'), t('reader.themeNameRequired')) return } @@ -126,7 +126,7 @@ export default function CustomizeTheme({ onCancel, mode = 'edit', theme: namedTh !customTheme.colors?.foreground || !customTheme.colors?.highlight ) { - Alert.alert('Error', 'Theme colors are required') + Alert.alert(t('common.error'), t('reader.themeColorsRequired')) return } @@ -134,7 +134,7 @@ export default function CustomizeTheme({ onCancel, mode = 'edit', theme: namedTh if (isCreateMode) { if (themes[trimmedName]) { - Alert.alert('Error', 'A theme with this name already exists') + Alert.alert(t('common.error'), t('reader.themeNameExists')) return } addTheme(trimmedName, customTheme) @@ -153,6 +153,7 @@ export default function CustomizeTheme({ onCancel, mode = 'edit', theme: namedTh isCreateMode, addTheme, selectTheme, + t, onCancel, ]) diff --git a/apps/expo/components/book/reader/epub/controls/customTheme/colorPickerRow/ColorPickerRow.tsx b/apps/expo/components/book/reader/epub/controls/customTheme/colorPickerRow/ColorPickerRow.tsx index b723393354..f4e9618b82 100644 --- a/apps/expo/components/book/reader/epub/controls/customTheme/colorPickerRow/ColorPickerRow.tsx +++ b/apps/expo/components/book/reader/epub/controls/customTheme/colorPickerRow/ColorPickerRow.tsx @@ -6,6 +6,7 @@ import ColorPicker, { HueSlider, Panel1 } from 'reanimated-color-picker' import { SheetBackDetection } from '~/components/SheetBackDetection' import { Button, Text } from '~/components/ui' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' type Props = { label: string @@ -14,6 +15,7 @@ type Props = { } export function ColorPickerRow({ label, value, onChange }: Props) { + const { t } = useTranslate() const sheetRef = useRef(null) const [tempColor, setTempColor] = useState(value) const colors = useColors() @@ -68,17 +70,17 @@ export function ColorPickerRow({ label, value, onChange }: Props) { - Hue + {t('reader.hue')} diff --git a/apps/expo/components/book/reader/image/Footer.tsx b/apps/expo/components/book/reader/image/Footer.tsx index ece0d98ecf..207213555c 100644 --- a/apps/expo/components/book/reader/image/Footer.tsx +++ b/apps/expo/components/book/reader/image/Footer.tsx @@ -11,7 +11,7 @@ import TImage from 'react-native-turbo-image' import { getThumbnailResizeProps, TurboImage } from '~/components/image' import { Progress, Text } from '~/components/ui' import { useColors } from '~/lib/constants' -import { useDisplay, usePrevious } from '~/lib/hooks' +import { useDisplay, usePrevious, useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { usePreferencesStore, useReaderStore } from '~/stores' import { useBookPreferences } from '~/stores/reader' @@ -22,6 +22,7 @@ import { useImageBasedReader } from './context' const SIZE_MODIFIER = 1.5 export default function Footer() { + const { t } = useTranslate() const { isTablet, width } = useDisplay() const { book, @@ -556,13 +557,15 @@ export default function Footer() { > {trackElapsedTime && ( - Reading time: {formattedReadTime} + + {t('reader.readingTime', { time: formattedReadTime })} + )} - Page {currentPage} of {book.pages} + {t('reader.pageOf', { current: currentPage, total: book.pages })} diff --git a/apps/expo/components/book/reader/image/NextUpOverlay.tsx b/apps/expo/components/book/reader/image/NextUpOverlay.tsx index 81d1ad41df..a905732750 100644 --- a/apps/expo/components/book/reader/image/NextUpOverlay.tsx +++ b/apps/expo/components/book/reader/image/NextUpOverlay.tsx @@ -14,7 +14,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import { ThumbnailImage } from '~/components/image' import { Button, Heading, Icon, Label, Text } from '~/components/ui' import { COLORS } from '~/lib/constants' -import { useDisplay } from '~/lib/hooks' +import { useDisplay, useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { usePreferencesStore } from '~/stores' @@ -27,6 +27,7 @@ type Props = { } export default function NextUpOverlay({ isVisible, book, onClose }: Props) { + const { t } = useTranslate() const { sdk } = useSDK() const { serverId } = useImageBasedReader() @@ -106,7 +107,7 @@ export default function NextUpOverlay({ isVisible, book, onClose }: Props) { - + {book.name} @@ -130,8 +131,8 @@ export default function NextUpOverlay({ isVisible, book, onClose }: Props) { width: size + 16, }} > - diff --git a/apps/expo/components/book/reader/pdf/PdfReader.tsx b/apps/expo/components/book/reader/pdf/PdfReader.tsx index 53a8fd82cd..d72af0a5ff 100644 --- a/apps/expo/components/book/reader/pdf/PdfReader.tsx +++ b/apps/expo/components/book/reader/pdf/PdfReader.tsx @@ -5,7 +5,7 @@ import { View } from 'react-native' import { useShallow } from 'zustand/react/shallow' import { FullScreenLoader } from '~/components/ui' -import { useDownload } from '~/lib/hooks' +import { useDownload, useTranslate } from '~/lib/hooks' import { intoPDFReadiumLocator, PDFBookLoadedEvent, @@ -59,6 +59,7 @@ type Props = { // - settings sheet, a chunk of existing settings do not apply to PDF export default function PdfReader({ book, initialPage, onPageChanged, ...ctx }: Props) { + const { t } = useTranslate() const { downloadImmediate } = useDownload({ serverId: ctx.serverId }) const [localUri, setLocalUri] = useState(() => ctx.offlineUri || null) @@ -227,7 +228,7 @@ export default function PdfReader({ book, initialPage, onPageChanged, ...ctx }: [controlsVisible, setControlsVisible], ) - if (isDownloading) return + if (isDownloading) return if (!localUri) return null diff --git a/apps/expo/components/bookClub/AddBookOptionsSheet.tsx b/apps/expo/components/bookClub/AddBookOptionsSheet.tsx index d351dcb97a..45ff24c08f 100644 --- a/apps/expo/components/bookClub/AddBookOptionsSheet.tsx +++ b/apps/expo/components/bookClub/AddBookOptionsSheet.tsx @@ -7,6 +7,7 @@ import { Platform, View } from 'react-native' import { Pressable } from 'react-native-gesture-handler' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { Icon, Text } from '../ui' import { AddBookSheet, type AddBookSheetRef, usePrefetchAddBookSheet } from './AddBookSheet' @@ -29,6 +30,7 @@ type Props = { export const AddBookOptionsSheet = forwardRef( ({ onAddBook }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) const addSheetRef = useRef(null) const manualSheetRef = useRef(null) @@ -70,25 +72,25 @@ export const AddBookOptionsSheet = forwardRef( onDidPresent={prefetchAddBookSheet} > - Add a book + {t('bookClub.addBook')} handleOptionPress('search')} /> handleOptionPress('manual')} /> handleOptionPress('suggestions')} /> @@ -116,7 +118,7 @@ type OptionRowProps = { function OptionRow({ label, description, icon, onPress }: OptionRowProps) { return ( - + {optionIcons[icon]} diff --git a/apps/expo/components/bookClub/AddBookSheet.tsx b/apps/expo/components/bookClub/AddBookSheet.tsx index 4393eb8de8..0023de1fc0 100644 --- a/apps/expo/components/bookClub/AddBookSheet.tsx +++ b/apps/expo/components/bookClub/AddBookSheet.tsx @@ -6,6 +6,7 @@ import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IS_IOS_26_PLUS, ON_END_REACHED_THRESHOLD, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useActiveServer } from '../activeServer' import BookListItem from '../book/BookListItem' @@ -66,6 +67,7 @@ type Props = { } export const AddBookSheet = forwardRef(({ onAddBook }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) const previewSheetRef = useRef(null) @@ -171,20 +173,26 @@ export const AddBookSheet = forwardRef(({ onAddBook }, r contentInsetAdjustmentBehavior="automatic" ListHeaderComponentStyle={{ paddingBottom: 16 }} ListHeaderComponent={ - + } ListEmptyComponent={ {isFiltered && ( )} } diff --git a/apps/expo/components/bookClub/BookClubCard.tsx b/apps/expo/components/bookClub/BookClubCard.tsx index d26bd3ab96..9e23cd2ff2 100644 --- a/apps/expo/components/bookClub/BookClubCard.tsx +++ b/apps/expo/components/bookClub/BookClubCard.tsx @@ -1,9 +1,10 @@ import { useSDK } from '@stump/client' import { FragmentType, graphql, useFragment } from '@stump/graphql' import { useRouter } from 'expo-router' -import pluralize from 'pluralize' import { Pressable, Text, View } from 'react-native' +import { useTranslate } from '~/lib/hooks' + import { useActiveServer } from '../activeServer' import { ThumbnailImage } from '../image' import { AvatarStack, Card, Heading } from '../ui' @@ -53,6 +54,7 @@ type Props = { } export function BookClubCard({ club }: Props) { + const { t } = useTranslate() const data = useFragment(fragment, club) const router = useRouter() const { @@ -80,7 +82,7 @@ export function BookClubCard({ club }: Props) { className="w-full" > - + - + {data.name} {data.description && ( @@ -105,7 +107,7 @@ export function BookClubCard({ club }: Props) { )} - + - {pluralize('member', data.membersCount, true)} + {t('bookClub.memberCount', { count: data.membersCount })} diff --git a/apps/expo/components/bookClub/CurrentBookCard.tsx b/apps/expo/components/bookClub/CurrentBookCard.tsx index 16ef4ef022..b4decfb9e4 100644 --- a/apps/expo/components/bookClub/CurrentBookCard.tsx +++ b/apps/expo/components/bookClub/CurrentBookCard.tsx @@ -17,6 +17,7 @@ import LinearGradient from 'react-native-linear-gradient' import { toast } from 'sonner-native' import { useColorScheme } from '~/lib/useColorScheme' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { ThumbnailImage } from '../image' @@ -77,6 +78,7 @@ type Props = { } export function CurrentBookCard({ data }: Props) { + const { t } = useTranslate() const { clubId, checkRole } = useBookClubContext() const queryClient = useQueryClient() @@ -143,8 +145,8 @@ export function CurrentBookCard({ data }: Props) { }, onError: (error) => { console.error('Failed to add book to club', error) - toast.error('Failed to add book to club', { - description: error instanceof Error ? error.message : 'An unknown error occurred', + toast.error(t('bookClub.addBookFailed'), { + description: error instanceof Error ? error.message : t('errors.unknown'), }) }, }) @@ -160,32 +162,34 @@ export function CurrentBookCard({ data }: Props) { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bookClubById', clubId] }) queryClient.invalidateQueries({ queryKey: ['bookClubContext', clubId] }) - toast.success('Book archived', { - description: 'The current book has been archived', + toast.success(t('bookClub.bookArchived'), { + description: t('bookClub.bookArchivedDescription'), }) }, onError: (error) => { console.error('Failed to archive book', error) - toast.error('Failed to archive book', { - description: error instanceof Error ? error.message : 'An unknown error occurred', + toast.error(t('bookClub.archiveBookFailed'), { + description: error instanceof Error ? error.message : t('errors.unknown'), }) }, }) const confirmArchiveBook = useCallback(() => { Alert.alert( - 'Archive book', - `Are you sure you are ready to archive ${book?.title ? `'${book?.title}'` : 'the current book'}?`, + t('bookClub.archiveBook'), + t('bookClub.archiveBookConfirmation', { + book: book?.title ? `'${book.title}'` : t('common.thisBook'), + }), [ - { text: 'Cancel', style: 'cancel' }, + { text: t('common.cancel'), style: 'cancel' }, { - text: 'Archive', + text: t('bookClub.archiveAction'), style: 'destructive', onPress: () => archiveBook({ bookClubBookId: book?.id || '' }), }, ], ) - }, [archiveBook, book]) + }, [archiveBook, book, t]) const isModerator = checkRole(BookClubMemberRole.Moderator) @@ -200,7 +204,7 @@ export function CurrentBookCard({ data }: Props) { } style={{ flexGrow: 1 }} > - + {backgroundGradient && ( )} - + {isModerator && !isEmpty && ( - + - + {EditIcon} - + {ArchiveIcon} @@ -273,16 +277,16 @@ export function CurrentBookCard({ data }: Props) { )} {isModerator && isEmpty && ( - - + + {PlusIcon} )} - - - {isEmpty ? 'Add a book' : 'Currently reading'} + + + {isEmpty ? t('bookClub.addBook') : t('bookClub.currentlyReading')} diff --git a/apps/expo/components/bookClub/CurrentBookSheet.tsx b/apps/expo/components/bookClub/CurrentBookSheet.tsx index cbe4657c3a..fa8926653d 100644 --- a/apps/expo/components/bookClub/CurrentBookSheet.tsx +++ b/apps/expo/components/bookClub/CurrentBookSheet.tsx @@ -10,6 +10,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import TImage from 'react-native-turbo-image' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { useActiveServer } from '../activeServer' @@ -29,6 +30,7 @@ type Props = { } export const CurrentBookSheet = forwardRef(({ book }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) useImperativeHandle(ref, () => ({ @@ -140,7 +142,7 @@ export const CurrentBookSheet = forwardRef(({ book } onPress={onGoToBook} disabled={!book.entity?.id && !book.url} > - Go to Book + {t('bookClub.goToBook')} diff --git a/apps/expo/components/bookClub/ManualBookEntrySheet.tsx b/apps/expo/components/bookClub/ManualBookEntrySheet.tsx index de69bde9bb..1f45e4516a 100644 --- a/apps/expo/components/bookClub/ManualBookEntrySheet.tsx +++ b/apps/expo/components/bookClub/ManualBookEntrySheet.tsx @@ -1,24 +1,28 @@ import { zodResolver } from '@hookform/resolvers/zod' import { TrueSheet } from '@lodev09/react-native-true-sheet' import { BookClubBookInput } from '@stump/graphql' -import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react' +import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react' import { Controller, useForm, useFormState } from 'react-hook-form' import { ScrollView, View } from 'react-native' import { z } from 'zod' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { SheetBackDetection } from '../SheetBackDetection' import { Input, SheetHeader } from '../ui' -const schema = z.object({ - title: z.string().min(1, { message: 'Title is required' }), - author: z.string().min(1, { message: 'Author is required' }), - url: z.string().optional(), - imageUrl: z.string().optional(), -}) +type Translate = ReturnType['t'] -type ManualBookEntrySchema = z.infer +const createSchema = (t: Translate) => + z.object({ + title: z.string().min(1, { message: t('bookClub.titleRequired') }), + author: z.string().min(1, { message: t('bookClub.authorRequired') }), + url: z.string().optional(), + imageUrl: z.string().optional(), + }) + +type ManualBookEntrySchema = z.infer> export type ManualBookEntrySheetRef = { open: () => void @@ -31,6 +35,8 @@ type Props = { export const ManualBookEntrySheet = forwardRef( ({ onAddBook }, ref) => { + const { t } = useTranslate() + const schema = useMemo(() => createSchema(t), [t]) const sheetRef = useRef(null) const colors = useColors() @@ -89,7 +95,7 @@ export const ManualBookEntrySheet = forwardRef( onDidDismiss={handleDismiss} header={ sheetRef.current?.dismiss()} onSubmit={handleSubmit(onSubmit)} /> @@ -105,8 +111,8 @@ export const ManualBookEntrySheet = forwardRef( name="title" render={({ field: { onChange, onBlur, value } }) => ( ( name="author" render={({ field: { onChange, onBlur, value } }) => ( ( name="url" render={({ field: { onChange, onBlur, value } }) => ( ( name="imageUrl" render={({ field: { onChange, onBlur, value } }) => ( + {moderator.avatarUrl && } - + {getFallback(moderator.displayName)} diff --git a/apps/expo/components/bookClub/PastBookGridItem.tsx b/apps/expo/components/bookClub/PastBookGridItem.tsx index a70be0542d..50c4339dfa 100644 --- a/apps/expo/components/bookClub/PastBookGridItem.tsx +++ b/apps/expo/components/bookClub/PastBookGridItem.tsx @@ -5,6 +5,7 @@ import { Easing, Pressable, View } from 'react-native' import { easeGradient } from 'react-native-easing-gradient' import { cn } from '~/lib/utils' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { useActiveServer } from '../activeServer' @@ -44,6 +45,7 @@ type Props = { } export function PastBookGridItem({ data }: Props) { + const { t } = useTranslate() const book = useFragment(fragment, data) const { activeServer: { id: serverID }, @@ -64,7 +66,7 @@ export function PastBookGridItem({ data }: Props) { }) const thumbnailUrl = book.entity?.thumbnail.url || book.imageUrl || undefined - const title = book.entity?.resolvedName || book.title || 'Unknown' + const title = book.entity?.resolvedName || book.title || t('common.unknown') const router = useRouter() @@ -75,7 +77,7 @@ export function PastBookGridItem({ data }: Props) { } > {({ pressed }) => ( - + - + {backgroundGradient && ( + - - No past discussions + + {t('bookClub.noPastDiscussions')} )} diff --git a/apps/expo/components/bookClub/PreviewBookSheet.tsx b/apps/expo/components/bookClub/PreviewBookSheet.tsx index ddd1141b67..bbb5d3c25b 100644 --- a/apps/expo/components/bookClub/PreviewBookSheet.tsx +++ b/apps/expo/components/bookClub/PreviewBookSheet.tsx @@ -8,6 +8,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context' import TImage from 'react-native-turbo-image' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { useOverviewAnimations } from '../book/overview' @@ -110,6 +111,7 @@ type BookContentProps = { } & Pick function BookContent({ book, onConfirmAddBook }: BookContentProps) { + const { t } = useTranslate() const { sdk } = useSDK() const thumbnailRatio = usePreferencesStore((store) => store.thumbnailRatio) @@ -170,7 +172,7 @@ function BookContent({ book, onConfirmAddBook }: BookContentProps) { diff --git a/apps/expo/components/bookClub/SuggestionsPickerSheet.tsx b/apps/expo/components/bookClub/SuggestionsPickerSheet.tsx index 51039b0b3e..0a7e042d9b 100644 --- a/apps/expo/components/bookClub/SuggestionsPickerSheet.tsx +++ b/apps/expo/components/bookClub/SuggestionsPickerSheet.tsx @@ -14,6 +14,7 @@ import { Pressable } from 'react-native-gesture-handler' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import ListEmpty from '../ListEmpty' import { SheetBackDetection } from '../SheetBackDetection' @@ -51,6 +52,7 @@ type Suggestion = SuggestionsPickerSheetQuery['bookClubSuggestions'][number] export const SuggestionsPickerSheet = forwardRef( ({ onAddBook }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) const { clubId } = useBookClubContext() @@ -107,7 +109,10 @@ export const SuggestionsPickerSheet = forwardRef sheetRef.current?.dismiss()} /> + sheetRef.current?.dismiss()} + /> } onDidPresent={() => setIsOpen(true)} onDidDismiss={() => setIsOpen(false)} @@ -119,7 +124,7 @@ export const SuggestionsPickerSheet = forwardRef} + ListEmptyComponent={} ItemSeparatorComponent={() => } /> @@ -139,30 +144,33 @@ type SuggestionRowProps = { // TODO(book-club): MAke not ugly, just a mock at this point really function SuggestionRow({ suggestion, onSelect }: SuggestionRowProps) { - const suggestedByName = suggestion.suggestedBy?.user?.username ?? 'Unknown' + const { t } = useTranslate() + const suggestedByName = suggestion.suggestedBy?.user?.username ?? t('common.unknown') return ( - - + + - {suggestion.title || 'Untitled'} + {suggestion.title || t('common.unknownTitle')} {suggestion.author && ( {suggestion.author} )} - Suggested by {suggestedByName} + + {t('bookClub.suggestedBy', { name: suggestedByName })} + {suggestion.notes && ( “{suggestion.notes}” diff --git a/apps/expo/components/bookClub/discussion/DiscussionRoom.tsx b/apps/expo/components/bookClub/discussion/DiscussionRoom.tsx index a509ea3029..77bdfabc05 100644 --- a/apps/expo/components/bookClub/discussion/DiscussionRoom.tsx +++ b/apps/expo/components/bookClub/discussion/DiscussionRoom.tsx @@ -3,6 +3,7 @@ import { useCallback, useRef, useState } from 'react' import { View } from 'react-native' import type { EmojiSelection } from '~/components/emoji/types' +import { useTranslate } from '~/lib/hooks' import Message, { type MessageData } from './Message' import { MessageActionSheet, type MessageActionSheetRef } from './MessageActionSheet' @@ -36,6 +37,7 @@ export default function DiscussionRoom({ listHeader, parentMessageId, }: DiscussionRoomProps) { + const { t } = useTranslate() const actionSheetRef = useRef(null) const [replyingTo, setReplyingTo] = useState(null) @@ -88,7 +90,7 @@ export default function DiscussionRoom({ onSend={handleSend} isSending={isSending} isLocked={isLocked} - placeholder={`Message #${name}...`} + placeholder={t('bookClub.messageRoomPlaceholder', { name })} parentMessageId={parentMessageId} replyingTo={replyingTo} onCancelReply={() => setReplyingTo(null)} diff --git a/apps/expo/components/bookClub/discussion/Message.tsx b/apps/expo/components/bookClub/discussion/Message.tsx index 57ecc093c6..ef3a66ddb8 100644 --- a/apps/expo/components/bookClub/discussion/Message.tsx +++ b/apps/expo/components/bookClub/discussion/Message.tsx @@ -8,6 +8,7 @@ import { Image, Pressable, View } from 'react-native' import type { EmojiSelection } from '~/components/emoji/types' import { Avatar, AvatarFallback, AvatarImage, Icon, Text } from '~/components/ui' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import MessageReplyPreview from './MessageReplyPreview' @@ -75,10 +76,11 @@ function Message({ onThreadPress, onToggleReaction, }: MessageProps) { + const { t } = useTranslate() const { sdk } = useSDK() const isDeleted = !!message.deletedAt - const displayName = message.member?.displayName || message.member?.username || 'Unknown' + const displayName = message.member?.displayName || message.member?.username || t('common.unknown') const threadChildrenCount = message.threadChildrenCount ?? 0 const reactions = message.reactions ?? [] const showReplyPreview = !!message.replyTo && !isThreadHeader @@ -87,10 +89,10 @@ function Message({ if (isDeleted) { return ( - + - - This message was deleted + + {t('bookClub.messageDeleted')} ) @@ -106,7 +108,7 @@ function Message({ {showReplyPreview && } @@ -125,15 +127,15 @@ function Message({ - - + + {displayName} {formatTimestamp(message.timestamp)} {message.editedAt && ( - (edited) + {t('bookClub.edited')} )} {message.isPinnedMessage && } @@ -141,7 +143,7 @@ function Message({ {message.content} - + {reactions.map((reaction) => { const key = reaction.emoji ?? `custom:${reaction.customEmojiId}` @@ -166,7 +168,7 @@ function Message({ 0 && !isThreadHeader && ( onThreadPress?.(message)} > - {threadChildrenCount} {threadChildrenCount === 1 ? 'reply' : 'replies'} + {t( + threadChildrenCount === 1 + ? 'bookClub.replyCountOne' + : 'bookClub.replyCountOther', + { count: threadChildrenCount }, + )} )} diff --git a/apps/expo/components/bookClub/discussion/MessageActionSheet.tsx b/apps/expo/components/bookClub/discussion/MessageActionSheet.tsx index bf55652766..72e65cdbed 100644 --- a/apps/expo/components/bookClub/discussion/MessageActionSheet.tsx +++ b/apps/expo/components/bookClub/discussion/MessageActionSheet.tsx @@ -7,6 +7,7 @@ import { Pressable } from 'react-native-gesture-handler' import { Divider } from '~/components/Divider' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { EmojiPickerSheet, type EmojiPickerSheetRef } from '../../emoji/EmojiPickerSheet' @@ -33,6 +34,7 @@ type Props = { export const MessageActionSheet = forwardRef( ({ quickEmojis = QUICK_EMOJIS, onReply, onThreadPress, onToggleReaction, onDelete }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) const emojiPickerRef = useRef(null) @@ -128,13 +130,18 @@ export const MessageActionSheet = forwardRef( {/* TODO: Support message editing */} - + {onReply && ( )} @@ -144,8 +151,8 @@ export const MessageActionSheet = forwardRef( // TODO: This icon was the one that made me not use native, although I'd prefer all native icons here. // I'm kinda surprised sf doens't have yarn or spool or something thread related icon={Spool} - label="Thread" - description="Start a thread" + label={t('bookClub.thread')} + description={t('bookClub.startThread')} onPress={handleThread} /> )} @@ -153,8 +160,8 @@ export const MessageActionSheet = forwardRef( {canDelete && onDelete && ( @@ -184,7 +191,7 @@ function ActionRow({ icon, label, description, onPress, disabled, destructive }: (null) + const resolvedPlaceholder = placeholder ?? t('bookClub.messagePlaceholder') const [text, setText] = useState('') const [keyboardVisible, setKeyboardVisible] = useState(false) @@ -67,12 +70,12 @@ export default function MessageComposer({ if (isLocked) { return ( - This discussion is locked. + {t('bookClub.discussionLocked')} ) @@ -81,11 +84,15 @@ export default function MessageComposer({ return ( {replyingTo && ( - + - Replying to{' '} - {replyingTo.member?.displayName || replyingTo.member?.username || 'Unknown'} + {t('bookClub.replyingTo', { + name: + replyingTo.member?.displayName || + replyingTo.member?.username || + t('common.unknown'), + })} {replyingTo.content} @@ -97,13 +104,13 @@ export default function MessageComposer({ )} + {/* 16px row pad + 16px avatar center - 2px stroke + 4px extra for spacing and line shenanigans */} - + @@ -45,14 +47,14 @@ export default function MessageReplyPreview({ replyTo }: Props) { /> )} - {getSenderInitials(replyTo.member)} + {getSenderInitials(replyTo.member)} {replyName} - + {replyTo.content} diff --git a/apps/expo/components/downloadQueue/FailedDownloadItem.tsx b/apps/expo/components/downloadQueue/FailedDownloadItem.tsx index 31f43950db..80048bb2d4 100644 --- a/apps/expo/components/downloadQueue/FailedDownloadItem.tsx +++ b/apps/expo/components/downloadQueue/FailedDownloadItem.tsx @@ -2,7 +2,7 @@ import { AlertCircle, RefreshCw, X } from 'lucide-react-native' import { Pressable, View } from 'react-native' import { downloadQueueMetadata } from '~/db' -import { useDownloadQueue } from '~/lib/hooks' +import { useDownloadQueue, useTranslate } from '~/lib/hooks' import { Card, Icon, Text } from '../ui' @@ -13,28 +13,29 @@ type Props = { } export default function FailedDownloadItem({ item, onRetry, onDismiss }: Props) { + const { t } = useTranslate() return ( - + {downloadQueueMetadata.safeParse(item.metadata).data?.bookName || item.filename} - {item.failureReason || 'Unknown error'} + {item.failureReason || t('errors.unknown')} - + onRetry(item.id)} - className="rounded-full bg-white/75 p-2 active:opacity-70 dark:bg-black/40" + className="bg-white/75 p-2 dark:bg-black/40 rounded-full active:opacity-70" > onDismiss(item.id)} - className="rounded-full bg-white/75 p-2 active:opacity-70 dark:bg-black/40" + className="bg-white/75 p-2 dark:bg-black/40 rounded-full active:opacity-70" > diff --git a/apps/expo/components/emoji/EmojiPickerSheet.tsx b/apps/expo/components/emoji/EmojiPickerSheet.tsx index 458eae914d..65c16cecf0 100644 --- a/apps/expo/components/emoji/EmojiPickerSheet.tsx +++ b/apps/expo/components/emoji/EmojiPickerSheet.tsx @@ -5,7 +5,7 @@ import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'reac import { Image, Pressable, View } from 'react-native' import { IS_IOS_26_PLUS, useColors } from '~/lib/constants' -import { useDisplay } from '~/lib/hooks' +import { useDisplay, useTranslate } from '~/lib/hooks' import { Input, SheetHeader, Text } from '../ui' import type { Emoji, EmojiSelection } from './types' @@ -35,17 +35,16 @@ const CATEGORY_ORDER = [ 'flags', ] as const -// TODO(localization): Add translation strings instead -const LABELS: Record<(typeof CATEGORY_ORDER)[number], string> = { - 'smileys & emotion': 'Smileys & Emotion', - 'people & body': 'People & Body', - 'animals & nature': 'Animals & Nature', - 'food & drink': 'Food & Drink', - 'travel & places': 'Travel & Places', - activities: 'Activities', - objects: 'Objects', - symbols: 'Symbols', - flags: 'Flags', +const CATEGORY_LOCALE_KEYS: Record<(typeof CATEGORY_ORDER)[number], string> = { + 'smileys & emotion': 'smileysAndEmotion', + 'people & body': 'peopleAndBody', + 'animals & nature': 'animalsAndNature', + 'food & drink': 'foodAndDrink', + 'travel & places': 'travelAndPlaces', + activities: 'activities', + objects: 'objects', + symbols: 'symbols', + flags: 'flags', } type EmojiSection = { @@ -87,6 +86,7 @@ const emojiMatchesQuery = (emoji: Emoji, queryTokens: string[]) => { } export const EmojiPickerSheet = forwardRef(({ onEmojiSelect }, ref) => { + const { t } = useTranslate() const sheetRef = useRef(null) const colors = useColors() const emojisByCategory = useEmojis() @@ -134,7 +134,7 @@ export const EmojiPickerSheet = forwardRef(({ onEmoj ) if (serverEmojis.length) { nextSections.push({ - title: 'Server', + title: t('emojiPicker.server'), emojis: serverEmojis, }) } @@ -156,13 +156,18 @@ export const EmojiPickerSheet = forwardRef(({ onEmoj if (!categoryEmojis?.length) continue nextSections.push({ - title: category, + title: + category in CATEGORY_LOCALE_KEYS + ? t( + `emojiPicker.categories.${CATEGORY_LOCALE_KEYS[category as keyof typeof CATEGORY_LOCALE_KEYS]}`, + ) + : category, emojis: categoryEmojis, }) } return nextSections - }, [emojisByCategory, queryTokens]) + }, [emojisByCategory, queryTokens, t]) const listData = useMemo(() => { const items: ListItem[] = [] @@ -212,7 +217,7 @@ export const EmojiPickerSheet = forwardRef(({ onEmoj (({ onEmoj grabber backgroundColor={IS_IOS_26_PLUS ? undefined : colors.sheet.background} grabberOptions={{ color: colors.sheet.grabber }} - header={} + header={} scrollable > (({ onEmoj keyExtractor={(item) => item.key} ListEmptyComponent={ - No emojis found + {t('emojiPicker.empty')} } renderItem={({ item }) => { if (item.type === 'header') { return ( - - {LABELS[item.title as keyof typeof LABELS] ?? item.title} - + {item.title} ) } diff --git a/apps/expo/components/error/PotentiallyOutdatedServer.tsx b/apps/expo/components/error/PotentiallyOutdatedServer.tsx index 3c193ca4ee..ec249dc30c 100644 --- a/apps/expo/components/error/PotentiallyOutdatedServer.tsx +++ b/apps/expo/components/error/PotentiallyOutdatedServer.tsx @@ -2,6 +2,8 @@ import { useRouter } from 'expo-router' import { Linking, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' +import { useTranslate } from '~/lib/hooks' + import Owl, { useOwlHeaderOffset } from '../Owl' import { Button, Heading, Text } from '../ui' import { getIssueUrl } from './utils' @@ -12,33 +14,33 @@ type Props = { } export default function PotentiallyOutdatedServer({ error, onRetry }: Props) { + const { t } = useTranslate() const router = useRouter() const emptyContainerStyle = useOwlHeaderOffset() return ( - - Outdated Server + + {t('errors.outdatedServer')} - An error was returned that suggests your server is outdated. Please make sure your - server is updated to continue. + {t('errors.outdatedServerDescription')} - + {onRetry && ( )} diff --git a/apps/expo/components/error/ServerConnectFailed.tsx b/apps/expo/components/error/ServerConnectFailed.tsx index ab1da09ba3..e2d6eb14ea 100644 --- a/apps/expo/components/error/ServerConnectFailed.tsx +++ b/apps/expo/components/error/ServerConnectFailed.tsx @@ -2,6 +2,8 @@ import { useRouter } from 'expo-router' import { View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' +import { useTranslate } from '~/lib/hooks' + import Owl, { useOwlHeaderOffset } from '../Owl' import { Button, Heading, Text } from '../ui' @@ -10,33 +12,33 @@ type Props = { } export default function ServerConnectFailed({ onRetry }: Props) { + const { t } = useTranslate() const router = useRouter() const emptyContainerStyle = useOwlHeaderOffset() return ( - - Failed to Connect + + {t('errors.connectionFailed')} - A network error suggests this server is currently unavailable. Please ensure that it is - running and accessible from this device + {t('errors.connectionFailedDescription')} - + {onRetry && ( @@ -47,7 +49,7 @@ export default function ServerConnectFailed({ onRetry }: Props) { className="ml-2" onPress={onRetry} > - Try Again + {t('errors.tryAgain')} )} diff --git a/apps/expo/components/error/ServerErrorBoundary.tsx b/apps/expo/components/error/ServerErrorBoundary.tsx index 11651852bc..01c6edb6ef 100644 --- a/apps/expo/components/error/ServerErrorBoundary.tsx +++ b/apps/expo/components/error/ServerErrorBoundary.tsx @@ -3,6 +3,8 @@ import { useRouter } from 'expo-router' import { Linking, ScrollView, View } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' +import { useTranslate } from '~/lib/hooks' + import Owl from '../Owl' import { Button, Heading, Text } from '../ui' import PotentiallyOutdatedServer from './PotentiallyOutdatedServer' @@ -15,6 +17,7 @@ type Props = { } export default function ServerErrorBoundary({ error, onRetry }: Props) { + const { t } = useTranslate() const router = useRouter() if (isNetworkError(error)) { @@ -26,14 +29,14 @@ export default function ServerErrorBoundary({ error, onRetry }: Props) { } return ( - - - + + + - Something went wrong! + {t('errors.somethingWentWrong')} @@ -44,14 +47,14 @@ export default function ServerErrorBoundary({ error, onRetry }: Props) { - + {onRetry && ( )} diff --git a/apps/expo/components/library/LibraryOverviewSheet.tsx b/apps/expo/components/library/LibraryOverviewSheet.tsx index 119dc0efd8..d3cd4793f5 100644 --- a/apps/expo/components/library/LibraryOverviewSheet.tsx +++ b/apps/expo/components/library/LibraryOverviewSheet.tsx @@ -11,6 +11,7 @@ import { ScrollView } from 'react-native-gesture-handler' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { IS_IOS_26_PLUS, STAT_COLORS, useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useGridItemSize } from '../listLayout/grid/useGridItemSize' import { SheetBackDetection } from '../SheetBackDetection' @@ -103,6 +104,7 @@ type SheetContentProps = { // TODO: make less ugly, low key kinda ugly tbh. I think my brain wants a grid rather than flex wrap maybe // TODO: Show more stuff function SheetContent({ library }: SheetContentProps) { + const { t } = useTranslate() const { stats } = library const formattedSize = formatBytesSeparate(stats.totalBytes) @@ -116,40 +118,40 @@ function SheetContent({ library }: SheetContentProps) { const libraryStats = [ { - label: 'In Progress', + label: t('libraryOverview.inProgress'), value: stats.inProgressBooks, icon: BookOpen, colors: STAT_COLORS.inProgress, }, { - label: 'Completed', + label: t('libraryOverview.completed'), value: stats.completedBooks, suffix: `/ ${stats.bookCount}`, icon: BookCheck, colors: STAT_COLORS.completed, }, { - label: 'Books', + label: t('libraryOverview.books'), value: stats.bookCount, icon: Library, colors: STAT_COLORS.books, }, { - label: 'Series', + label: t('libraryOverview.series'), value: stats.seriesCount, icon: Layers, colors: STAT_COLORS.series, }, { - label: 'Reading Time', + label: t('libraryOverview.readingTime'), value: formattedTime ? formattedTime.value : '??', suffix: formattedTime ? formattedTime.unit : undefined, icon: Clock, colors: STAT_COLORS.readingTime, }, { - label: 'Size', - value: formattedSize ? formattedSize.value : 'Unknown', + label: t('libraryOverview.size'), + value: formattedSize ? formattedSize.value : t('common.unknown'), suffix: formattedSize ? formattedSize.unit : '', icon: HardDrive, colors: STAT_COLORS.size, diff --git a/apps/expo/components/library/listHeader/LibrarySeriesListHeader.tsx b/apps/expo/components/library/listHeader/LibrarySeriesListHeader.tsx index 938eb8847f..7bea7c4d5c 100644 --- a/apps/expo/components/library/listHeader/LibrarySeriesListHeader.tsx +++ b/apps/expo/components/library/listHeader/LibrarySeriesListHeader.tsx @@ -10,6 +10,7 @@ import { ActionDef } from '~/components/filter/types' import { useSeriesFilterMenu } from '~/components/series/listHeader/SeriesFilterMenu' import { useSeriesSortAndDisplayMenu } from '~/components/series/listHeader/SeriesSortAndDisplayMenu' import { MiniEntityStatCards } from '~/components/stats' +import { useTranslate } from '~/lib/hooks' const scanMutation = graphql(` mutation LibrarySeriesListHeaderScanLibrary($id: ID!) { @@ -28,6 +29,7 @@ type Props = { } export function LibrarySeriesListHeader({ libraryId, stats, additionalActions }: Props) { + const { t } = useTranslate() const client = useQueryClient() const { mutate: scanLibrary } = useGraphQLMutation(scanMutation, { onSuccess: () => { @@ -48,7 +50,7 @@ export function LibrarySeriesListHeader({ libraryId, stats, additionalActions }: const result: ActionDef[] = [ { key: 'overview', - label: 'Overview', + label: t('common.overview'), icon: { ios: 'info.circle', android: Info }, onPress: additionalActions.onShowOverview, }, @@ -57,14 +59,14 @@ export function LibrarySeriesListHeader({ libraryId, stats, additionalActions }: if (checkPermission(UserPermission.ScanLibrary)) { result.push({ key: 'scan', - label: 'Scan Library', + label: t('entityActions.scanLibrary'), icon: { ios: 'document.viewfinder', android: ScanLine }, onPress: () => scanLibrary({ id: libraryId }), }) } return result - }, [additionalActions, checkPermission, scanLibrary, libraryId]) + }, [additionalActions, checkPermission, scanLibrary, libraryId, t]) const sortMenu = useSeriesSortAndDisplayMenu({ layoutKey: `library-${libraryId}-series`, diff --git a/apps/expo/components/localLibrary/ContinueReading.tsx b/apps/expo/components/localLibrary/ContinueReading.tsx index dbd9f6bf4e..465c2a015c 100644 --- a/apps/expo/components/localLibrary/ContinueReading.tsx +++ b/apps/expo/components/localLibrary/ContinueReading.tsx @@ -5,7 +5,7 @@ import { Fragment, useMemo } from 'react' import { View } from 'react-native' import { db, downloadedFiles, libraryRefs, readProgress, seriesRefs } from '~/db' -import { useListItemSize } from '~/lib/hooks' +import { useListItemSize, useTranslate } from '~/lib/hooks' import { useReadingNowWidgetSync, WidgetSyncBook, @@ -19,6 +19,7 @@ import { intoDownloadedFile } from './types' import { getThumbnailPath } from './utils' export default function ContinueReading() { + const { t } = useTranslate() // Note: This is a workaround for https://github.com/drizzle-team/drizzle-orm/issues/2660 const id = useDownloadsState((state) => state.fetchCounter) @@ -91,7 +92,7 @@ export default function ContinueReading() { {leftOffBooks.length > 0 && ( - Continue Reading + {t('stumpServer.continueReading.label')} diff --git a/apps/expo/components/opds/CreditsSection.tsx b/apps/expo/components/opds/CreditsSection.tsx index a5cb96947a..44191e26a9 100644 --- a/apps/expo/components/opds/CreditsSection.tsx +++ b/apps/expo/components/opds/CreditsSection.tsx @@ -3,6 +3,7 @@ import { useMemo } from 'react' import { View } from 'react-native' import MetadataBadgeSection from '~/components/overview/MetadataBadgeSection' +import { useTranslate } from '~/lib/hooks' import { extractCredits, OPDSMetadataLinkableItem } from './utils' @@ -12,6 +13,7 @@ type Props = { } export default function CreditsSection({ metadata, onPressCredit }: Props) { + const { t } = useTranslate() const credits = useMemo(() => extractCredits(metadata), [metadata]) if (credits.length === 0) { @@ -22,8 +24,8 @@ export default function CreditsSection({ metadata, onPressCredit }: Props) { {credits.map((credit) => ( ({ label: item.label, onPress: onPressCredit ? () => onPressCredit(item) : undefined, diff --git a/apps/expo/components/opds/FeedSelfURL.tsx b/apps/expo/components/opds/FeedSelfURL.tsx index b38fdf6d3d..7031710491 100644 --- a/apps/expo/components/opds/FeedSelfURL.tsx +++ b/apps/expo/components/opds/FeedSelfURL.tsx @@ -4,6 +4,7 @@ import { ComponentPropsWithoutRef } from 'react' import { Pressable, View } from 'react-native' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useActiveServer } from '../activeServer' import { Text } from '../ui' @@ -13,7 +14,8 @@ type Props = { label?: string } & Omit, 'children' | 'onPress'> -export default function FeedSelfURL({ label = 'See more', url }: Props) { +export default function FeedSelfURL({ label, url }: Props) { + const { t } = useTranslate() const router = useRouter() const { activeServer: { id: serverID }, @@ -42,7 +44,7 @@ export default function FeedSelfURL({ label = 'See more', url }: Props) { > - {label} + {label ?? t('opds.seeMore')} diff --git a/apps/expo/components/opds/FeedTitle.tsx b/apps/expo/components/opds/FeedTitle.tsx index 152996bb08..44470b4cb5 100644 --- a/apps/expo/components/opds/FeedTitle.tsx +++ b/apps/expo/components/opds/FeedTitle.tsx @@ -2,6 +2,7 @@ import { OPDSFeed } from '@stump/sdk' import { View } from 'react-native' import { cn } from '~/lib/utils' +import { useTranslate } from '~/lib/hooks' import { Heading } from '../ui' @@ -11,10 +12,11 @@ type Props = { } export default function FeedTitle({ feed: { metadata }, className }: Props) { - const title = metadata.title || 'OPDS Feed' + const { t } = useTranslate() + const title = metadata.title || t('opds.feedTitle') return ( - + {title} diff --git a/apps/expo/components/opds/MaybeErrorFeed.tsx b/apps/expo/components/opds/MaybeErrorFeed.tsx index bcdc8cfcf3..709e76532a 100644 --- a/apps/expo/components/opds/MaybeErrorFeed.tsx +++ b/apps/expo/components/opds/MaybeErrorFeed.tsx @@ -5,6 +5,7 @@ import { SafeAreaView } from 'react-native-safe-area-context' import { ZodError } from 'zod' import { isOPDSAuthError } from '~/lib/sdk/auth' +import { useTranslate } from '~/lib/hooks' import Owl from '../Owl' import { Button, Heading, Text } from '../ui' @@ -14,6 +15,7 @@ type Props = { onRetry?: () => void } export default function MaybeErrorFeed({ error, onRetry }: Props) { + const { t } = useTranslate() const router = useRouter() if (!error) return null @@ -21,18 +23,18 @@ export default function MaybeErrorFeed({ error, onRetry }: Props) { // Note: This is handled above in tree if (isOPDSAuthError(error)) return null - const errorTitle = error instanceof ZodError ? 'Invalid Feed' : 'Error Loading Feed' + const errorTitle = error instanceof ZodError ? t('opds.invalidFeed') : t('opds.feedLoadFailed') const errorMessage = error instanceof ZodError - ? `This feed does not adhere to the OPDS v2.0 specification: ${error.message}` + ? t('opds.feedSpecError', { version: 'v2.0', error: error.message }) : error instanceof Error && error.message ? error.message - : 'There was an error fetching this feed.' + : t('opds.feedFetchError') return ( - - - + + + @@ -46,14 +48,14 @@ export default function MaybeErrorFeed({ error, onRetry }: Props) { - + {onRetry && ( )} diff --git a/apps/expo/components/opds/Navigation.tsx b/apps/expo/components/opds/Navigation.tsx index cba60efad8..ccaa98ad3e 100644 --- a/apps/expo/components/opds/Navigation.tsx +++ b/apps/expo/components/opds/Navigation.tsx @@ -4,6 +4,8 @@ import { useRouter } from 'expo-router' import { ChevronRight, Rss } from 'lucide-react-native' import { Pressable, View } from 'react-native' +import { useTranslate } from '~/lib/hooks' + import { useActiveServer } from '../activeServer' import { Card, ListEmptyMessage } from '../ui' import { Icon } from '../ui/icon' @@ -14,6 +16,7 @@ type Props = { } & FeedComponentOptions export default function Navigation({ navigation, renderEmpty }: Props) { + const { t } = useTranslate() const { sdk } = useSDK() const { activeServer } = useActiveServer() const router = useRouter() @@ -22,7 +25,7 @@ export default function Navigation({ navigation, renderEmpty }: Props) { return ( - + {navigation.map((link) => ( {({ pressed }) => ( - + )} ))} - {!navigation.length && } + {!navigation.length && } ) } diff --git a/apps/expo/components/opds/NavigationFeed.tsx b/apps/expo/components/opds/NavigationFeed.tsx index 708cb6d824..03c8d81dc8 100644 --- a/apps/expo/components/opds/NavigationFeed.tsx +++ b/apps/expo/components/opds/NavigationFeed.tsx @@ -57,7 +57,7 @@ export default function NavigationFeed({ > {({ pressed }) => ( - + )} diff --git a/apps/expo/components/opds/NavigationGroup.tsx b/apps/expo/components/opds/NavigationGroup.tsx index 49f697a4fc..50e0752c43 100644 --- a/apps/expo/components/opds/NavigationGroup.tsx +++ b/apps/expo/components/opds/NavigationGroup.tsx @@ -6,6 +6,8 @@ import { ChevronRight, Rss } from 'lucide-react-native' import { useMemo } from 'react' import { Pressable, View } from 'react-native' +import { useTranslate } from '~/lib/hooks' + import { useActiveServer } from '../activeServer' import { Card, ListEmptyMessage } from '../ui' import { Icon } from '../ui/icon' @@ -21,6 +23,7 @@ export default function NavigationGroup({ group: { metadata, links, navigation: initialNavigation }, renderEmpty, }: Props) { + const { t } = useTranslate() const selfURL = links.find((link) => hasLinkRel(link, 'self'))?.href const hasGroupPagination = links.some((link) => hasLinkRel(link, 'next')) const { sdk } = useSDK() @@ -52,9 +55,9 @@ export default function NavigationGroup({ return ( - + : undefined} className="flex-1" > @@ -70,7 +73,7 @@ export default function NavigationGroup({ > {({ pressed }) => ( - + )} @@ -78,7 +81,9 @@ export default function NavigationGroup({ - {!navigation.length && } + {!navigation.length && ( + + )} ) } diff --git a/apps/expo/components/opds/NavigationLink.tsx b/apps/expo/components/opds/NavigationLink.tsx index 83ca95fdcd..81cb037b21 100644 --- a/apps/expo/components/opds/NavigationLink.tsx +++ b/apps/expo/components/opds/NavigationLink.tsx @@ -34,14 +34,14 @@ export default function NavigationLink({ link }: Props) { > {({ pressed }) => ( {link.title} - + )} diff --git a/apps/expo/components/opds/OPDSAuthDialog.tsx b/apps/expo/components/opds/OPDSAuthDialog.tsx index 844fed5189..a58cba09b1 100644 --- a/apps/expo/components/opds/OPDSAuthDialog.tsx +++ b/apps/expo/components/opds/OPDSAuthDialog.tsx @@ -4,7 +4,7 @@ import { queryClient, useSDK } from '@stump/client' import { Api, constants, OPDSAuthenticationDocument, resolveUrl } from '@stump/sdk' import { opdsURL } from '@stump/sdk/controllers' import { isAxiosError } from 'axios' -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Controller, useForm, useFormState } from 'react-hook-form' import { View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -13,6 +13,7 @@ import urlJoin from 'url-join' import { z } from 'zod' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useActiveServer } from '../activeServer' import { SheetBackDetection } from '../SheetBackDetection' @@ -27,6 +28,15 @@ type OPDSAuthDialogProps = { } export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDialogProps) { + const { t } = useTranslate() + const schema = useMemo( + () => + z.object({ + password: z.string().min(1, { message: t('auth.passwordRequired') }), + username: z.string().min(1, { message: t('auth.usernameRequired') }), + }), + [t], + ) const { activeServer } = useActiveServer() const { sdk } = useSDK() @@ -93,7 +103,7 @@ export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDia try { const newApi = await attemptRequest({ username, password }) if (!newApi) { - setLoginError('Failed to authenticate') + setLoginError(t('errors.connectionFailed')) return } @@ -113,11 +123,11 @@ export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDia if (isAxiosError(error)) { setLoginError(error.message) } else { - setLoginError('An error occurred') + setLoginError(t('errors.unknown')) } } }, - [attemptRequest, sdk, onClose], + [attemptRequest, sdk, onClose, t], ) const basicAuth = authDoc?.authentication.find( @@ -128,8 +138,8 @@ export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDia ? resolveUrl(logoLink.href, sdk?.rootURL ?? activeServer?.url) : undefined - const usernameLabel = basicAuth?.labels?.login || 'Username' - const passwordLabel = basicAuth?.labels?.password || 'Password' + const usernameLabel = basicAuth?.labels?.login || t('common.username') + const passwordLabel = basicAuth?.labels?.password || t('common.password') return ( <> @@ -216,7 +226,7 @@ export default function OPDSAuthDialog({ isOpen, authDoc, onClose }: OPDSAuthDia /> {/* eslint-disable-next-line react-hooks/refs */} @@ -231,8 +241,4 @@ type Credentials = { password: string } -const schema = z.object({ - password: z.string().min(1, { message: 'Password is required' }), - username: z.string().min(1, { message: 'Username is required' }), -}) -type LoginSchema = z.infer +type LoginSchema = Credentials diff --git a/apps/expo/components/opds/PublicationGroup.tsx b/apps/expo/components/opds/PublicationGroup.tsx index 2735e9569c..81fdcfd122 100644 --- a/apps/expo/components/opds/PublicationGroup.tsx +++ b/apps/expo/components/opds/PublicationGroup.tsx @@ -8,7 +8,7 @@ import { Rss } from 'lucide-react-native' import { useCallback, useMemo } from 'react' import { Pressable, View } from 'react-native' -import { useListItemSize } from '~/lib/hooks' +import { useListItemSize, useTranslate } from '~/lib/hooks' import { cn } from '~/lib/utils' import { useActiveServer } from '../activeServer' @@ -27,6 +27,7 @@ export default function PublicationGroup({ // eslint-disable-next-line react/prop-types renderEmpty, }: Props) { + const { t } = useTranslate() const selfURL = links?.find((link) => hasLinkRel(link, 'self'))?.href const hasGroupPagination = links?.some((link) => hasLinkRel(link, 'next')) const router = useRouter() @@ -88,7 +89,7 @@ export default function PublicationGroup({ > {({ pressed }) => ( @@ -124,8 +125,8 @@ export default function PublicationGroup({ // card list sections return ( - - {metadata.title || 'Publications'} + + {metadata.title || t('opds.publications')} {selfURL && } @@ -142,7 +143,9 @@ export default function PublicationGroup({ ItemSeparatorComponent={() => } /> - {!publications.length && } + {!publications.length && ( + + )} ) } diff --git a/apps/expo/components/opds/PublicationMenu.tsx b/apps/expo/components/opds/PublicationMenu.tsx index 6cb4fc015d..381039d7ab 100644 --- a/apps/expo/components/opds/PublicationMenu.tsx +++ b/apps/expo/components/opds/PublicationMenu.tsx @@ -6,7 +6,7 @@ import { Platform, View } from 'react-native' import { Pressable } from 'react-native-gesture-handler' import * as DropdownMenu from 'zeego/dropdown-menu' -import { useIsOPDSPublicationDownloaded, useOPDSDownload } from '~/lib/hooks' +import { useIsOPDSPublicationDownloaded, useOPDSDownload, useTranslate } from '~/lib/hooks' import { useActiveServer } from '../activeServer' import { Icon } from '../ui' @@ -17,6 +17,7 @@ type Props = { } export default function PublicationMenu({ publicationUrl, metadata }: Props) { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -41,7 +42,7 @@ export default function PublicationMenu({ publicationUrl, metadata }: Props) { destructive disabled={!isDownloaded || isDeleting} > - Delete Download + {t('bookActions.deleteDownload.label')} @@ -51,7 +52,7 @@ export default function PublicationMenu({ publicationUrl, metadata }: Props) { - Delete Download + {t('bookActions.deleteDownload.label')} diff --git a/apps/expo/components/opds/useFeedTitle.ts b/apps/expo/components/opds/useFeedTitle.ts index 0eabff41c2..ce28760396 100644 --- a/apps/expo/components/opds/useFeedTitle.ts +++ b/apps/expo/components/opds/useFeedTitle.ts @@ -4,9 +4,11 @@ import { useLayoutEffect, useMemo } from 'react' import { Platform } from 'react-native' import { IS_IOS_26_PLUS } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' export function useFeedTitle(feed?: OPDSFeed | null) { - const title = useMemo(() => (feed ? feed.metadata.title || 'OPDS Feed' : null), [feed]) + const { t } = useTranslate() + const title = useMemo(() => (feed ? feed.metadata.title || t('opds.feedTitle') : null), [feed, t]) const navigation = useNavigation() useLayoutEffect(() => { if (!title) return diff --git a/apps/expo/components/opds/utils.ts b/apps/expo/components/opds/utils.ts index 9e9b39f547..4320bac739 100644 --- a/apps/expo/components/opds/utils.ts +++ b/apps/expo/components/opds/utils.ts @@ -147,27 +147,27 @@ export const getFlexibleArrayField = ( type CreditFieldDefinition = { keys: string[] - label: string + labelKey: string } export const CREDIT_FIELD_DEFINITIONS: CreditFieldDefinition[] = [ - { keys: ['author', 'authors'], label: 'Authors' }, - { keys: ['writer', 'writers'], label: 'Writers' }, - { keys: ['artist', 'artists'], label: 'Artists' }, - { keys: ['penciler', 'pencilers'], label: 'Pencilers' }, - { keys: ['inker', 'inkers'], label: 'Inkers' }, - { keys: ['colorist', 'colorists'], label: 'Colorists' }, - { keys: ['letterer', 'letterers'], label: 'Letterers' }, - { keys: ['coverArtist', 'coverArtists'], label: 'Cover Artists' }, - { keys: ['editor', 'editors'], label: 'Editors' }, - { keys: ['translator', 'translators'], label: 'Translators' }, - { keys: ['contributor', 'contributors'], label: 'Contributors' }, - { keys: ['illustrator', 'illustrators'], label: 'Illustrators' }, - { keys: ['narrator', 'narrators'], label: 'Narrators' }, + { keys: ['author', 'authors'], labelKey: 'authors' }, + { keys: ['writer', 'writers'], labelKey: 'writers' }, + { keys: ['artist', 'artists'], labelKey: 'artists' }, + { keys: ['penciler', 'pencilers'], labelKey: 'pencilers' }, + { keys: ['inker', 'inkers'], labelKey: 'inkers' }, + { keys: ['colorist', 'colorists'], labelKey: 'colorists' }, + { keys: ['letterer', 'letterers'], labelKey: 'letterers' }, + { keys: ['coverArtist', 'coverArtists'], labelKey: 'coverArtists' }, + { keys: ['editor', 'editors'], labelKey: 'editors' }, + { keys: ['translator', 'translators'], labelKey: 'translators' }, + { keys: ['contributor', 'contributors'], labelKey: 'contributors' }, + { keys: ['illustrator', 'illustrators'], labelKey: 'illustrators' }, + { keys: ['narrator', 'narrators'], labelKey: 'narrators' }, ] export type ExtractedCredit = { - label: string + labelKey: string items: OPDSMetadataLinkableItem[] } @@ -195,7 +195,7 @@ export const extractCredits = (meta: OPDSMetadata | null | undefined): Extracted if (uniqueItems.length > 0) { credits.push({ - label: definition.label, + labelKey: definition.labelKey, items: uniqueItems, }) } diff --git a/apps/expo/components/opdsLegacy/FeedActionMenu.tsx b/apps/expo/components/opdsLegacy/FeedActionMenu.tsx index 61005a562a..96ba2f8db1 100644 --- a/apps/expo/components/opdsLegacy/FeedActionMenu.tsx +++ b/apps/expo/components/opdsLegacy/FeedActionMenu.tsx @@ -16,8 +16,10 @@ import { } from '~/components/ui' import { useColors } from '~/lib/constants' import { usePreferencesStore } from '~/stores' +import { useTranslate } from '~/lib/hooks' export default function FeedActionMenu() { + const { t } = useTranslate() const [isOpen, setIsOpen] = useState(false) const insets = useSafeAreaInsets() @@ -46,7 +48,7 @@ export default function FeedActionMenu() { }} > setPreferences({ opdsLayout: 'grid' })} > - Grid + {t('common.grid')} - Grid, + {t('common.grid')}, setPreferences({ opdsLayout: 'list' })} > - List + {t('common.list')} - List, + {t('common.list')}, @@ -91,7 +93,7 @@ export default function FeedActionMenu() { {onRetry && ( )} diff --git a/apps/expo/components/opdsLegacy/OPDSLegacyEntryItem.tsx b/apps/expo/components/opdsLegacy/OPDSLegacyEntryItem.tsx index 897ded808a..ad8abc7561 100644 --- a/apps/expo/components/opdsLegacy/OPDSLegacyEntryItem.tsx +++ b/apps/expo/components/opdsLegacy/OPDSLegacyEntryItem.tsx @@ -12,7 +12,7 @@ import { useRef } from 'react' import { Image, Platform, Pressable, View } from 'react-native' import { getLegacyStreamingContextValue } from '~/context/opdsLegacy' -import { useIsLegacyOPDSEntryDownloaded, useOPDSDownload } from '~/lib/hooks' +import { useIsLegacyOPDSEntryDownloaded, useOPDSDownload, useTranslate } from '~/lib/hooks' import { useColorScheme } from '~/lib/useColorScheme' import { cn } from '~/lib/utils' import { usePreferencesStore } from '~/stores' @@ -31,6 +31,7 @@ type Props = { } export default function OPDSEntry({ entry }: Props) { + const { t } = useTranslate() const { colorScheme } = useColorScheme() const { sdk } = useSDK() const { @@ -97,7 +98,7 @@ export default function OPDSEntry({ entry }: Props) { { items: [ { - label: 'See Details', + label: t('bookActions.seeDetails'), icon: { ios: 'info.circle', android: Info, @@ -117,7 +118,7 @@ export default function OPDSEntry({ entry }: Props) { { items: [ { - label: 'Download', + label: t('common.download'), disabled: !downloadLink || isDownloaded, onPress: () => { if (!downloadLink) return @@ -157,7 +158,7 @@ export default function OPDSEntry({ entry }: Props) { { items: [ { - label: 'Delete Download', + label: t('bookActions.deleteDownload.label'), onPress: () => { deleteBook({ id: entry.id, @@ -179,8 +180,8 @@ export default function OPDSEntry({ entry }: Props) { {({ pressed }) => ( + {isStreamable && ( - + )} {isDownloaded && ( - + )} @@ -251,7 +252,9 @@ export default function OPDSEntry({ entry }: Props) { {layout === 'list' && streamingContext?.pageCount != null && ( - {streamingContext.pageCount} pages + + {streamingContext.pageCount} {t('common.pages')} + )} diff --git a/apps/expo/components/opdsLegacy/OPDSLegacyEntryItemSheet.tsx b/apps/expo/components/opdsLegacy/OPDSLegacyEntryItemSheet.tsx index 83530c8d3d..427e882790 100644 --- a/apps/expo/components/opdsLegacy/OPDSLegacyEntryItemSheet.tsx +++ b/apps/expo/components/opdsLegacy/OPDSLegacyEntryItemSheet.tsx @@ -10,6 +10,7 @@ import TImage from 'react-native-turbo-image' import { stripHtml } from 'string-strip-html' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useColorScheme } from '~/lib/useColorScheme' import { usePreferencesStore } from '~/stores' @@ -29,6 +30,7 @@ type Props = { export const OPDSLegacyEntryItemSheet = forwardRef( function OPDSLegacyEntryItemSheet({ entry }, ref) { + const { t } = useTranslate() const { activeServer: { name: serverName }, } = useActiveServer() @@ -140,8 +142,10 @@ export const OPDSLegacyEntryItemSheet = forwardRef( {(pageCount != null || currentPage != null) && ( - {pageCount != null && } - {currentPage != null && } + {pageCount != null && } + {currentPage != null && ( + + )} )} @@ -149,15 +153,15 @@ export const OPDSLegacyEntryItemSheet = forwardRef( {!!description && } ({ label: author.name })) || [])]} /> - - + + {entry.updated && ( { if (authMode === 'default') { return ( - + {t(getKey('auth.default.description'))} ) @@ -196,7 +196,7 @@ export default function AddOrEditServerForm({ control={control} render={({ field: { onChange, onBlur, value } }) => ( {formValues.customHeaders?.length && ( - + {formValues.customHeaders.map((header, index) => ( @@ -412,7 +412,7 @@ export default function AddOrEditServerForm({ )} {isAddingHeader ? ( - + {({ pressed }) => ( diff --git a/apps/expo/components/search/SearchHistoryAndFavorites.tsx b/apps/expo/components/search/SearchHistoryAndFavorites.tsx index 5728778d44..3473dac38b 100644 --- a/apps/expo/components/search/SearchHistoryAndFavorites.tsx +++ b/apps/expo/components/search/SearchHistoryAndFavorites.tsx @@ -6,6 +6,7 @@ import { useActiveServer } from '~/components/activeServer' import { Icon, ListLabel, Text } from '~/components/ui' import { ContextMenu } from '~/components/ui/context-menu/context-menu' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { useCuratedSearch, useSearchStore } from '~/stores/search' import { Divider } from '../Divider' @@ -15,6 +16,7 @@ type Props = { } export function SearchHistoryAndFavorites({ onSelect }: Props) { + const { t } = useTranslate() const { activeServer: { id: serverID }, } = useActiveServer() @@ -37,8 +39,8 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { if (!hasFavorites && !hasHistory) { return ( - - Your favorites and recent searches will appear here + + {t('searchHistory.empty')} ) @@ -52,7 +54,7 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { > {hasFavorites && ( - Favorites + {t('searchHistory.favorites')} {favoriteSearches.map((record, index) => ( {index > 0 && } @@ -62,7 +64,7 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { { items: [ { - label: 'Unfavorite', + label: t('searchHistory.unfavorite'), icon: { ios: 'bookmark.slash', android: BookmarkX }, onPress: () => unfavoriteSearch(record.query, serverID), role: 'destructive', @@ -84,9 +86,9 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { {hasHistory && ( - Recently Searched + {t('searchHistory.recent')} clearSearchHistory(serverID)} hitSlop={10}> - Clear + {t('common.clear')} {searchHistory.map((record, index) => ( @@ -100,7 +102,7 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { ...(!favoriteQuerySet.has(record.query) ? [ { - label: 'Favorite', + label: t('common.favorite'), icon: { ios: 'bookmark.fill' as const, android: Bookmark, @@ -110,7 +112,7 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { ] : [ { - label: 'Unfavorite', + label: t('searchHistory.unfavorite'), icon: { ios: 'bookmark.slash' as const, android: BookmarkX, @@ -119,7 +121,7 @@ export function SearchHistoryAndFavorites({ onSelect }: Props) { }, ]), { - label: 'Remove', + label: t('searchHistory.remove'), icon: { ios: 'trash', android: Trash }, onPress: () => removeFromHistory(record.query, serverID), role: 'destructive' as const, diff --git a/apps/expo/components/selection/SelectionLeftScreenHeader.tsx b/apps/expo/components/selection/SelectionLeftScreenHeader.tsx index 459063f33c..53187e640d 100644 --- a/apps/expo/components/selection/SelectionLeftScreenHeader.tsx +++ b/apps/expo/components/selection/SelectionLeftScreenHeader.tsx @@ -2,11 +2,13 @@ import { Host, Image } from '@expo/ui/swift-ui' import { ListMinus, ListPlus } from 'lucide-react-native' import { Platform, Pressable, View } from 'react-native' +import { useTranslate } from '~/lib/hooks' import { useSelectionStore } from '~/stores/selection' import { Icon } from '../ui/icon' export default function SelectionLeftScreenHeader() { + const { t } = useTranslate() const isSelectAll = useSelectionStore((state) => state.isSelectAll()) const clearSelection = useSelectionStore((state) => state.clearSelection) const selectAll = useSelectionStore((state) => state.selectAll) @@ -22,7 +24,7 @@ export default function SelectionLeftScreenHeader() { const PressableChild = Platform.select({ ios: ( ), android: ( - + ), diff --git a/apps/expo/components/selection/SelectionRightScreenHeader.tsx b/apps/expo/components/selection/SelectionRightScreenHeader.tsx index eb1c0c2bc4..fdc64fdc06 100644 --- a/apps/expo/components/selection/SelectionRightScreenHeader.tsx +++ b/apps/expo/components/selection/SelectionRightScreenHeader.tsx @@ -1,11 +1,11 @@ import { Host, Image } from '@expo/ui/swift-ui' import { Stack } from 'expo-router' import { CheckCircle2, Share, Trash } from 'lucide-react-native' -import pluralize from 'pluralize' import { useCallback, useMemo } from 'react' import { Alert, Platform, Pressable, View } from 'react-native' import { useSelectionStore } from '~/stores/selection' +import { useTranslate } from '~/lib/hooks' import { ActionMenu } from '../ui/action-menu/action-menu' import { Icon } from '../ui/icon' @@ -13,6 +13,7 @@ import { Icon } from '../ui/icon' // TODO: Redesign after https://github.com/software-mansion/react-native-screens/issues/2990#issuecomment-3448692775 export default function SelectionRightScreenHeader() { + const { t } = useTranslate() const onStopSelection = useSelectionStore((state) => state.resetSelection) const currentSelection = useSelectionStore((state) => state.selectionState) const customActions = useSelectionStore((state) => state.customActions) @@ -27,15 +28,16 @@ export default function SelectionRightScreenHeader() { }, [currentSelection, deleteAction, onStopSelection]) const confirmDeleteSelection = useCallback(() => { + const count = currentSelection.size Alert.alert( - `Delete ${currentSelection.size} ${pluralize('download', currentSelection.size)}`, - 'This action cannot be undone.', + t(count === 1 ? 'selection.deleteOneDownload' : 'selection.deleteManyDownloads', { count }), + t('selection.cannotUndo'), [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Delete', style: 'destructive', onPress: onDeleteSelection }, + { text: t('common.cancel'), style: 'cancel' }, + { text: t('common.delete'), style: 'destructive', onPress: onDeleteSelection }, ], ) - }, [currentSelection.size, onDeleteSelection]) + }, [currentSelection.size, onDeleteSelection, t]) return Platform.select({ ios: ( @@ -46,7 +48,7 @@ export default function SelectionRightScreenHeader() { {}} disabled> - Share + {t('common.share')} @@ -57,7 +59,7 @@ export default function SelectionRightScreenHeader() { destructive disabled={!deleteAction} > - Delete + {t('common.delete')} @@ -73,7 +75,7 @@ export default function SelectionRightScreenHeader() { }} > {hasAbout && ( - - {metadata?.summary && } + + {metadata?.summary && ( + + )} {metadata?.descriptionFormatted && !metadata?.summary && ( - + )} )} {hasPublicationInfo && ( - - {metadata?.publisher && } - {metadata?.imprint && } + + {metadata?.publisher && ( + + )} + {metadata?.imprint && ( + + )} {metadata?.publicationRun && ( - + + )} + {metadata?.status && ( + + )} + {metadata?.booktype && ( + + )} + {metadata?.year && ( + + )} + {metadata?.volume && ( + )} - {metadata?.status && } - {metadata?.booktype && } - {metadata?.year && } - {metadata?.volume && } {metadata?.totalIssues && ( - + )} )} {hasDetails && ( - + {metadata?.ageRating && ( - + + )} + {metadata?.metaType && ( + )} - {metadata?.metaType && } )} ({ label: genre }))} /> ({ label: writer }))} /> ({ label: character }))} /> diff --git a/apps/expo/components/series/SeriesSearchItem.tsx b/apps/expo/components/series/SeriesSearchItem.tsx index 30e8515813..54007c2f62 100644 --- a/apps/expo/components/series/SeriesSearchItem.tsx +++ b/apps/expo/components/series/SeriesSearchItem.tsx @@ -3,7 +3,7 @@ import { FragmentType, graphql, useFragment } from '@stump/graphql' import { useRouter } from 'expo-router' import { Pressable, View } from 'react-native' -import { useDisplay } from '~/lib/hooks' +import { useDisplay, useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' import { useActiveServer } from '../activeServer' @@ -48,6 +48,7 @@ type Props = { } export default function SeriesSearchItem({ series }: Props) { + const { t } = useTranslate() const { sdk } = useSDK() const { activeServer: { id: serverID }, @@ -72,7 +73,7 @@ export default function SeriesSearchItem({ series }: Props) { width: width * 0.75, }} > - + - + {data.resolvedName} - {data.readCount}/{data.mediaCount} books • {data.percentageCompleted.toFixed(1)}% + {data.readCount}/{data.mediaCount} {t('seriesOverview.books')} •{' '} + {data.percentageCompleted.toFixed(1)}% diff --git a/apps/expo/components/smartList/SmartListActionMenu.tsx b/apps/expo/components/smartList/SmartListActionMenu.tsx index 1ad1f55673..975cb07539 100644 --- a/apps/expo/components/smartList/SmartListActionMenu.tsx +++ b/apps/expo/components/smartList/SmartListActionMenu.tsx @@ -18,6 +18,7 @@ import { Text, } from '~/components/ui' import { useColors } from '~/lib/constants' +import { useTranslate } from '~/lib/hooks' import { usePreferencesStore } from '~/stores' type Props = { @@ -26,6 +27,7 @@ type Props = { } export default function SmartListActionMenu({ onCollapseAll, onExpandAll }: Props) { + const { t } = useTranslate() const [isOpen, setIsOpen] = useState(false) const insets = useSafeAreaInsets() @@ -54,7 +56,7 @@ export default function SmartListActionMenu({ onCollapseAll, onExpandAll }: Prop }} > setPreferences({ smartListLayout: 'grid' })} > - Grid + {t('common.grid')} @@ -86,7 +88,7 @@ export default function SmartListActionMenu({ onCollapseAll, onExpandAll }: Prop key="displayAsList" onSelect={() => setPreferences({ smartListLayout: 'list' })} > - List + {t('common.list')} @@ -94,12 +96,16 @@ export default function SmartListActionMenu({ onCollapseAll, onExpandAll }: Prop {onCollapseAll && onExpandAll && ( - Collapse All + + {t('smartList.collapseAll')} + - Expand All + + {t('smartList.expandAll')} + @@ -111,7 +117,7 @@ export default function SmartListActionMenu({ onCollapseAll, onExpandAll }: Prop
diff --git a/packages/browser/src/components/DirectoryPickerModal.tsx b/packages/browser/src/components/DirectoryPickerModal.tsx index 829f6a316a..b5225d6298 100644 --- a/packages/browser/src/components/DirectoryPickerModal.tsx +++ b/packages/browser/src/components/DirectoryPickerModal.tsx @@ -1,5 +1,6 @@ import { useDirectoryListing } from '@stump/client' import { Button, CheckBox, cx, Dialog, Input, Text, useBoolean } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { ArrowLeft, Folder } from 'lucide-react' import { useCallback, useEffect, useMemo, useRef } from 'react' import AutoSizer from 'react-virtualized-auto-sizer' @@ -19,6 +20,7 @@ export default function DirectoryPickerModal({ startingPath, onPathChange, }: Props) { + const { t } = useLocaleContext() const virtuosoRef = useRef(null) const [showHidden, { toggle }] = useBoolean(false) @@ -80,10 +82,8 @@ export default function DirectoryPickerModal({ - Select a Directory - - Specify the directory where your library is located. - + {t('libraryUi.directoryPicker.title')} + {t('libraryUi.directoryPicker.description')} @@ -147,11 +147,15 @@ export default function DirectoryPickerModal({ - +
- - + +
diff --git a/packages/browser/src/components/ErrorFallback.tsx b/packages/browser/src/components/ErrorFallback.tsx index 5400df73b3..7c730574ff 100644 --- a/packages/browser/src/components/ErrorFallback.tsx +++ b/packages/browser/src/components/ErrorFallback.tsx @@ -1,4 +1,5 @@ import { Button, ButtonOrLink, useBodyLock } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { ExternalLink } from 'lucide-react' import { FallbackProps } from 'react-error-boundary' import { toast } from 'sonner' @@ -8,11 +9,12 @@ import { copyTextToClipboard } from '../utils/misc' // TODO: take in platform? export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { useBodyLock() + const { t } = useLocaleContext() function copyErrorStack() { if (error.stack) { copyTextToClipboard(error.stack).then(() => { - toast.success('Copied error details to your clipboard') + toast.success(t('errorScene.copiedDetails')) }) } } @@ -24,39 +26,41 @@ export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) { > Construction illustration
-

A critical error occurred

+

+ {t('errorScene.criticalHeading')} +

- {error.message || 'The error message was empty.'} + {error.message || t('errorScene.emptyMessage')}

- Go Home + {t('errorScene.buttons.goHome')} - Report Bug + {t('errorScene.buttons.report')} {error.stack && ( )}
diff --git a/packages/browser/src/components/HorizontalCardList.tsx b/packages/browser/src/components/HorizontalCardList.tsx index f954a5c2cc..c75290158d 100644 --- a/packages/browser/src/components/HorizontalCardList.tsx +++ b/packages/browser/src/components/HorizontalCardList.tsx @@ -1,4 +1,5 @@ import { Button, cn, Heading, Text, ToolTip } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { ChevronLeft, ChevronRight, CircleSlash2 } from 'lucide-react' import { forwardRef, useMemo } from 'react' import { ScrollerProps, Virtuoso } from 'react-virtuoso' @@ -23,6 +24,7 @@ export default function HorizontalCardList({ height: heightProp, footerHeight = 96, }: Props) { + const { t } = useLocaleContext() const { preferences: { thumbnailRatio }, } = usePreferences() @@ -52,9 +54,9 @@ export default function HorizontalCardList({
- Nothing to show + {t('common.nothingToShow')} - No results present to display + {t('common.noResultsToDisplay')}
@@ -86,7 +88,7 @@ export default function HorizontalCardList({ {title}
- + - +
@@ -103,7 +105,7 @@ export default function MultiRowHorizontalCardList({ {title}
- + - +
diff --git a/packages/browser/src/components/ReadMore.tsx b/packages/browser/src/components/ReadMore.tsx index b7a369933c..f022718b24 100644 --- a/packages/browser/src/components/ReadMore.tsx +++ b/packages/browser/src/components/ReadMore.tsx @@ -1,4 +1,5 @@ import { cn, useBoolean } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { DEBUG_ENV } from '../index.ts' import Markdown from './markdown/MarkdownPreview.tsx' @@ -12,6 +13,7 @@ const COLLAPSED_HEIGHT = 72 const MAX_EXPANDED_HEIGHT = 300 export default function ReadMore({ text, muted }: Props) { + const { t } = useLocaleContext() const [showingAll, { toggle }] = useBoolean(false) const resolvedText = text ? text : DEBUG_ENV ? DEBUG_FAKE_TEXT : '' @@ -48,7 +50,7 @@ export default function ReadMore({ text, muted }: Props) { onClick={toggle} className="px-3 py-0.5 text-xs font-medium cursor-pointer rounded-full border border-dashed border-border bg-background text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" > - {showingAll ? 'Read less' : 'Read more'} + {showingAll ? t('common.readLess') : t('common.readMore')}
diff --git a/packages/browser/src/components/ServerStatusOverlay.tsx b/packages/browser/src/components/ServerStatusOverlay.tsx index cb79775436..2c8f898863 100644 --- a/packages/browser/src/components/ServerStatusOverlay.tsx +++ b/packages/browser/src/components/ServerStatusOverlay.tsx @@ -1,10 +1,12 @@ import { Link, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { AnimatePresence, motion } from 'framer-motion' import { useEffect, useState } from 'react' import { useAppStore } from '@/stores' export default function ServerStatusOverlay() { + const { t } = useLocaleContext() const [show, setShow] = useState(false) const isConnected = useAppStore((store) => store.isConnectedWithServer) @@ -39,7 +41,7 @@ export default function ServerStatusOverlay() { >
- Server is not connected + {t('serverStatus.disconnected')}
@@ -49,11 +51,11 @@ export default function ServerStatusOverlay() {
- Please check your internet connection.{' '} + {t('serverStatus.checkConnection')}{' '} - Click here + {t('common.clickHere')} {' '} - to change your server URL. + {t('serverStatus.changeUrlSuffix')}
diff --git a/packages/browser/src/components/TableOrGridLayout.tsx b/packages/browser/src/components/TableOrGridLayout.tsx index b403a6fcbc..07c232eaad 100644 --- a/packages/browser/src/components/TableOrGridLayout.tsx +++ b/packages/browser/src/components/TableOrGridLayout.tsx @@ -1,5 +1,6 @@ import { IconButton, ToolTip } from '@stump/components' import { InterfaceLayout } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { LayoutGrid, Table } from 'lucide-react' type Props = { @@ -8,9 +9,10 @@ type Props = { } export default function TableOrGridLayout({ layout, setLayout }: Props) { + const { t } = useLocaleContext() return (
- + - + a.label.localeCompare(b.label)).map(({ value }) => value)} diff --git a/packages/browser/src/components/UserMenu.tsx b/packages/browser/src/components/UserMenu.tsx index c771d4a1bf..87d80ebccd 100644 --- a/packages/browser/src/components/UserMenu.tsx +++ b/packages/browser/src/components/UserMenu.tsx @@ -1,4 +1,5 @@ import { Avatar, cn, ConfirmationModal, Dropdown, Text } from '@stump/components' +import { useLocaleContext } from '@stump/i18n' import { Bell, Server, Settings } from 'lucide-react' import { useState } from 'react' import { useNavigate } from 'react-router-dom' @@ -14,6 +15,7 @@ type Props = { } export default function UserMenu({ variant = 'sidebar' }: Props) { + const { t } = useLocaleContext() const [isOpen, setIsOpen] = useState(false) const [isSignOutConfirmOpen, setIsSignOutConfirmOpen] = useState(false) const navigate = useNavigate() @@ -30,9 +32,9 @@ export default function UserMenu({ variant = 'sidebar' }: Props) { return ( <> setIsSignOutConfirmOpen(false)} @@ -87,7 +89,7 @@ export default function UserMenu({ variant = 'sidebar' }: Props) { - Notifications + {t('common.notifications')} - Settings + {t('sidebar.buttons.settings')} @@ -105,7 +107,7 @@ export default function UserMenu({ variant = 'sidebar' }: Props) { {platform !== 'browser' && ( navigate('/')} className={itemClasses(isSidebar)}> - Switch server + {t('common.switchServer')} )} @@ -114,7 +116,7 @@ export default function UserMenu({ variant = 'sidebar' }: Props) { isDestructive className={itemClasses(isSidebar, true)} > - Sign out + {t('signOutModal.buttons.signOut')} diff --git a/packages/browser/src/components/book/BookCard.tsx b/packages/browser/src/components/book/BookCard.tsx index f6e43d710c..f6d0319c44 100644 --- a/packages/browser/src/components/book/BookCard.tsx +++ b/packages/browser/src/components/book/BookCard.tsx @@ -2,7 +2,7 @@ import { getThumbnailTintColor } from '@stump/client' import { formatBytes } from '@stump/client' import { cn, ProgressBar, Text } from '@stump/components' import { FragmentType, graphql, useFragment } from '@stump/graphql' -import pluralize from 'pluralize' +import { useLocaleContext } from '@stump/i18n' import { memo, useCallback, useMemo } from 'react' import { Link } from '@/context' @@ -65,6 +65,7 @@ const BookCard = memo(function BookCard({ onSelect, fullWidth = true, }: Props) { + const { t } = useLocaleContext() const data = useFragment(BookCardFragment, fragment) const paths = usePaths() @@ -133,7 +134,7 @@ const BookCard = memo(function BookCard({ if (isMissing) { return ( - File Missing + {t('entityUi.bookCard.fileMissing')} ) } @@ -146,7 +147,7 @@ const BookCard = memo(function BookCard({ {!isEbookProgress && ( - {pagesLeft} {pluralize('page', pagesLeft)} left + {t('entityUi.bookCard.pagesLeft', { count: pagesLeft })} )}
@@ -154,7 +155,7 @@ const BookCard = memo(function BookCard({ } else if (progressPercent === 100) { return ( - Completed + {t('common.completed')} ) } diff --git a/packages/browser/src/components/book/BookSearch.tsx b/packages/browser/src/components/book/BookSearch.tsx index 0fa10a29ce..fb15cf41d1 100644 --- a/packages/browser/src/components/book/BookSearch.tsx +++ b/packages/browser/src/components/book/BookSearch.tsx @@ -1,6 +1,7 @@ import { useInfiniteGraphQL } from '@stump/client' import { Input } from '@stump/components' import { BookCardFragment, graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useState } from 'react' import { useDebouncedValue } from 'rooks' @@ -38,6 +39,7 @@ const query = graphql(` * a filter slide over. Must be used within a `FilterProvider`. */ export default function BookSearch({ onBookSelect }: Props) { + const { t } = useLocaleContext() const [search, setSearch] = useState('') const [debouncedValue] = useDebouncedValue(search, 500) @@ -70,7 +72,7 @@ export default function BookSearch({ onBookSelect }: Props) { return (
setSearch(e.target.value)} /> diff --git a/packages/browser/src/components/book/BookSearchOverlay.tsx b/packages/browser/src/components/book/BookSearchOverlay.tsx index 35f75e8f9e..8975b3a787 100644 --- a/packages/browser/src/components/book/BookSearchOverlay.tsx +++ b/packages/browser/src/components/book/BookSearchOverlay.tsx @@ -1,5 +1,6 @@ import { Button, Sheet } from '@stump/components' import { BookCardFragment } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { Search } from 'lucide-react' import { useState } from 'react' @@ -17,6 +18,7 @@ type Props = { } export default function BookSearchOverlay({ onBookSelect, sheetProps }: Props) { + const { t } = useLocaleContext() const [isOpen, setIsOpen] = useState(false) const renderTrigger = () => { @@ -27,7 +29,7 @@ export default function BookSearchOverlay({ onBookSelect, sheetProps }: Props) { return ( ) } @@ -42,8 +44,8 @@ export default function BookSearchOverlay({ onBookSelect, sheetProps }: Props) { open={isOpen} onClose={() => setIsOpen(false)} onOpen={() => setIsOpen(true)} - title="Search for a book" - description={sheetProps?.prompt || 'You can use the search bar below to find a book'} + title={t('bookSearch.title')} + description={sheetProps?.prompt || t('bookSearch.description')} trigger={renderTrigger()} size="xl" > diff --git a/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx b/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx index d816b3367c..1e5b6f44a4 100644 --- a/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx +++ b/packages/browser/src/components/book/metadata/MediaMetadataEditor.tsx @@ -132,7 +132,7 @@ export default function MediaMetadataEditor({ mediaId, data }: Props) { onClick={() => setShowMissing((prev) => !prev)} /> - Missing + {t('metadataEditor.missing')}
), @@ -268,7 +268,7 @@ export default function MediaMetadataEditor({ mediaId, data }: Props) { size: 0, }), ], - [metadata, paths, checkPermission], + [metadata, paths, checkPermission, t], ) as ColumnDef[] const items = useMemo( @@ -304,14 +304,14 @@ export default function MediaMetadataEditor({ mediaId, data }: Props) { }, onError: (error) => { console.error('Failed to update metadata', error) - toast.error('Failed to update metadata') + toast.error(t('entityUi.metadataEditor.errors.updateFailed')) }, }) const { mutate: setLocked } = useGraphQLMutation(setLockedFieldsMutation, { onError: () => { setLockedFields(new Set(metadata?.lockedFields ?? [])) - toast.error('Failed to update locked fields') + toast.error(t('entityUi.metadataEditor.errors.lockedFieldsUpdateFailed')) }, }) diff --git a/packages/browser/src/components/book/table/CoverImageCell.tsx b/packages/browser/src/components/book/table/CoverImageCell.tsx index f7ed0b5280..34f3a1ffde 100644 --- a/packages/browser/src/components/book/table/CoverImageCell.tsx +++ b/packages/browser/src/components/book/table/CoverImageCell.tsx @@ -1,4 +1,5 @@ import { useSDK } from '@stump/client' +import { useLocaleContext } from '@stump/i18n' import { Book } from 'lucide-react' import { useState } from 'react' @@ -11,6 +12,7 @@ type Props = { } export default function CoverImageCell({ id, title }: Props) { + const { t } = useLocaleContext() const { sdk } = useSDK() const { preferences: { thumbnailRatio }, @@ -41,7 +43,7 @@ export default function CoverImageCell({ id, title }: Props) { if (showFallback) { return (
resolvedName, { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Name - - ), + header: () => , id: MediaModelOrdering.Name, // TODO (graphql): should this be resovledName?, sorting by `name` is different from sorting by `resolvedName` minSize: 285, }) @@ -86,11 +83,7 @@ const pagesColumn = columnHelper.accessor('pages', { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Pages - - ), + header: () => , id: MediaModelOrdering.Pages, size: 60, }) @@ -103,11 +96,7 @@ const fileSizeColumn = columnHelper.accessor('size', { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - File Size - - ), + header: () => , id: MediaModelOrdering.Size, size: 100, }) @@ -120,11 +109,7 @@ const extensionColumn = columnHelper.accessor('extension', { ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Extension - - ), + header: () => , id: MediaModelOrdering.Extension, size: 90, }) @@ -153,11 +138,7 @@ const publishedColumn = columnHelper.accessor( enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Published - - ), + header: () => , id: 'published', }, ) @@ -180,11 +161,7 @@ const addedColumn = columnHelper.accessor( ), enableGlobalFilter: true, enableSorting: true, - header: () => ( - - Added - - ), + header: () => , id: MediaModelOrdering.CreatedAt, }, ) @@ -198,11 +175,7 @@ const publisherColumn = columnHelper.accessor(({ metadata }) => metadata?.publis enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Publisher - - ), + header: () => , id: 'publisher', }) @@ -215,11 +188,7 @@ const ageRatingColumn = columnHelper.accessor(({ metadata }) => metadata?.ageRat enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Age Rating - - ), + header: () => , id: 'age_rating', }) @@ -228,11 +197,7 @@ const genresColumn = columnHelper.accessor(({ metadata }) => metadata?.genres, { enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Genres - - ), + header: () => , id: 'genres', }) @@ -245,11 +210,7 @@ const volumeColumn = columnHelper.accessor(({ metadata }) => metadata?.volume, { enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Volume - - ), + header: () => , id: 'volume', }) @@ -258,11 +219,7 @@ const inkersColumn = columnHelper.accessor(({ metadata }) => metadata?.inkers, { enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Inkers - - ), + header: () => , id: 'inkers', }) @@ -271,11 +228,7 @@ const writersColumn = columnHelper.accessor(({ metadata }) => metadata?.writers, enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Writers - - ), + header: () => , id: 'writers', }) @@ -284,11 +237,7 @@ const pencillersColumn = columnHelper.accessor(({ metadata }) => metadata?.penci enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Pencillers - - ), + header: () => , id: 'pencillers', }) @@ -297,11 +246,7 @@ const coloristsColumn = columnHelper.accessor(({ metadata }) => metadata?.colori enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Colorists - - ), + header: () => , id: 'colorists', }) @@ -311,11 +256,7 @@ const letterersColumn = columnHelper.accessor(({ metadata }) => metadata?.letter enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Letterers - - ), + header: () => , id: 'letterers', }) @@ -325,11 +266,7 @@ const artistsColumn = columnHelper.accessor(({ metadata }) => metadata?.coverArt enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Artists - - ), + header: () => , id: 'artists', }) @@ -339,11 +276,7 @@ const charactersColumn = columnHelper.accessor(({ metadata }) => metadata?.chara enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Characters - - ), + header: () => , id: 'characters', }) @@ -357,11 +290,7 @@ const linksColumn = columnHelper.accessor(({ metadata }) => metadata?.links?.joi enableGlobalFilter: true, // TODO(relation-ordering): Support order by relation enableSorting: false, - header: () => ( - - Links - - ), + header: () => , id: 'links', }) @@ -419,28 +348,27 @@ export const columnMap = { position: positionColumn, } as Record> -// TODO: localization keys instead of hardcoded strings export const columnOptionMap: Record = { - added: 'Added', - age_rating: 'Age Rating', - artists: 'Artists', - characters: 'Characters', - colorists: 'Colorists', - cover: 'Cover', - extension: 'Extension', - file_size: 'File Size', - genres: 'Genres', - inkers: 'Inkers', - letterers: 'Letterers', - links: 'Links', - name: 'Name', - pages: 'Pages', - pencillers: 'Pencillers', - published: 'Published', - publisher: 'Publisher', - volume: 'Volume', - writers: 'Writers', - position: 'Position', + added: 'tableColumns.labels.added', + age_rating: 'tableColumns.labels.age_rating', + artists: 'tableColumns.labels.artists', + characters: 'tableColumns.labels.characters', + colorists: 'tableColumns.labels.colorists', + cover: 'tableColumns.labels.cover', + extension: 'tableColumns.labels.extension', + file_size: 'tableColumns.labels.file_size', + genres: 'tableColumns.labels.genres', + inkers: 'tableColumns.labels.inkers', + letterers: 'tableColumns.labels.letterers', + links: 'tableColumns.labels.links', + name: 'tableColumns.labels.name', + pages: 'tableColumns.labels.pages', + pencillers: 'tableColumns.labels.pencillers', + published: 'tableColumns.labels.published', + publisher: 'tableColumns.labels.publisher', + volume: 'tableColumns.labels.volume', + writers: 'tableColumns.labels.writers', + position: 'tableColumns.labels.position', } export const defaultColumns = [ diff --git a/packages/browser/src/components/bookClub/DeleteBookClubConfirmation.tsx b/packages/browser/src/components/bookClub/DeleteBookClubConfirmation.tsx index 71f0db427d..a735e3a14b 100644 --- a/packages/browser/src/components/bookClub/DeleteBookClubConfirmation.tsx +++ b/packages/browser/src/components/bookClub/DeleteBookClubConfirmation.tsx @@ -1,6 +1,7 @@ import { useGraphQLMutation } from '@stump/client' import { ConfirmationModal } from '@stump/components' import { graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import { useNavigate } from 'react-router' import { toast } from 'sonner' @@ -22,6 +23,7 @@ type Props = { } export default function DeleteBookClubConfirmation({ isOpen, id, onClose, trigger }: Props) { + const { t } = useLocaleContext() const navigate = useNavigate() const { mutate: deleteClub, isPending } = useGraphQLMutation(mutation, { @@ -31,15 +33,15 @@ export default function DeleteBookClubConfirmation({ isOpen, id, onClose, trigge }, onError: (error) => { console.error('Error deleting book club:', error) - toast.error('Failed to delete book club') + toast.error(t('bookClubUi.deleteConfirmation.error')) }, }) return ( } export default function BookClubBookItem({ data }: Props) { + const { t } = useLocaleContext() const book = useFragment(fragment, data) const { bookClub } = useBookClubContext() @@ -50,12 +51,16 @@ export default function BookClubBookItem({ data }: Props) { let message if (isCurrent) { - message = `Started ${formatDistanceToNow(startedAt, { addSuffix: true })}` + message = t('bookClubUi.bookItem.started', { + date: formatDistanceToNow(startedAt, { addSuffix: true }), + }) } else if (completedAt) { const daysAgo = differenceInDays(new Date(), completedAt) - message = `Completed ${daysAgo} ${pluralize('day', daysAgo)} ago` + message = t('bookClubUi.bookItem.completedDaysAgo', { count: daysAgo }) } else { - message = `Added ${formatDistanceToNow(startedAt, { addSuffix: true })}` + message = t('common.addedAt', { + date: formatDistanceToNow(startedAt, { addSuffix: true }), + }) } return { @@ -63,7 +68,7 @@ export default function BookClubBookItem({ data }: Props) { start: startedAt, end: completedAt, } - }, [book, isCurrent]) + }, [book, isCurrent, t]) // const discussionInfo = useMemo(() => { // const archived = !isCurrent && !isDiscussing @@ -81,13 +86,13 @@ export default function BookClubBookItem({ data }: Props) { if (isCurrent) { return ( - Currently reading + {t('bookClubUi.bookItem.currentlyReading')} ) } else { return ( - Past book + {t('bookClubUi.bookItem.pastBook')} ) } @@ -119,7 +124,7 @@ export default function BookClubBookItem({ data }: Props) { ) const link = details?.url const isExternal = !book.entity - const heading = details?.title ?? 'Untitled' + const heading = details?.title ?? t('common.unknownTitle') const author = details?.author return ( @@ -133,7 +138,9 @@ export default function BookClubBookItem({ data }: Props) { {author && {author}} {link && ( - {isExternal ? 'External link' : 'Access book'} + {isExternal + ? t('bookClubUi.bookItem.externalLink') + : t('bookClubUi.bookItem.accessBook')} )}
@@ -169,11 +176,10 @@ export default function BookClubBookItem({ data }: Props) { )} > {daysInfo.end - ? `Read for ${differenceInDays(daysInfo.end, daysInfo.start)} ${pluralize( - 'day', - differenceInDays(daysInfo.end, daysInfo.start), - )}` - : 'Not completed yet'} + ? t('bookClubUi.bookItem.readingDuration', { + count: differenceInDays(daysInfo.end, daysInfo.start), + }) + : t('bookClubUi.bookItem.notCompletedYet')}
)} diff --git a/packages/browser/src/components/bookClub/books/BookClubBooks.tsx b/packages/browser/src/components/bookClub/books/BookClubBooks.tsx index 43a8ee2795..88ff5cd94a 100644 --- a/packages/browser/src/components/bookClub/books/BookClubBooks.tsx +++ b/packages/browser/src/components/bookClub/books/BookClubBooks.tsx @@ -1,6 +1,7 @@ import { useGraphQL } from '@stump/client' import { ButtonOrLink, cn, Heading, ScrollArea, Text } from '@stump/components' import { graphql } from '@stump/graphql' +import { useLocaleContext } from '@stump/i18n' import AutoSizer from 'react-virtualized-auto-sizer' import { useMediaMatch, useToggle } from 'rooks' @@ -27,6 +28,7 @@ const query = graphql(` `) export default function BookClubBooks() { + const { t } = useLocaleContext() const { bookClub, viewerCanManage } = useBookClubContext() const isMobile = useMediaMatch('(max-width: 768px)') @@ -73,7 +75,9 @@ export default function BookClubBooks() { onClick={togglePastBooks} > - {showPastBooks ? 'Hide' : 'Show'} past books + {showPastBooks + ? t('bookClubUi.booksList.actions.hidePast') + : t('bookClubUi.booksList.actions.showPast')}
@@ -95,14 +99,14 @@ export default function BookClubBooks() { return (
{viewerCanManage && ( - Create a schedule + {t('bookClubUi.booksList.empty.createSchedule')} )}
@@ -126,7 +130,7 @@ export default function BookClubBooks() {
{!!bookClub.currentBook && ( - Books + {t('bookClubUi.booksList.title')} )} {renderContent()} diff --git a/packages/browser/src/components/explorer/FileExplorer.tsx b/packages/browser/src/components/explorer/FileExplorer.tsx index c0499a0cc1..d957681e77 100644 --- a/packages/browser/src/components/explorer/FileExplorer.tsx +++ b/packages/browser/src/components/explorer/FileExplorer.tsx @@ -1,3 +1,5 @@ +import { useLocaleContext } from '@stump/i18n' + import GenericEmptyState from '@/components/GenericEmptyState' import { useFileExplorerContext } from './context' @@ -8,12 +10,16 @@ import { FileTable } from './table' // This is not optimal, and should be refactored to issue one query and match on the client export default function FileExplorer() { + const { t } = useLocaleContext() const { files, layout } = useFileExplorerContext() if (!files.length) { return (
- +
) } diff --git a/packages/browser/src/components/explorer/FileExplorerFooter.tsx b/packages/browser/src/components/explorer/FileExplorerFooter.tsx index e7b6f36fdc..c1c5c19cf8 100644 --- a/packages/browser/src/components/explorer/FileExplorerFooter.tsx +++ b/packages/browser/src/components/explorer/FileExplorerFooter.tsx @@ -1,3 +1,4 @@ +import { useLocaleContext } from '@stump/i18n' import { ChevronRight } from 'lucide-react' import { Fragment, useMemo } from 'react' @@ -6,10 +7,11 @@ import { useFileExplorerContext } from './context' export const FOOTER_HEIGHT = 40 export default function FileExplorerFooter() { + const { t } = useLocaleContext() const { currentPath, rootPath, navigateToPath } = useFileExplorerContext() const pathSegments = useMemo(() => { - const rootName = rootPath.split('/').filter(Boolean).pop() ?? 'Library' + const rootName = rootPath.split('/').filter(Boolean).pop() ?? t('common.library') if (!currentPath || currentPath === rootPath) { return [{ name: rootName, path: rootPath }] @@ -25,7 +27,7 @@ export default function FileExplorerFooter() { path: rootPath + '/' + parts.slice(0, i + 1).join('/'), })), ] - }, [currentPath, rootPath]) + }, [currentPath, rootPath, t]) return (