Skip to content

Commit 3c5fbac

Browse files
committed
ssr-daisyui: refactor i18n to be typesafe, clean up, add mobile nav
1 parent 9600a42 commit 3c5fbac

33 files changed

+344
-420
lines changed

packages/ssr-daisyui/app/components/theme/header.tsx

Lines changed: 0 additions & 44 deletions
This file was deleted.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Container } from '~/components/theme/container.tsx'
2+
import { NavDrawer } from '~/components/theme/header/nav-drawer.tsx'
3+
4+
export const Header: React.FC = () => {
5+
return (
6+
<Container as="header" className="py-0 md:py-4">
7+
<NavDrawer />
8+
</Container>
9+
)
10+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { MenuIcon } from 'lucide-react'
2+
import React from 'react'
3+
import { useTranslation } from 'react-i18next'
4+
import { href, Link } from 'react-router'
5+
import { Logo } from '~/components/theme/logo.tsx'
6+
import { useLang } from '~/hooks/use-lang.tsx'
7+
import { type MenuItem } from '~/types/menu-item.ts'
8+
9+
export const NavDrawer: React.FC = () => {
10+
const id = React.useId()
11+
const { t } = useTranslation()
12+
const { lang } = useLang()
13+
const [isOpen, setIsOpen] = React.useState(false)
14+
15+
const close = () => setIsOpen(false)
16+
const toggle = () => setIsOpen((prev) => !prev)
17+
18+
const menu: MenuItem[] = [
19+
{
20+
path: (lang: string) => href('/:lang?/home', { lang }),
21+
name: t('menu.home.name', 'Home'),
22+
},
23+
{
24+
path: (lang: string) => href('/:lang?/form', { lang }),
25+
name: t('menu.form.name', 'Example Form'),
26+
},
27+
]
28+
29+
return (
30+
<div className="drawer">
31+
<input id={id} type="checkbox" className="drawer-toggle" checked={isOpen} onChange={toggle} />
32+
<div className="drawer-content flex flex-col">
33+
<div className="navbar bg-base-300 flex w-full">
34+
<div className="flex-none lg:hidden">
35+
<button onClick={toggle} aria-label="open sidebar" className="btn btn-square btn-ghost">
36+
<MenuIcon />
37+
</button>
38+
</div>
39+
<div className="mx-2 flex-1 justify-items-end px-2 md:justify-items-start">
40+
<Link to={href('/')} className="btn btn-ghost btn-sm flex flex-row gap-3 text-xl">
41+
<Logo variant="sm" />
42+
<span>{t('header.title', 'ACME Inc.')}</span>
43+
</Link>
44+
</div>
45+
<div className="hidden flex-none lg:block">
46+
<ul className="menu menu-horizontal">
47+
{menu?.map((each, idx) => (
48+
<li key={idx}>
49+
<Link to={each.path(lang)}>{t(each.name)}</Link>
50+
</li>
51+
))}
52+
</ul>
53+
</div>
54+
</div>
55+
</div>
56+
<div className="drawer-side z-10">
57+
<label
58+
htmlFor={id}
59+
aria-label="close sidebar"
60+
className="drawer-overlay"
61+
onClick={close}></label>
62+
<ul className="menu bg-base-200 min-h-full w-80 p-4">
63+
{menu?.map((each, idx) => (
64+
<li key={idx}>
65+
<Link to={each.path(lang)} onClick={close}>
66+
{t(each.name)}
67+
</Link>
68+
</li>
69+
))}
70+
</ul>
71+
</div>
72+
</div>
73+
)
74+
}

packages/ssr-daisyui/app/components/ui/language-switcher.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useTranslation } from 'react-i18next'
22
import { Link, useLocation } from 'react-router'
3+
import { Button } from '~/components/ui/button.tsx'
34
import { useLang } from '~/hooks/use-lang.tsx'
4-
import i18n from '~/i18n.ts'
5+
import { i18nConfig } from '~/i18n-config.ts'
56

