diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..95942c8 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,91 @@ +You are an expert in React Router 7, TailwindCSS, and TypeScript, focusing on scalable web development. + +**Key Principles** + +- Provide clear, precise React Router 7 and TypeScript examples. +- Apply immutability and pure functions where applicable. +- Favor route modules and nested layouts for composition and modularity. +- Use meaningful variable names (e.g., `isAuthenticated`, `userRole`). +- Always use kebab-case for file names (e.g., `user-profile.tsx`). +- Prefer named exports for loaders, actions, and components. + +**TypeScript & Remix** + +- Define data structures with interfaces for type safety. +- Avoid the `any` type, fully utilize TypeScript's type system. +- Organize files: imports, loaders/actions, component logic. +- Use template strings for multi-line literals. +- Utilize optional chaining and nullish coalescing. +- Use nested layouts and dynamic routes where applicable. +- Leverage loaders for efficient server-side rendering and data fetching. +- Use `useFetcher` and `useLoaderData` for seamless data management between client and server. + +**File Naming Conventions** + +- `*.tsx` for React components +- `*.ts` for utilities, types, and configurations +- `root.tsx` for the root layout +- All files use kebab-case. + +**Code Style** + +- Use single quotes for string literals. +- Indent with 2 spaces. +- Ensure clean code with no trailing whitespace. +- Use `const` for immutable variables. +- Use template strings for string interpolation. + +**Remix-Specific Guidelines** + +- Routes are mapped from URL schemas to files in `app/routes.ts` +- Use `` for navigation, avoiding full page reloads. +- Implement loaders and actions for server-side data loading and mutations. +- Import type `Route` from `./+types/{route-filename}.ts` to type loaders and actions. +- Ensure accessibility with semantic HTML and ARIA labels. +- Leverage route-based loading, error boundaries, and catch boundaries. +- Use the `useFetcher` hook for non-blocking data updates. +- Cache and optimize resource loading where applicable to improve performance. + +**Import Order** + +1. React Router 7 core modules +2. React and other core libraries +3. Third-party packages +4. Application-specific imports +5. Environment-specific imports +6. Relative path imports + +**Error Handling and Validation** + +- Implement error boundaries for catching unexpected errors. +- Use custom error handling within loaders and actions. +- Validate user input on both client and server using formData or JSON. + +**Testing** + +- Use `@testing-library/react` for component testing. +- Write tests for loaders and actions ensuring data correctness. +- Mock fetch requests and responses where applicable. + +**Performance Optimization** + +- Prefetch routes using `` for faster navigation. +- Defer non-essential JavaScript using ``. +- Optimize nested layouts to minimize re-rendering. +- Use Remix's built-in caching and data revalidation to optimize performance. + +**Security** + +- Prevent XSS by sanitizing user-generated content. +- Use Remix's CSRF protection for form submissions. +- Handle sensitive data on the server, never expose in client code. + +**Key Conventions** + +- Use React Router 7's loaders and actions to handle server-side logic. +- Focus on reusability and modularity across routes and components. +- Follow Remix’s best practices for file structure and data fetching. +- Optimize for performance and accessibility. + +**Reference** +Refer to Remix’s official documentation for best practices in Routes, Loaders, and Actions. diff --git a/.eslintrc b/.eslintrc index 5205f98..2b510c4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -19,7 +19,9 @@ "parser": "@typescript-eslint/parser", "settings": { "import/resolver": { - "typescript": true + "typescript": { + "project": "./tsconfig.json" + } } }, "plugins": ["unused-imports", "react", "@typescript-eslint", "import", "prettier"], diff --git a/.husky/pre-commit b/.husky/pre-commit index fe9f891..1b35ad6 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" # npm run typecheck -npx lint-staged +# npx lint-staged diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e9eafa3..f03767a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,8 @@ "streetsidesoftware.code-spell-checker", "bradlc.vscode-tailwindcss", "eamodio.gitlens", - "esbenp.prettier-vscode" + "esbenp.prettier-vscode", + "Lokalise.i18n-ally", + "inlang.vs-code-extension" ] -} +} \ No newline at end of file diff --git a/.vscode/i18n-ally-reviews.yml b/.vscode/i18n-ally-reviews.yml new file mode 100644 index 0000000..e79af03 --- /dev/null +++ b/.vscode/i18n-ally-reviews.yml @@ -0,0 +1,3 @@ +# Review comments generated by i18n-ally. Please commit this file. + +reviews: diff --git a/.vscode/settings.json b/.vscode/settings.json index 3f9b2b8..3398fb0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,11 @@ "className", "ngClass", "tw" - ] + ], + "i18n-ally.localesPaths": [ + "app/i18n/locales", + ], + "i18n-ally.sourceLanguage": "en", + "i18n-ally.annotations": true, + "i18n-ally.keystyle": "nested" } diff --git a/src/assets/icons/cognactive-icon.svg b/app/assets/icons/cognactive-icon.svg similarity index 100% rename from src/assets/icons/cognactive-icon.svg rename to app/assets/icons/cognactive-icon.svg diff --git a/src/assets/icons/cognactive-icon.tsx b/app/assets/icons/cognactive-icon.tsx similarity index 100% rename from src/assets/icons/cognactive-icon.tsx rename to app/assets/icons/cognactive-icon.tsx diff --git a/src/assets/icons/cognactive-symbol.css b/app/assets/icons/cognactive-symbol.css similarity index 100% rename from src/assets/icons/cognactive-symbol.css rename to app/assets/icons/cognactive-symbol.css diff --git a/src/assets/icons/detail.svg b/app/assets/icons/detail.svg similarity index 100% rename from src/assets/icons/detail.svg rename to app/assets/icons/detail.svg diff --git a/src/components/brand/cognacitve-symbol.tsx b/app/components/brand/cognacitve-symbol.tsx similarity index 100% rename from src/components/brand/cognacitve-symbol.tsx rename to app/components/brand/cognacitve-symbol.tsx diff --git a/src/components/content-header.tsx/index.tsx b/app/components/content-header.tsx/index.tsx similarity index 100% rename from src/components/content-header.tsx/index.tsx rename to app/components/content-header.tsx/index.tsx diff --git a/src/components/date-picker/index.tsx b/app/components/date-picker/index.tsx similarity index 77% rename from src/components/date-picker/index.tsx rename to app/components/date-picker/index.tsx index 9fc88f6..d7637df 100644 --- a/src/components/date-picker/index.tsx +++ b/app/components/date-picker/index.tsx @@ -1,13 +1,15 @@ 'use client' import * as React from 'react' -import { format } from 'date-fns' +import { intlFormat } from 'date-fns' import { Calendar as CalendarIcon } from 'lucide-react' import { cn } from 'src/lib/utils' import { Button } from 'src/components/ui/button' import { Calendar } from 'src/components/ui/calendar' import { Popover, PopoverContent, PopoverTrigger } from 'src/components/ui/popover' +import { useLocale } from 'remix-i18next/react' +import { useTranslation } from 'react-i18next' type DatePickerProps = { currentDate?: Date @@ -23,6 +25,8 @@ export const DatePicker: React.FC = (props) => { setDate(props.currentDate) }, [props.currentDate]) + const locale = useLocale('lang') + const { t } = useTranslation() return ( @@ -31,7 +35,11 @@ export const DatePicker: React.FC = (props) => { className={cn('w-[280px] justify-start text-left font-normal', !date && 'text-muted-foreground')} > - {date ? format(date, 'PPP') : Pick a date} + {date ? ( + intlFormat(date, { month: 'long', day: 'numeric', year: 'numeric' }, { locale: locale }) + ) : ( + {t('tracker.pick_date')} + )} diff --git a/src/components/error-page/index.tsx b/app/components/error-page/index.tsx similarity index 100% rename from src/components/error-page/index.tsx rename to app/components/error-page/index.tsx diff --git a/app/components/footer/index.tsx b/app/components/footer/index.tsx new file mode 100644 index 0000000..54cc367 --- /dev/null +++ b/app/components/footer/index.tsx @@ -0,0 +1,47 @@ +import { Link } from 'react-router' +import CognactiveIcon from 'src/assets/icons/cognactive-icon' +import { ArrowUpRightSquare, Github } from 'lucide-react' +import { GITHUB_REPO_BASE, TELEGRAM_CHAT_LINK } from 'src/constants' +import { Button } from '../ui/button' +import { LanguageSelector } from '../language-selector' +import { useTranslation } from 'react-i18next' + +const Footer: React.FC = () => { + const { t } = useTranslation() + + return ( +
+
+
+
+ + + + + +
+ +
+

