视频地址:https://www.bilibili.com/vide...

Remix i18n

留神一下 languages 的结构调整:

export const languages = {  zh: {    flag: '',    name: '简体中文'  },  en: {    flag: '',    name: 'English'  }};

语言切换组件

与 DaisyUI 雷同

import { useI18n } from 'remix-i18n';import { useEffect, useState } from 'react';import { useLocation } from '@remix-run/react';import { getLocale, languages } from '~/i18n';export function ToggleLocale() {  const { t } = useI18n();  const location = useLocation();  const [path, setPath] = useState(    location.pathname.replace(`/${getLocale(location.pathname)}`, '')  );  useEffect(() => {    setPath(location.pathname.replace(`/${getLocale(location.pathname)}`, ''));  }, [location]);  return (    <div title={t('tips.toggleLocale')} className='dropdown dropdown-end'>      <div tabIndex='0' className='btn btn-ghost gap-1 normal-case'>        <svg          className='inline-block h-4 w-4 fill-current md:h-5 md:w-5'          xmlns='http://www.w3.org/2000/svg'          width='20'          height='20'          viewBox='0 0 512 512'>          <path d='M363,176,246,464h47.24l24.49-58h90.54l24.49,58H480ZM336.31,362,363,279.85,389.69,362Z' />          <path d='M272,320c-.25-.19-20.59-15.77-45.42-42.67,39.58-53.64,62-114.61,71.15-143.33H352V90H214V48H170V90H32v44H251.25c-9.52,26.95-27.05,69.5-53.79,108.36-32.68-43.44-47.14-75.88-47.33-76.22L143,152l-38,22,6.87,13.86c.89,1.56,17.19,37.9,54.71,86.57.92,1.21,1.85,2.39,2.78,3.57-49.72,56.86-89.15,79.09-89.66,79.47L64,368l23,36,19.3-11.47c2.2-1.67,41.33-24,92-80.78,24.52,26.28,43.22,40.83,44.3,41.67L255,362Z' />        </svg>        <svg          width='12px'          height='12px'          className='ml-1 hidden h-3 w-3 fill-current opacity-60 sm:inline-block'          xmlns='http://www.w3.org/2000/svg'          viewBox='0 0 2048 2048'>          <path d='M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z' />        </svg>      </div>      <div className='dropdown-content bg-base-200 text-base-content rounded-t-box rounded-b-box top-px mt-16 w-52 overflow-y-auto shadow-2xl'>        <ul className='menu menu-compact gap-1 p-3' tabIndex='0'>          {Object.entries(languages).map(([locale, item]) => (            <li key={locale}>              <a                href={`/${locale}${path}`}                className='flex flex-1 justify-between'>                {item.flag}                {item.name}              </a>            </li>          ))}        </ul>      </div>    </div>  );}

Remix 主题切换

切换主题接口

/api/theme
import {  type LoaderFunction,  redirect,  type ActionFunction,  json} from '@remix-run/node';import { themes } from '~/components/atom/use-theme';import { getSession } from '~/services/session.server';export const action: ActionFunction = async ({ request }) => {  switch (request.method) {    case 'PUT':    case 'POST': {      const session = await getSession(request.headers.get('Cookie'));      const data: { theme: string } = await request.json();      const theme = themes.map((x) => x.id).includes(data.theme)        ? data.theme        : 'retro';      session.set('theme', theme);      return json(        { success: true },        {          headers: {            'Set-Cookie': await sessionStore.commitSession(session)          }        }      );    }    default: {      return json(        {          success: false        },        {          status: 403        }      );    }  }};export const loader: LoaderFunction = () => {  throw redirect('/');};

Theme Provider