67
export const LanguageSwitcher: React.FC = () => {
78
const { lang } = useLang()
@@ -10,11 +11,13 @@ export const LanguageSwitcher: React.FC = () => {
1011

1112
return (
1213
<div title={t('language.switcher.title', 'Change Language')} className="flex gap-2">
13-
{i18n.supportedLngs
14+
{i18nConfig.supportedLngs
1415
.filter((each) => each !== lang)
1516
.map((each) => (
16-
<Link to={location.pathname.replace(lang, each)} key={each}>
17-
{each.toUpperCase()}
17+
<Link to={location.pathname.replace(lang, each)} key={each} reloadDocument={true}>
18+
<Button variant={'ghost'} size={'xs'}>
19+
{each.toUpperCase()}
20+
</Button>
1821
</Link>
1922
))}
2023
</div>
Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,23 @@
1-
/**
2-
* By default, Remix will handle hydrating your app on the client for you.
3-
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4-
* For more information, see https://remix.run/file-conventions/entry.client
5-
*/
6-
71
import i18next from 'i18next'
8-
import I18nLanguageDetector from 'i18next-browser-languagedetector'
9-
import I18nBackend from 'i18next-http-backend'
2+
import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector'
3+
import Fetch from 'i18next-fetch-backend'
104
import { startTransition, StrictMode } from 'react'
115
import { hydrateRoot } from 'react-dom/client'
126
import { I18nextProvider, initReactI18next } from 'react-i18next'
137
import { HydratedRouter } from 'react-router/dom'
148
import { getInitialNamespaces } from 'remix-i18next/client'
15-
import versionFile from './version.json'
16-
import i18n from '~/i18n.ts'
9+
import { i18nConfig } from '~/i18n-config.ts'
1710

18-
async function hydrate() {
11+
async function main() {
1912
await i18next
2013
.use(initReactI18next)
21-
.use(I18nLanguageDetector)
22-
.use(I18nBackend)
14+
.use(Fetch)
15+
.use(I18nextBrowserLanguageDetector)
2316
.init({
24-
...i18n,
17+
fallbackLng: i18nConfig.fallbackLng,
2518
ns: getInitialNamespaces(),
26-
backend: { loadPath: `/locales/${i18n.jsonFileSchema}?${versionFile.version}` },
27-
detection: {
28-
// Here only enable htmlTag detection, we'll detect the language only
29-
// server-side with remix-i18next, by using the `<html lang>` attribute
30-
// we can communicate to the client the language detected server-side
31-
order: ['htmlTag'],
32-
// Because we only use htmlTag, there's no reason to cache the language
33-
// on the browser, so we disable it
34-
caches: [],
35-
},
19+
detection: { order: ['htmlTag'], caches: [] },
20+
backend: { loadPath: '/api/locales/{{lng}}/{{ns}}' },
3621
})
3722

3823
startTransition(() => {
@@ -47,10 +32,4 @@ async function hydrate() {
4732
})
4833
}
4934

50-
if (window.requestIdleCallback) {
51-
window.requestIdleCallback(hydrate)
52-
} else {
53-
// Safari doesn't support requestIdleCallback
54-
// https://caniuse.com/requestidlecallback
55-
window.setTimeout(hydrate, 1)
56-
}
35+
main().catch((error) => console.error(error))
Lines changed: 19 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -1,132 +1,37 @@
1-
/**
2-
* By default, Remix will handle generating the HTTP Response for you.
3-
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4-
* For more information, see https://remix.run/file-conventions/entry.server
5-
*/
6-
7-
import { resolve } from 'node:path'
81
import { PassThrough } from 'node:stream'
92

103
import { createReadableStreamFromReadable } from '@react-router/node'
11-
import { createInstance } from 'i18next'
12-
import Backend from 'i18next-fs-backend'
134
import { isbot } from 'isbot'
14-
import { renderToPipeableStream } from 'react-dom/server'
15-
import { I18nextProvider, initReactI18next } from 'react-i18next'
16-
import { type AppLoadContext, type EntryContext, ServerRouter } from 'react-router'
17-
import i18n from './i18n.ts' // your i18n configuration file
18-
import i18nextServer from './i18next.server'
5+
import { type RenderToPipeableStreamOptions, renderToPipeableStream } from 'react-dom/server'
6+
import { I18nextProvider } from 'react-i18next'
7+
import { ServerRouter, type EntryContext, type unstable_RouterContextProvider } from 'react-router'
8+
import { getInstance } from './middleware/i18next'
199

20-
const ABORT_DELAY = 5000
10+
export const streamTimeout = 5_000
2111

22-
export default async function handleRequest(
12+
export default function handleRequest(
2313
request: Request,
2414
responseStatusCode: number,
2515
responseHeaders: Headers,
26-
reactRouterContext: EntryContext,
27-
// This is ignored so we can keep it in the template for visibility. Feel
28-
// free to delete this parameter in your app if you're not using it!
29-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
30-
loadContext: AppLoadContext,
16+
entryContext: EntryContext,
17+
routerContext: unstable_RouterContextProvider,
3118
) {
32-
return isbot(request.headers.get('user-agent') || '')
33-
? handleBotRequest(request, responseStatusCode, responseHeaders, reactRouterContext)
34-
: handleBrowserRequest(request, responseStatusCode, responseHeaders, reactRouterContext)
35-
}
36-
37-
async function handleBotRequest(
38-
request: Request,
39-
responseStatusCode: number,
40-
responseHeaders: Headers,
41-
reactRouterContext: EntryContext,
42-
) {
43-
const instance = createInstance()
44-
const lng = await i18nextServer.getLocale(request)
45-
const ns = i18nextServer.getRouteNamespaces(reactRouterContext)
46-
47-
await instance
48-
.use(initReactI18next)
49-
.use(Backend)
50-
.init({
51-
...i18n,
52-
lng,
53-
ns, // The namespaces the routes about to render wants to use
54-
backend: { loadPath: resolve(`./public/locales/${i18n.jsonFileSchema}`) },
55-
})
56-
5719
return new Promise((resolve, reject) => {
5820
let shellRendered = false
59-
const { pipe, abort } = renderToPipeableStream(
60-
<I18nextProvider i18n={instance}>
61-
<ServerRouter context={reactRouterContext} url={request.url} />
62-
</I18nextProvider>,
63-
{
64-
onAllReady() {
65-
shellRendered = true
66-
const body = new PassThrough()
67-
const stream = createReadableStreamFromReadable(body)
68-
69-
responseHeaders.set('Content-Type', 'text/html')
70-
71-
resolve(
72-
new Response(stream, {
73-
headers: responseHeaders,
74-
status: responseStatusCode,
75-
}),
76-
)
21+
let userAgent = request.headers.get('user-agent')
7722

78-
pipe(body)
79-
},
80-
onShellError(error: unknown) {
81-
reject(error)
82-
},
83-
onError(error: unknown) {
84-
responseStatusCode = 500
85-
// Log streaming rendering errors from inside the shell. Don't log
86-
// errors encountered during initial shell rendering since they'll
87-
// reject and get logged in handleDocumentRequest.
88-
if (shellRendered) {
89-
console.error(error)
90-
}
91-
},
92-
},
93-
)
23+
let readyOption: keyof RenderToPipeableStreamOptions =
24+
(userAgent && isbot(userAgent)) || entryContext.isSpaMode ? 'onAllReady' : 'onShellReady'
9425

95-
setTimeout(abort, ABORT_DELAY)
96-
})
97-
}
98-
99-
async function handleBrowserRequest(
100-
request: Request,
101-
responseStatusCode: number,
102-
responseHeaders: Headers,
103-
reactRouterContext: EntryContext,
104-
) {
105-
const instance = createInstance()
106-
const lng = await i18nextServer.getLocale(request)
107-
const ns = i18nextServer.getRouteNamespaces(reactRouterContext)
108-
109-
await instance
110-
.use(initReactI18next) // Tell our instance to use react-i18next
111-
.use(Backend) // Setup our backend
112-
.init({
113-
...i18n, // spread the configuration
114-
lng, // The locale we detected above
115-
ns, // The namespaces the routes about to render wants to use
116-
backend: { loadPath: resolve(`./public/locales/${i18n.jsonFileSchema}`) },
117-
})
118-
119-
return new Promise((resolve, reject) => {
120-
let shellRendered = false
121-
const { pipe, abort } = renderToPipeableStream(
122-
<I18nextProvider i18n={instance}>
123-
<ServerRouter context={reactRouterContext} url={request.url} />
26+
let { pipe, abort } = renderToPipeableStream(
27+
<I18nextProvider i18n={getInstance(routerContext)}>
28+
<ServerRouter context={entryContext} url={request.url} />
12429
</I18nextProvider>,
12530
{
126-
onShellReady() {
31+
[readyOption]() {
12732
shellRendered = true
128-
const body = new PassThrough()
129-
const stream = createReadableStreamFromReadable(body)
33+
let body = new PassThrough()
34+
let stream = createReadableStreamFromReadable(body)
13035

13136
responseHeaders.set('Content-Type', 'text/html')
13237

@@ -144,16 +49,11 @@ async function handleBrowserRequest(
14449
},
14550
onError(error: unknown) {
14651
responseStatusCode = 500
147-
// Log streaming rendering errors from inside the shell. Don't log
148-
// errors encountered during initial shell rendering since they'll
149-
// reject and get logged in handleDocumentRequest.
150-
if (shellRendered) {
151-
console.error(error)
152-
}
52+
if (shellRendered) console.error(error)
15353
},
15454
},
15555
)
15656

157-
setTimeout(abort, ABORT_DELAY)
57+
setTimeout(abort, streamTimeout + 1000)
15858
})
15959
}

0 commit comments

Comments
 (0)