+ + {t('footer.data-usage-title')} + +

+

+ {t('footer.medical-disclaimer')}: {t('footer.medical-disclaimer-desc')} +

+
+
+ ) +} + +export default Footer diff --git a/src/components/header/index.tsx b/app/components/header/index.tsx similarity index 84% rename from src/components/header/index.tsx rename to app/components/header/index.tsx index 6ddd4c0..f48e2b2 100644 --- a/src/components/header/index.tsx +++ b/app/components/header/index.tsx @@ -1,18 +1,18 @@ -import React from 'react' - import CognactiveIcon from 'src/assets/icons/cognactive-icon' import { cn } from 'src/lib/utils' import { Button } from '../ui/button' import { Github } from 'lucide-react' import { Link, useNavigation, useMatches } from 'react-router' import { useSpinDelay } from 'spin-delay' +import { useTranslation } from 'react-i18next' +// import { LanguageSelector } from '../language-selector' interface IProps { className?: string } export function Header(props: IProps) { const matches = useMatches() - const isHomepage = matches.findIndex((m) => m.pathname === '/') == matches.length - 1 + const isHomepage = matches.every((m) => m.pathname === '/') const transition = useNavigation() const busy = transition.state !== 'idle' @@ -21,6 +21,8 @@ export function Header(props: IProps) { minDuration: 300, }) + const { t } = useTranslation() + return (
- {/* */} {isHomepage && ( diff --git a/src/components/hero/index.tsx b/app/components/hero/index.tsx similarity index 79% rename from src/components/hero/index.tsx rename to app/components/hero/index.tsx index 5570ea4..46b2929 100644 --- a/src/components/hero/index.tsx +++ b/app/components/hero/index.tsx @@ -6,15 +6,15 @@ import { LayoutGridIcon, Scan, MonitorPlay, + FlameIcon, } from 'lucide-react' import CognactiveIcon from 'src/assets/icons/cognactive-icon' import { Button } from '../ui/button' import { Link } from 'react-router' -// Temporary. See docs/decisions/02-i18n -import t from 'src/i18n/locales/en/translation.json' import { GITHUB_REPO_BASE } from 'src/constants' +import { useTranslation } from 'react-i18next' interface FeatureProps { icon: React.ElementType @@ -46,9 +46,11 @@ const HeroFeature: React.FC = ({ icon: Icon, title, description, l } export const Hero = () => { + const { t } = useTranslation() + return (
-
+
@@ -57,21 +59,20 @@ export const Hero = () => {

- {t['hero-title']} + {t('homepage.hero-title')}

-

Open source tools and tech for the anti-fungal NAC protocol.

+

{t('homepage.hero-subtitle')}

{/* -
-

What is the NAC protocol?

+

{t('homepage.whitepaper-label')}

@@ -107,18 +108,18 @@ export const Hero = () => {
} - title={t['tracking']} - description={t['tracking-desc']} + title={t('homepage.tracking')} + description={t('homepage.tracking-desc')} /> } - title={t['hero-appexperience']} - description={t['hero-appexperience-desc']} + title={t('homepage.hero-appexperience')} + description={t('homepage.hero-appexperience-desc')} /> } - title={t['hero-opensource']} - description={t['hero-opensource-desc']} + title={t('homepage.hero-opensource')} + description={t('homepage.hero-opensource-desc')} link={GITHUB_REPO_BASE} linkText="Source code available here." /> @@ -126,7 +127,7 @@ export const Hero = () => {
-
+
) } diff --git a/app/components/language-selector/index.tsx b/app/components/language-selector/index.tsx new file mode 100644 index 0000000..bb340e4 --- /dev/null +++ b/app/components/language-selector/index.tsx @@ -0,0 +1,81 @@ +import { useTranslation } from 'react-i18next' +import { useCallback, useMemo, useState } from 'react' +import { Languages, ChevronDown } from 'lucide-react' +import i18next from 'i18next' +import { Button } from '../ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog' +import { cn } from 'src/lib/utils' +import { supportedLanguages } from 'app/localization/resource' + +const getLocaleDisplayName = (locale: string, displayLocale?: string) => { + const displayName = new Intl.DisplayNames([displayLocale || locale], { + type: 'language', + }).of(locale) + return displayName ? displayName.charAt(0).toLocaleUpperCase() + displayName.slice(1) : locale +} + +type LanguageSelectorProps = { + triggerClassName?: string +} + +const LanguageSelector = ({ triggerClassName }: LanguageSelectorProps) => { + const { i18n } = useTranslation() + const [open, setOpen] = useState(false) + + const localesAndNames = useMemo(() => { + return supportedLanguages.map((locale) => ({ + locale, + name: getLocaleDisplayName(locale), + })) + }, []) + + const languageChanged = useCallback(async (locale: any) => { + i18next.changeLanguage(locale) + const searchParams = new URLSearchParams(window.location.search) + searchParams.set('lng', locale) + window.history.replaceState(null, '', `${window.location.pathname}?${searchParams.toString()}`) + setOpen(false) + }, []) + + const { resolvedLanguage: currentLanguage } = i18n + + return ( + + + + + + + Select Language + +
+ {localesAndNames.map(({ locale, name }) => { + const isSelected = currentLanguage === locale + return ( +
+ + +
+ ) + })} +
+
+
+ ) +} + +export { LanguageSelector } diff --git a/src/components/layout/index.tsx b/app/components/layout/index.tsx similarity index 100% rename from src/components/layout/index.tsx rename to app/components/layout/index.tsx diff --git a/src/components/lore/index.tsx b/app/components/lore/index.tsx similarity index 100% rename from src/components/lore/index.tsx rename to app/components/lore/index.tsx diff --git a/src/components/ui/alert.tsx b/app/components/ui/alert.tsx similarity index 100% rename from src/components/ui/alert.tsx rename to app/components/ui/alert.tsx diff --git a/src/components/ui/button.tsx b/app/components/ui/button.tsx similarity index 100% rename from src/components/ui/button.tsx rename to app/components/ui/button.tsx diff --git a/src/components/ui/calendar.tsx b/app/components/ui/calendar.tsx similarity index 100% rename from src/components/ui/calendar.tsx rename to app/components/ui/calendar.tsx diff --git a/src/components/ui/card.tsx b/app/components/ui/card.tsx similarity index 100% rename from src/components/ui/card.tsx rename to app/components/ui/card.tsx diff --git a/src/components/ui/carousel.tsx b/app/components/ui/carousel.tsx similarity index 100% rename from src/components/ui/carousel.tsx rename to app/components/ui/carousel.tsx diff --git a/src/components/ui/dialog.tsx b/app/components/ui/dialog.tsx similarity index 100% rename from src/components/ui/dialog.tsx rename to app/components/ui/dialog.tsx diff --git a/src/components/ui/hero-video-dialog.tsx b/app/components/ui/hero-video-dialog.tsx similarity index 100% rename from src/components/ui/hero-video-dialog.tsx rename to app/components/ui/hero-video-dialog.tsx diff --git a/src/components/ui/input.tsx b/app/components/ui/input.tsx similarity index 100% rename from src/components/ui/input.tsx rename to app/components/ui/input.tsx diff --git a/src/components/ui/label.tsx b/app/components/ui/label.tsx similarity index 100% rename from src/components/ui/label.tsx rename to app/components/ui/label.tsx diff --git a/src/components/ui/meteors.tsx b/app/components/ui/meteors.tsx similarity index 100% rename from src/components/ui/meteors.tsx rename to app/components/ui/meteors.tsx diff --git a/src/components/ui/pagination.tsx b/app/components/ui/pagination.tsx similarity index 100% rename from src/components/ui/pagination.tsx rename to app/components/ui/pagination.tsx diff --git a/src/components/ui/popover.tsx b/app/components/ui/popover.tsx similarity index 100% rename from src/components/ui/popover.tsx rename to app/components/ui/popover.tsx diff --git a/src/components/ui/progress.tsx b/app/components/ui/progress.tsx similarity index 100% rename from src/components/ui/progress.tsx rename to app/components/ui/progress.tsx diff --git a/src/components/ui/sheet.tsx b/app/components/ui/sheet.tsx similarity index 100% rename from src/components/ui/sheet.tsx rename to app/components/ui/sheet.tsx diff --git a/src/components/ui/switch.tsx b/app/components/ui/switch.tsx similarity index 100% rename from src/components/ui/switch.tsx rename to app/components/ui/switch.tsx diff --git a/src/components/ui/table.tsx b/app/components/ui/table.tsx similarity index 100% rename from src/components/ui/table.tsx rename to app/components/ui/table.tsx diff --git a/src/components/ui/textarea.tsx b/app/components/ui/textarea.tsx similarity index 100% rename from src/components/ui/textarea.tsx rename to app/components/ui/textarea.tsx diff --git a/src/constants.ts b/app/constants.ts similarity index 100% rename from src/constants.ts rename to app/constants.ts diff --git a/src/data/studies.tsx b/app/data/studies.tsx similarity index 100% rename from src/data/studies.tsx rename to app/data/studies.tsx diff --git a/app/entry.client.tsx b/app/entry.client.tsx index 319d5e0..1f000d4 100644 --- a/app/entry.client.tsx +++ b/app/entry.client.tsx @@ -1,12 +1,55 @@ import React, { startTransition, StrictMode } from 'react' +import i18next from 'i18next' +import LanguageDetector from 'i18next-browser-languagedetector' +import Fetch from 'i18next-fetch-backend' + +import { I18nextProvider, initReactI18next } from 'react-i18next' import { HydratedRouter } from 'react-router/dom' +import { getInitialNamespaces } from 'remix-i18next/client' +import i18n from '@/localization/i18n' + import { hydrateRoot } from 'react-dom/client' -startTransition(() => { - hydrateRoot( - document, - - - , - ) -}) +async function hydrate() { + // eslint-disable-next-line import/no-named-as-default-member + await i18next + .use(initReactI18next) // Tell i18next to use the react-i18next plugin + .use(LanguageDetector) // Setup a client-side language detector + .use(Fetch) // Setup your backend + .init({ + ...i18n, // spread the configuration + // This function detects the namespaces your routes rendered while SSR use + ns: getInitialNamespaces(), + backend: { + loadPath: '/resource/locales?lng={{lng}}&ns={{ns}}', + }, + detection: { + // Here only enable htmlTag detection, we'll detect the language only + // server-side with remix-i18next, by using the `` attribute + // we can communicate to the client the language detected server-side + order: ['htmlTag'], + // Because we only use htmlTag, there's no reason to cache the language + // on the browser, so we disable it + caches: [], + }, + }) + + startTransition(() => { + hydrateRoot( + document, + + + + + , + ) + }) +} + +if (window.requestIdleCallback) { + window.requestIdleCallback(hydrate) +} else { + // Safari doesn't support requestIdleCallback + // https://caniuse.com/requestidlecallback + window.setTimeout(hydrate, 1) +} diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 5962f26..6c15a6e 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -5,6 +5,12 @@ import type { AppLoadContext, EntryContext } from 'react-router' import { createReadableStreamFromReadable } from '@react-router/node' import { ServerRouter } from 'react-router' import { isbot } from 'isbot' +import { createInstance } from 'i18next' +import { I18nextProvider, initReactI18next } from 'react-i18next' +import i18n from './localization/i18n' // i18n configuration file +import i18nextOpts from './localization/i18n.server' +import { resources } from './localization/resource' + import { renderToPipeableStream } from 'react-dom/server' const ABORT_DELAY = 5_000 @@ -18,7 +24,7 @@ export default function handleRequest( ) { return isbot(request.headers.get('user-agent') || '') ? handleBotRequest(request, responseStatusCode, responseHeaders, reactRouterContext) - : handleBrowserRequest(request, responseStatusCode, responseHeaders, reactRouterContext) + : handleBrowserRequest(request, responseStatusCode, responseHeaders, reactRouterContext, loadContext) } function handleBotRequest( @@ -59,30 +65,44 @@ function handleBotRequest( }) } -function handleBrowserRequest( +async function handleBrowserRequest( request: Request, responseStatusCode: number, responseHeaders: Headers, reactRouterContext: EntryContext, + appContext: AppLoadContext, ) { + const instance = createInstance() + const lng = appContext.lang + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ns = i18nextOpts.getRouteNamespaces(reactRouterContext as any) + + await instance + .use(initReactI18next) // Tell our instance to use react-i18next + .init({ + ...i18n, // spread the configuration + lng, // The locale we detected above + ns, // The namespaces the routes about to render wants to use + resources, + }) + return new Promise((resolve, reject) => { + let didError = false const { pipe, abort } = renderToPipeableStream( - , + + + , { onShellReady() { const body = new PassThrough() - + const stream = createReadableStreamFromReadable(body) responseHeaders.set('Content-Type', 'text/html') - const url = new URL(request.url) - if (url.pathname.match(/^\/videos\/.+\.mp4$/)) { - responseHeaders.set('Cache-Control', 'public, max-age=604800') // 1 week - } - resolve( - new Response(createReadableStreamFromReadable(body), { + // @ts-expect-error - We purposely do not define the body as existent so it's not used inside loaders as it's injected there as well + appContext.body(stream, { headers: responseHeaders, - status: responseStatusCode, + status: didError ? 500 : responseStatusCode, }), ) @@ -92,6 +112,8 @@ function handleBrowserRequest( reject(error) }, onError(error: unknown) { + didError = true + console.error(error) responseStatusCode = 500 }, diff --git a/src/i18n/locales/en/notfound.json b/app/i18n/locales/en/notfound.json similarity index 100% rename from src/i18n/locales/en/notfound.json rename to app/i18n/locales/en/notfound.json diff --git a/app/i18n/locales/en/translation.json b/app/i18n/locales/en/translation.json new file mode 100644 index 0000000..936cc68 --- /dev/null +++ b/app/i18n/locales/en/translation.json @@ -0,0 +1,155 @@ +{ + "title": "cognactive", + "footer": { + "data-usage-title": "Data Usage & Privacy Policy", + "medical-disclaimer": "Medical Disclaimer", + "medical-disclaimer-desc": "This website is provided for educational and informational purposes only and does not constitute providing medical advice or professional services. Nothing herein should be construed as an attempt to diagnose, treat, cure, or prevent any condition, illness, or disease, and those seeking medical advice should consult with a licensed physician.", + "chat-nac-link-title": "Chat NAC" + }, + "privacy": { + "title": "Privacy & Data Usage", + "policy-link": "Read full privacy policy", + "regimen-title": "Protocol Regimen Tracking Utility", + "dimensions": { + "local-storage": { + "title": "Local Data Privacy", + "description": "Regimen activity data is stored only on your device and never transmitted to servers." + }, + "third-party": { + "title": "No Third-Party Sharing", + "description": "Regimen activity data is not shared with third parties." + } + } + }, + "homepage": { + "github-link-title": "View Code", + "tracker-link-title": "Launch Tracker", + "ingredient-scanner-link-title": "Ingredient Scanner", + "tracking": "Track your NAC", + "tracking-desc": "Track NAC protocol regimen, myco die-off experiences and more. Respect for your privacy with local data storage. Flexible with support for customizable regimen.", + "hero-appexperience": "App your NAC", + "hero-appexperience-desc": "Take the cognactive toolbelt with you on your journey. Install the cognactive mobile web app to your home screen and use it like an app. Compatible with iOS and Android.", + "hero-opensource": "Open source", + "hero-opensource-desc": "Cognactive is ready for customization, collaboration and improvements as a global community technological public good.", + "hero-title": "Track NAC Protocol", + "hero-subtitle": "Open source tools and tech for the anti-fungal and immunomodular NAC protocol.", + "fungcaster": "Watch Videos", + "cave-allegory": "Cave of Allegories", + "whitepaper-prompt": "Read whitepaper", + "whitepaper-label": "What is the NAC protocol?" + }, + "tracker": { + "pick_date": "Pick a date", + "start_tracking": "Track daily activities and myco die-off experiences for the NAC protocol.", + "start_date": "Protocol Start Date", + "start_button": "Start Tracking NAC Protocol", + "started_on": "Started NAC protocol on", + "reset_tracking": "Reset Tracking", + "reset_description": "Reset the tracking start date and delete all data.", + "reset_button": "Reset Tracker", + "reset_confirm": "Are you sure? Your progress will be reset. All activity data will be erased. This action cannot be undone.", + "reset_error": "Something went wrong while resetting tracker data", + "install_title": "Install cognactive to mobile device", + "install_description": "To continue, open this webpage from your mobile device. Then open the Share menu and select 'Add to Home Screen'.", + "privacy_title": "Privacy Policy", + "privacy_text": "All regimen activity tracking data (tracking dates, experiences, supplements) is stored locally in web browser and not shared with any third-party.", + "privacy_link": "Learn more about cognactive Privacy & Data Usage", + "legal_title": "Legal Disclaimer", + "legal_text": "The cognactive app helps track activities and events and is for personal use only. cognactive is not a medical app. Use this app at your own risk and liability.", + "dashboard": { + "day": "Day", + "date_error": "Selected date cannot be before the start date or after today.", + "date_start": "Tracking started on this day", + "daily_regimen": "Daily Regimen", + "customize": "Customize", + "morning": "Morning", + "night": "Night", + "note_journal": "Note Journal", + "read_all_notes": "Read All Notes", + "food_guide": "Food Guide" + }, + "progress_indicator": { + "phase_1": "Phase 1", + "phase_2": "Phase 2", + "two_month_milestone": "2 month milestone", + "phase_2_eligible": "Phase 2 Eligibility", + "weeks_no_die_off": "3 weeks of no die-off", + "latest_die_off": "Latest die-off symptom: {{date}}", + "phase_2_started": "started on {{date}}", + "edit": "Edit", + "cancel": "Cancel", + "three_weeks_on": "3 weeks on", + "one_week_off": "1 week off", + "phase_2_cycle_date": { + "title": "Phase 2 Cycle Start / Resume Date", + "description": "When did you start or resume phase 2? Setting this date will reset the timer." + }, + "phase_2_setup": { + "title": "Phase 2", + "optional": "Optional", + "description": "Once past the 2 month milestone, continue phase 1 as needed or prepare for phase 2 of the NAC protocol.", + "setup_button": "Setup Phase 2" + }, + "bso_only": "BSO only on week off." + }, + "die_off_symptoms": { + "title": "Myco Die-Off", + "description": "Track myco die-off symptoms and experiences", + "view_chart": "View Chart", + "total": "TOTAL", + "new_symptom": { + "placeholder": "Describe a new symptom", + "track_button": "Track", + "error": "Describe your symptom in more detail", + "success": "{{date}}\nTracked{{custom}} symptom\n{{symptom}}" + }, + "symptoms": { + "tiredness": "Tiredness", + "exhaustion": "Exhaustion", + "muscle_soreness": "Muscle soreness", + "increased_discharge": "Increased chest or nasal discharge", + "cold_flu": "Cold or flu like symptoms", + "cold_sores": "Cold sores", + "headaches": "Headaches", + "irritability": "Irritability", + "stool_changes": "Change in stool frequency, volume or color", + "rash": "Rash", + "bloated_stomach": "Bloated stomach", + "cramps": "Cramps", + "increased_gas": "Increased gas" + } + }, + "daily_note": { + "new_entry": "Write a New Journal Entry", + "whats_notable": "What's notable about {{date}}?", + "prompt": "Eat anything special? Do anything different?", + "placeholder": "Write your note here", + "privacy_notice": "Notes are only stored locally on your device", + "save_button": "Save Note", + "success": "Note saved" + }, + "trends": { + "loading": "Loading Symptom Analysis...", + "title": "Myco Die-Off", + "back_to_tracker": "Tracker", + "chart_description": "Chart showing {{period}} data", + "period": { + "weekly": "weekly", + "monthly": "monthly", + "week": "Week", + "month": "Month" + }, + "chart": { + "symptom_severity": "Symptom Severity" + } + } + }, + "memery": { + "title": "Cave of Allegories", + "platos-cave": { + "title": "Plato's Allegory of the Cave", + "description": "Plato's Allegory of the Cave describes a group of people who have lived chained to the wall of a cave all of their lives, facing a blank wall. The people watch shadows projected on the wall from objects passing in front of a fire behind them and begin to ascribe forms to these shadows. According to the allegory, the shadows are as close as the prisoners get to viewing reality. Plato suggests that the shadows are the prisoners' reality but that there is a higher, more true level of reality, hidden from human eyes.", + "relevance": "Anything that influences human thought can be characterized as the puppeteers in the allegory of the cave. Fungi like candida and cryptococcus can be considered the puppeteers in this metaphor. This is due to the influence on the gut-brain axis and residence in the brain." + } + } +} diff --git a/src/i18n/locales/es/notfound.json b/app/i18n/locales/es/notfound.json similarity index 100% rename from src/i18n/locales/es/notfound.json rename to app/i18n/locales/es/notfound.json diff --git a/app/i18n/locales/es/translation.json b/app/i18n/locales/es/translation.json new file mode 100644 index 0000000..be79d76 --- /dev/null +++ b/app/i18n/locales/es/translation.json @@ -0,0 +1,141 @@ +{ + "title": "cognactive", + "homepage": { + "get-started": "Iniciar Rastreador", + "tracker-link-title": "Iniciar Rastreador", + "github-link-title": "Ver Código", + "tracking": "Rastrea tu NAC", + "tracking-desc": "Rastrea el régimen del protocolo NAC, experiencias de eliminación de micotoxinas y más. Respeto por tu privacidad con almacenamiento de datos local. Flexible con soporte para régimen personalizable.", + "hero-appexperience": "App tu NAC", + "hero-appexperience-desc": "Lleva contigo el cinturón de herramientas cognactivas en tu viaje. Instala la aplicación web móvil cognactiva en tu pantalla de inicio y úsala como una app. Compatible con iOS y Android.", + "hero-opensource": "Código abierto", + "hero-opensource-desc": "Cognactive está lista para personalización, colaboración y mejoras como un bien público tecnológico de la comunidad global.", + "hero-title": "Rastrea el Protocolo NAC", + "fungcaster": "Ver Videos", + "ingredient-scanner-link-title": "Escáner de Ingredientes", + "hero-subtitle": "Herramientas y tecnología de código abierto para el protocolo NAC antifúngico e inmunomodulador.", + "whitepaper-prompt": "Leer el documento técnico", + "whitepaper-label": "¿Qué es el protocolo NAC?", + "cave-allegory": "Cueva de Alegorías" + }, + "footer": { + "data-usage-title": "Uso de Datos y Política de Privacidad", + "medical-disclaimer": "Descargo de Responsabilidad Médica", + "medical-disclaimer-desc": "Este sitio web se proporciona solo con fines educativos e informativos y no constituye la prestación de asesoramiento médico o servicios profesionales. Nada aquí debe interpretarse como un intento de diagnosticar, tratar, curar o prevenir ninguna condición, enfermedad o dolencia, y aquellos que busquen asesoramiento médico deben consultar con un médico licenciado.", + "chat-nac-link-title": "Chat NAC" + }, + "tracker": { + "start_tracking": "Rastrea actividades diarias y experiencias de eliminación de micotoxinas para el protocolo NAC.", + "start_date": "Fecha de Inicio del Protocolo", + "start_button": "Comenzar a Rastrear el Protocolo NAC", + "started_on": "Protocolo NAC iniciado el", + "reset_tracking": "Restablecer Seguimiento", + "reset_description": "Restablece la fecha de inicio del seguimiento y elimina todos los datos.", + "reset_button": "Restablecer Rastreador", + "reset_confirm": "¿Estás seguro? Tu progreso se restablecerá. Todos los datos de actividad serán borrados. Esta acción no se puede deshacer.", + "reset_error": "Algo salió mal al restablecer los datos del rastreador", + "install_title": "Instalar cognactive en el dispositivo móvil", + "install_description": "Para continuar, abre esta página web desde tu dispositivo móvil. Luego abre el menú Compartir y selecciona 'Agregar a la pantalla de inicio'.", + "privacy_title": "Política de Privacidad", + "privacy_text": "Todos los datos de seguimiento de actividades del régimen (fechas de seguimiento, experiencias, suplementos) se almacenan localmente en el navegador web y no se comparten con terceros.", + "privacy_link": "Aprende más sobre la Privacidad y Uso de Datos de cognactive", + "legal_title": "Aviso Legal", + "legal_text": "La aplicación cognactive ayuda a rastrear actividades y eventos y es solo para uso personal. cognactive no es una aplicación médica. Usa esta aplicación bajo tu propio riesgo y responsabilidad.", + "dashboard": { + "day": "Día", + "date_error": "La fecha seleccionada no puede ser anterior a la fecha de inicio o posterior a hoy.", + "date_start": "Seguimiento iniciado en este día", + "daily_regimen": "Régimen Diario", + "customize": "Personalizar", + "morning": "Mañana", + "night": "Noche", + "note_journal": "Diario de Notas", + "read_all_notes": "Leer Todas las Notas", + "food_guide": "Guía de Alimentos" + }, + "progress_indicator": { + "phase_1": "Fase 1", + "phase_2": "Fase 2", + "two_month_milestone": "Hito de 2 meses", + "weeks_no_die_off": "3 semanas sin eliminación", + "latest_die_off": "Último síntoma de eliminación: {{date}}", + "phase_2_started": "comenzó el {{date}}", + "edit": "Editar", + "cancel": "Cancelar", + "three_weeks_on": "3 semanas activas", + "one_week_off": "1 semana de descanso", + "phase_2_cycle_date": { + "title": "Fecha de Inicio / Reanudación del Ciclo de Fase 2", + "description": "¿Cuándo comenzaste o reanudaste la fase 2? Establecer esta fecha restablecerá el temporizador." + }, + "phase_2_setup": { + "title": "Fase 2", + "optional": "Opcional", + "description": "Una vez superado el hito de 2 meses, continúa la fase 1 según sea necesario o prepárate para la fase 2 del protocolo NAC.", + "setup_button": "Configurar Fase 2" + }, + "bso_only": "Solo BSO en la semana de descanso.", + "phase_2_eligible": "Elegibilidad para la Fase 2" + }, + "die_off_symptoms": { + "title": "Eliminación de Micotoxinas", + "description": "Rastrea los síntomas y experiencias de eliminación de micotoxinas", + "view_chart": "Ver Gráfico", + "total": "TOTAL", + "new_symptom": { + "placeholder": "Describe un nuevo síntoma", + "track_button": "Rastrear", + "error": "Describe tu síntoma con más detalle", + "success": "{{date}}\nSíntoma rastreado{{custom}}\n{{symptom}}" + }, + "symptoms": { + "tiredness": "Cansancio", + "exhaustion": "Exhausto", + "muscle_soreness": "Dolor muscular", + "increased_discharge": "Aumento de secreción nasal o torácica", + "cold_flu": "Síntomas de resfriado o gripe", + "cold_sores": "Herpes labial", + "headaches": "Dolores de cabeza", + "irritability": "Irritabilidad", + "stool_changes": "Cambio en la frecuencia, volumen o color de las heces", + "rash": "Erupción", + "bloated_stomach": "Estómago hinchado", + "cramps": "Calambres", + "increased_gas": "Aumento de gases" + } + }, + "daily_note": { + "new_entry": "Escribe una Nueva Entrada en el Diario", + "whats_notable": "¿Qué es notable sobre {{date}}?", + "prompt": "¿Comiste algo especial? ¿Hiciste algo diferente?", + "placeholder": "Escribe tu nota aquí", + "privacy_notice": "Las notas solo se almacenan localmente en tu dispositivo", + "save_button": "Guardar Nota", + "success": "Nota guardada" + }, + "trends": { + "loading": "Cargando Análisis de Síntomas...", + "title": "Eliminación de Micotoxinas", + "back_to_tracker": "Rastreador", + "chart_description": "Gráfico que muestra datos de {{period}}", + "period": { + "weekly": "semanal", + "monthly": "mensual", + "week": "Semana", + "month": "Mes" + }, + "chart": { + "symptom_severity": "Severidad del Síntoma" + } + }, + "pick_date": "Elige una fecha" + }, + "memery": { + "platos-cave": { + "title": "La Alegoría de la Cueva de Platón", + "description": "La Alegoría de la Cueva de Platón describe a un grupo de personas que han vivido encadenadas a la pared de una cueva toda su vida, frente a una pared en blanco. Las personas observan sombras proyectadas en la pared por objetos que pasan frente a un fuego detrás de ellas y comienzan a atribuir formas a estas sombras. Según la alegoría, las sombras son lo más cerca que los prisioneros están de ver la realidad. Platón sugiere que las sombras son la realidad de los prisioneros, pero que hay un nivel de realidad más alto y verdadero, oculto a los ojos humanos.", + "relevance": "Cualquier cosa que influya en el pensamiento humano puede caracterizarse como los titiriteros en la alegoría de la cueva. Hongos como la cándida y el criptococo pueden considerarse los titiriteros en esta metáfora. Esto se debe a la influencia en el eje intestino-cerebro y la residencia en el cerebro." + }, + "title": "Cueva de Alegorías" + } +} \ No newline at end of file diff --git a/app/i18n/locales/fr/notfound.json b/app/i18n/locales/fr/notfound.json new file mode 100644 index 0000000..da0913c --- /dev/null +++ b/app/i18n/locales/fr/notfound.json @@ -0,0 +1,5 @@ +{ + "oops": "Oops!", + "title": "Sorry, an unexpected error has occurred.", + "backtohomepage": "Back to home page" +} diff --git a/app/i18n/locales/fr/translation.json b/app/i18n/locales/fr/translation.json new file mode 100644 index 0000000..383de4a --- /dev/null +++ b/app/i18n/locales/fr/translation.json @@ -0,0 +1,140 @@ +{ + "title": "cognactive", + "homepage": { + "tracker-link-title": "Lancer le tracker", + "github-link-title": "Voir le code", + "tracking": "Suivez votre NAC", + "tracking-desc": "Suivez le régime du protocole NAC, les expériences de détoxification mycologique et plus encore. Respect de votre vie privée avec stockage local des données. Flexible avec prise en charge d'un régime personnalisable.", + "hero-appexperience": "Appliquez votre NAC", + "hero-appexperience-desc": "Emportez la ceinture à outils cognactive avec vous dans votre voyage. Installez l'application web mobile cognactive sur votre écran d'accueil et utilisez-la comme une application. Compatible avec iOS et Android.", + "hero-opensource": "Open source", + "hero-opensource-desc": "Cognactive est prêt pour la personnalisation, la collaboration et les améliorations en tant que bien public technologique mondial.", + "hero-title": "Suivre le protocole NAC", + "fungcaster": "Regarder des vidéos", + "ingredient-scanner-link-title": "Scanner d'ingrédients", + "hero-subtitle": "Outils et technologies open source pour le protocole NAC antifongique et immunomodulateur.", + "whitepaper-prompt": "Lire le livre blanc", + "whitepaper-label": "Qu'est-ce que le protocole NAC ?", + "cave-allegory": "Caverne des Allégories" + }, + "footer": { + "data-usage-title": "Utilisation des données et politique de confidentialité", + "medical-disclaimer": "Avertissement médical", + "medical-disclaimer-desc": "Ce site web est fourni à des fins éducatives et informatives uniquement et ne constitue pas un avis médical ou des services professionnels. Rien ici ne doit être interprété comme une tentative de diagnostiquer, traiter, guérir ou prévenir toute condition, maladie ou affection, et ceux qui recherchent un avis médical doivent consulter un médecin agréé.", + "chat-nac-link-title": "Chat NAC" + }, + "tracker": { + "start_tracking": "Suivez les activités quotidiennes et les expériences de détoxification mycotique pour le protocole NAC.", + "start_date": "Date de début du protocole", + "start_button": "Commencer le suivi du protocole NAC", + "started_on": "Protocole NAC commencé le", + "reset_tracking": "Réinitialiser le suivi", + "reset_description": "Réinitialisez la date de début du suivi et supprimez toutes les données.", + "reset_button": "Réinitialiser le suivi", + "reset_confirm": "Êtes-vous sûr ? Vos progrès seront réinitialisés. Toutes les données d'activité seront effacées. Cette action ne peut pas être annulée.", + "reset_error": "Une erreur s'est produite lors de la réinitialisation des données de suivi", + "install_title": "Installer cognactive sur un appareil mobile", + "install_description": "Pour continuer, ouvrez cette page web depuis votre appareil mobile. Ensuite, ouvrez le menu Partager et sélectionnez 'Ajouter à l'écran d'accueil'.", + "privacy_title": "Politique de confidentialité", + "privacy_text": "Toutes les données de suivi d'activité du régime (dates de suivi, expériences, suppléments) sont stockées localement dans le navigateur web et ne sont pas partagées avec des tiers.", + "privacy_link": "En savoir plus sur la confidentialité et l'utilisation des données de cognactive", + "legal_title": "Avertissement légal", + "legal_text": "L'application cognactive aide à suivre les activités et événements et est destinée à un usage personnel uniquement. cognactive n'est pas une application médicale. Utilisez cette application à vos propres risques et responsabilités.", + "dashboard": { + "day": "Jour", + "date_error": "La date sélectionnée ne peut pas être antérieure à la date de début ou postérieure à aujourd'hui.", + "date_start": "Suivi commencé ce jour", + "daily_regimen": "Régime quotidien", + "customize": "Personnaliser", + "morning": "Matin", + "night": "Nuit", + "note_journal": "Journal de notes", + "read_all_notes": "Lire toutes les notes", + "food_guide": "Guide alimentaire" + }, + "progress_indicator": { + "phase_1": "Phase 1", + "phase_2": "Phase 2", + "two_month_milestone": "Jalon de 2 mois", + "weeks_no_die_off": "3 semaines sans détoxification", + "latest_die_off": "Dernier symptôme de détoxification : {{date}}", + "phase_2_started": "commencé le {{date}}", + "edit": "Modifier", + "cancel": "Annuler", + "three_weeks_on": "3 semaines en cours", + "one_week_off": "1 semaine de pause", + "phase_2_cycle_date": { + "title": "Date de début / reprise du cycle de la phase 2", + "description": "Quand avez-vous commencé ou repris la phase 2 ? Définir cette date réinitialisera le minuteur." + }, + "phase_2_setup": { + "title": "Phase 2", + "optional": "Optionnel", + "description": "Une fois passé le jalon de 2 mois, continuez la phase 1 si nécessaire ou préparez-vous pour la phase 2 du protocole NAC.", + "setup_button": "Configurer la phase 2" + }, + "bso_only": "BSO uniquement pendant la semaine de pause.", + "phase_2_eligible": "Éligibilité Phase 2" + }, + "die_off_symptoms": { + "title": "Détoxification mycotique", + "description": "Suivez les symptômes et expériences de détoxification mycotique", + "view_chart": "Voir le graphique", + "total": "TOTAL", + "new_symptom": { + "placeholder": "Décrivez un nouveau symptôme", + "track_button": "Suivre", + "error": "Décrivez votre symptôme plus en détail", + "success": "{{date}}\nSymptôme suivi{{custom}}\n{{symptom}}" + }, + "symptoms": { + "tiredness": "Fatigue", + "exhaustion": "Épuisement", + "muscle_soreness": "Douleurs musculaires", + "increased_discharge": "Augmentation des sécrétions thoraciques ou nasales", + "cold_flu": "Symptômes de rhume ou de grippe", + "cold_sores": "Boutons de fièvre", + "headaches": "Maux de tête", + "irritability": "Irritabilité", + "stool_changes": "Changement de fréquence, volume ou couleur des selles", + "rash": "Éruption cutanée", + "bloated_stomach": "Ventre ballonné", + "cramps": "Crampes", + "increased_gas": "Augmentation des gaz" + } + }, + "daily_note": { + "new_entry": "Écrire une nouvelle entrée de journal", + "whats_notable": "Qu'est-ce qui est notable à propos de {{date}} ?", + "prompt": "Avez-vous mangé quelque chose de spécial ? Avez-vous fait quelque chose de différent ?", + "placeholder": "Écrivez votre note ici", + "privacy_notice": "Les notes sont uniquement stockées localement sur votre appareil", + "save_button": "Enregistrer la note", + "success": "Note enregistrée" + }, + "trends": { + "loading": "Chargement de l'analyse des symptômes...", + "title": "Détoxification mycotique", + "back_to_tracker": "Suivi", + "chart_description": "Graphique montrant les données de {{period}}", + "period": { + "weekly": "hebdomadaire", + "monthly": "mensuel", + "week": "Semaine", + "month": "Mois" + }, + "chart": { + "symptom_severity": "Gravité des symptômes" + } + }, + "pick_date": "Choisissez une date" + }, + "memery": { + "platos-cave": { + "title": "Allégorie de la Caverne de Platon", + "description": "L'Allégorie de la Caverne de Platon décrit un groupe de personnes qui ont vécu enchaînées au mur d'une caverne toute leur vie, face à un mur blanc. Les gens regardent les ombres projetées sur le mur par des objets passant devant un feu derrière eux et commencent à attribuer des formes à ces ombres. Selon l'allégorie, les ombres sont aussi proches que les prisonniers peuvent voir la réalité. Platon suggère que les ombres sont la réalité des prisonniers mais qu'il existe un niveau de réalité supérieur, plus vrai, caché aux yeux humains.", + "relevance": "Tout ce qui influence la pensée humaine peut être caractérisé comme les marionnettistes dans l'allégorie de la caverne. Les champignons comme le candida et le cryptococcus peuvent être considérés comme les marionnettistes dans cette métaphore. Cela est dû à l'influence sur l'axe intestin-cerveau et à la résidence dans le cerveau." + }, + "title": "Caverne des Allégories" + } +} \ No newline at end of file diff --git a/src/lib/openai.server.ts b/app/lib/openai.server.ts similarity index 100% rename from src/lib/openai.server.ts rename to app/lib/openai.server.ts diff --git a/src/lib/utils.ts b/app/lib/utils.ts similarity index 100% rename from src/lib/utils.ts rename to app/lib/utils.ts diff --git a/app/localization/README.md b/app/localization/README.md new file mode 100644 index 0000000..277663f --- /dev/null +++ b/app/localization/README.md @@ -0,0 +1,27 @@ +# Localization + +Localization works by using the `i18next` package. Everything is configured inside of this folder. +The localization works by using the `/app/i18n/locales` folder. This folder contains all the translations for the different languages. You can add new translations by adding new files to this folder and then changing the `resource.ts` file to include the new language. + +The server part is set up in the `entry.server.tsx` file, and the client part, conversely, is in the `entry.client.tsx` file and also the `root.tsx` file. + +The language is detected by the `Accept-Language` HTTP request header, a saved cookie or defaults to English. + +## Server-side + +Due to the fact that the server does not care about loading in additional resources as they are not send over the wire we +pass in `resources` to the `i18next` instance. This provides all the languages to your server which allows it to render +the correct language on the server. + +## Client-side + +The client-side is a bit more complicated. We do not want to load in all the languages on the client side as it would +be a lot of requests. Instead, we use the fetch backend to load in the language files on the client side. We have a resource route inside of the `routes` directory which is in charge of loading in the resources. This route is called `resource.locales` and it is used to load in the languages. The `resource.locales` route is set up to only load in the languages and namespaces that are needed. In production we cache these responses and in development we don't cache them. + +## Automated Translations + +Translation automation is setup using [`languine.ai`](https://languine.ai). The configuration is in the `languine.config.ts` file. After installing project dependencies using `npm install`, you can run the following command to see what languine can do: + +```bash +npx languine available +``` diff --git a/app/localization/i18n.server.ts b/app/localization/i18n.server.ts new file mode 100644 index 0000000..a7f5e5a --- /dev/null +++ b/app/localization/i18n.server.ts @@ -0,0 +1,49 @@ +import { resolve } from 'node:path' +import { RemixI18Next } from 'remix-i18next/server' +import i18n from '@/localization/i18n' // your i18n configuration file + +const i18next = new RemixI18Next({ + detection: { + findLocale: async (request) => { + // Get first path component from URL + const url = new URL(request.url) + const firstPathComponent = url.pathname.split('/')[1] + + // Check if it matches a supported language code + if (i18n.supportedLngs.includes(firstPathComponent as any)) { + return firstPathComponent + } + + const cookie = request.headers.get('Cookie') + const lng = cookie?.match(/lng=([^;]+)/)?.[1] + if (lng) { + return lng + } + + // Try to detect from Accept-Language header + // Try to detect from Accept-Language header + const acceptLanguage = request.headers.get('Accept-Language') + if (acceptLanguage) { + const preferredLang = acceptLanguage.split(',')[0].split('-')[0] + if (i18n.supportedLngs.includes(preferredLang as any)) { + return preferredLang + } + } + + // Else detect language from stored cookie + return 'en' + }, + supportedLanguages: i18n.supportedLngs, + fallbackLanguage: i18n.fallbackLng, + }, + // This is the configuration for i18next used + // when translating messages server-side only + i18next: { + ...i18n, + backend: { + loadPath: resolve('../i18n/locales/{{lng}}/{{ns}}.json'), + }, + }, +}) + +export default i18next diff --git a/app/localization/i18n.ts b/app/localization/i18n.ts new file mode 100644 index 0000000..15af262 --- /dev/null +++ b/app/localization/i18n.ts @@ -0,0 +1,11 @@ +import { supportedLanguages } from './resource' + +export default { + // This is the list of languages your application supports + supportedLngs: supportedLanguages, + // This is the language you want to use in case + // if the user language is not in the supportedLngs + fallbackLng: 'en', + // The default namespace of i18next is "translation", but you can customize it here + defaultNS: 'translation', +} diff --git a/app/localization/resource.ts b/app/localization/resource.ts new file mode 100644 index 0000000..7102481 --- /dev/null +++ b/app/localization/resource.ts @@ -0,0 +1,33 @@ +import spanish from '../../app/i18n/locales/es/translation.json' +import english from '../../app/i18n/locales/en/translation.json' +import french from '../../app/i18n/locales/fr/translation.json' + +import notfound from '../../app/i18n/locales/en/notfound.json' +import notfound_es from '../../app/i18n/locales/es/notfound.json' +import notfound_fr from '../../app/i18n/locales/fr/notfound.json' + +const languages = ['en', 'es', 'fr'] as const +export const supportedLanguages = [...languages] +export type Language = (typeof languages)[number] + +type Resource = { + translation: typeof english + notfound: typeof notfound +} + +export type Namespace = keyof Resource + +export const resources: Record = { + en: { + translation: english, + notfound: notfound, + }, + es: { + translation: spanish, + notfound: notfound_es, + }, + fr: { + translation: french, + notfound: notfound_fr, + }, +} diff --git a/src/pages/about/index.tsx b/app/pages/about/index.tsx similarity index 100% rename from src/pages/about/index.tsx rename to app/pages/about/index.tsx diff --git a/src/pages/blog/layout.tsx b/app/pages/blog/layout.tsx similarity index 100% rename from src/pages/blog/layout.tsx rename to app/pages/blog/layout.tsx diff --git a/src/pages/home/index.tsx b/app/pages/home/index.tsx similarity index 100% rename from src/pages/home/index.tsx rename to app/pages/home/index.tsx diff --git a/src/pages/tracker/daily-note-form.tsx b/app/pages/tracker/daily-note-form.tsx similarity index 74% rename from src/pages/tracker/daily-note-form.tsx rename to app/pages/tracker/daily-note-form.tsx index 66cb86e..a6670f0 100644 --- a/src/pages/tracker/daily-note-form.tsx +++ b/app/pages/tracker/daily-note-form.tsx @@ -10,33 +10,37 @@ import { SheetTitle, SheetTrigger, } from 'src/components/ui/sheet' - import { Textarea } from 'src/components/ui/textarea' import db from './db' import { useState } from 'react' import { toast } from 'react-hot-toast' import { useNavigate } from 'react-router' import { cn } from 'src/lib/utils' +import { useTranslation } from 'react-i18next' export default function DailyNoteForm({ dateKey, buttonClassName }: { dateKey: string; buttonClassName?: string }) { + const { t } = useTranslation() const [note, setNote] = useState('') - const navigate = useNavigate() return ( - What's notable about {dateKey}? - Eat anything special? Do anything different? + {t('tracker.daily_note.whats_notable', { date: dateKey })} + {t('tracker.daily_note.prompt')}
-