import { createContext } from 'react';export const themes = [  {    name: '  light',    id: 'light'  },  {    name: '  dark',    id: 'dark'  },  {    name: '  cupcake',    id: 'cupcake'  },  {    name: '  bumblebee',    id: 'bumblebee'  },  {    name: '✳️  Emerald',    id: 'emerald'  },  {    name: '  Corporate',    id: 'corporate'  },  {    name: '  synthwave',    id: 'synthwave'  },  {    name: '  retro',    id: 'retro'  },  {    name: '  cyberpunk',    id: 'cyberpunk'  },  {    name: '  valentine',    id: 'valentine'  },  {    name: '  halloween',    id: 'halloween'  },  {    name: '  garden',    id: 'garden'  },  {    name: '  forest',    id: 'forest'  },  {    name: '  aqua',    id: 'aqua'  },  {    name: '  lofi',    id: 'lofi'  },  {    name: '  pastel',    id: 'pastel'  },  {    name: '♀️  fantasy',    id: 'fantasy'  },  {    name: '  Wireframe',    id: 'wireframe'  },  {    name: '  black',    id: 'black'  },  {    name: '  luxury',    id: 'luxury'  },  {    name: '♂️  dracula',    id: 'dracula'  },  {    name: '  CMYK',    id: 'cmyk'  },  {    name: '  Autumn',    id: 'autumn'  },  {    name: '  Business',    id: 'business'  },  {    name: '  Acid',    id: 'acid'  },  {    name: '  Lemonade',    id: 'lemonade'  },  {    name: '  Night',    id: 'night'  },  {    name: '☕️  Coffee',    id: 'coffee'  },  {    name: '❄️  Winter',    id: 'winter'  }];type ThemeContextType = [  string | null,  React.Dispatch<React.SetStateAction<string | null>>];const ThemeContext = createContext<ThemeContextType | undefined>(undefined);ThemeContext.displayName = 'ThemeContext';const prefersLightMQ = '(prefers-color-scheme: light)';export function ThemeProvider({  children,  themeAction = '/api/theme',  specifiedTheme}: {  children: ReactNode;  themeAction: string;  specifiedTheme: string | null;}) {  const [theme, setTheme] = useState<string | null>(() => {    if (specifiedTheme) {      return THEMES.includes(specifiedTheme) ? specifiedTheme : null;    }    if (typeof window !== 'object') return null;    return window.matchMedia(prefersLightMQ).matches ? 'valentine' : 'retro';  });  const mountRun = React.useRef(false);  useEffect(() => {    if (!mountRun.current) {      mountRun.current = true;      return;    }    if (!theme) return;    // eslint-disable-next-line @typescript-eslint/no-floating-promises    fetch(`${themeAction}`, {      method: 'POST',      body: JSON.stringify({ theme })    });  }, [theme]);  useEffect(() => {    const mediaQuery = window.matchMedia(prefersLightMQ);    const handleChange = () => {      setTheme(mediaQuery.matches ? 'valentine' : 'retro');    };    mediaQuery.addEventListener('change', handleChange);    return () => mediaQuery.removeEventListener('change', handleChange);  }, []);  return (    <ThemeContext.Provider value={[theme, setTheme]}>      {children}    </ThemeContext.Provider>  );}export function useTheme() {  const context = useContext(ThemeContext);  if (context === undefined) {    throw new Error('useTheme must be used within a ThemeProvider');  }  return context;}

Use Theme

export const loader: LoaderFunction = async ({ request }) => {  const session = await getSession(request.headers.get('Cookie'));  const theme = (session.get('theme') as string) || 'retro';  return json({ theme });};export default function App() {  const { theme } = useLoaderData<LoaderData>();  return (    <ThemeProvider specifiedTheme={theme}>      <Outlet />    </ThemeProvider>  );}

切换主题组件

import { useI18n } from 'remix-i18n';import { themes } from './use-theme';export function ToggleTheme() {  const { t } = useI18n();  return (    <div title={t('tips.toggleTheme')} className={`dropdown dropdown-end`}>      <div tabIndex='0' className={`btn gap-1 normal-case btn-ghost`}>        <svg          width='20'          height='20'          xmlns='http://www.w3.org/2000/svg'          fill='none'          viewBox='0 0 24 24'          className='inline-block h-5 w-5 stroke-current md:h-6 md:w-6'>          <path            strokeLinecap='round'            strokeLinejoin='round'            strokeWidth='2'            d='M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01'          />        </svg>        {/* <span className='hidden md:inline'>{$t('change-theme-btn')}</span> */}        <svg          width='12px'          height='12px'          className='ml-1 hidden h-3 w-3 fill-current opacity-60 sm:inline-block'          xmlns='http://www.w3.org/2000/svg'          viewBox='0 0 2048 2048'>          <path d='M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z' />        </svg>      </div>      <div        className={`dropdown-content bg-base-200 text-base-content rounded-t-box rounded-b-box top-px max-h-96 h-[70vh] w-52 overflow-y-auto shadow-2xl mt-16`}>        <div className='grid grid-cols-1 gap-3 p-3' tabIndex='0'>          {themes.map((theme) => (            <div              key={theme.id}              className='outline-base-content overflow-hidden rounded-lg outline outline-2 outline-offset-2'>              <div                data-theme={theme.id}                className='bg-base-100 text-base-content w-full cursor-pointer font-sans'>                <div className='grid grid-cols-5 grid-rows-3'>                  <div className='col-span-5 row-span-3 row-start-1 flex gap-1 py-3 px-4'>                    <div className='flex-grow text-sm font-bold'>                      {theme.id}                    </div>                    <div className='flex flex-shrink-0 flex-wrap gap-1'>                      <div className='bg-primary w-2 rounded' />                      <div className='bg-secondary w-2 rounded' />                      <div className='bg-accent w-2 rounded' />                      <div className='bg-neutral w-2 rounded' />                    </div>                  </div>                </div>              </div>            </div>          ))}        </div>      </div>    </div>  );}

组件加切换事件

const [currentTheme, setTheme] = useTheme();const onThemeClicked = (theme: string) => {    setTheme(theme);    // eslint-disable-next-line @typescript-eslint/no-floating-promises    fetch('/api/theme', {      method: 'PUT',      body: JSON.stringify({ theme })    });    localStorage.setItem('theme', theme);};