搭建个人知识付费应用系统-(4)Remix i18n、主题切换

119 阅读1分钟

视频地址:www.bilibili.com/video/BV1md…

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);
};