From 3e3a9c59073971453f0fe5a9151a97206165554e Mon Sep 17 00:00:00 2001 From: RicoPleasure Date: Tue, 29 Jul 2025 19:15:09 +0100 Subject: [PATCH 1/4] feat: internationalization --- .storybook/main.ts | 21 ++--- .storybook/preview.ts | 1 - eslint.config.mjs | 2 +- postcss.config.mjs | 2 +- src/app/globals.css | 14 ++-- src/app/layout.tsx | 11 ++- src/components/Card.tsx | 6 +- src/components/input.tsx | 31 ++++--- src/components/label.tsx | 30 +++---- src/contexts/dictionary-provider.tsx | 82 +++++++++++++++++++ src/internationalization/dictionaries.ts | 14 ++++ src/internationalization/dictionaries/en.json | 0 src/internationalization/dictionaries/pt.json | 0 src/stories/Button.stories.ts | 26 +++--- src/stories/Button.tsx | 14 ++-- src/stories/Card.stories.ts | 16 ++-- src/stories/Configure.mdx | 44 +++++----- src/stories/Input.stories.ts | 22 ++--- src/stories/Label.stories.ts | 32 ++++---- src/stories/ToggleSwitch.stories.ts | 28 +++---- src/stories/button.css | 2 +- 21 files changed, 253 insertions(+), 145 deletions(-) create mode 100644 src/contexts/dictionary-provider.tsx create mode 100644 src/internationalization/dictionaries.ts create mode 100644 src/internationalization/dictionaries/en.json create mode 100644 src/internationalization/dictionaries/pt.json diff --git a/.storybook/main.ts b/.storybook/main.ts index 84a2abc..0e68624 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,21 +1,16 @@ import type { StorybookConfig } from "@storybook/nextjs-vite"; const config: StorybookConfig = { - "stories": [ - "../src/**/*.mdx", - "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)" - ], - "addons": [ + stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], + addons: [ "@chromatic-com/storybook", "@storybook/addon-docs", - "@storybook/addon-a11y" + "@storybook/addon-a11y", ], - "framework": { - "name": "@storybook/nextjs-vite", - "options": {} + framework: { + name: "@storybook/nextjs-vite", + options: {}, }, - "staticDirs": [ - "../public" - ] + staticDirs: ["../public"], }; -export default config; \ No newline at end of file +export default config; diff --git a/.storybook/preview.ts b/.storybook/preview.ts index c7c9b14..dc161b6 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,4 +1,3 @@ - import "../src/app/globals.css"; import type { Preview } from "@storybook/nextjs-vite"; diff --git a/eslint.config.mjs b/eslint.config.mjs index 1674939..8002882 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -14,7 +14,7 @@ const compat = new FlatCompat({ const eslintConfig = [ ...compat.extends("next/core-web-vitals", "next/typescript"), - ...storybook.configs["flat/recommended"] + ...storybook.configs["flat/recommended"], ]; export default eslintConfig; diff --git a/postcss.config.mjs b/postcss.config.mjs index 1af86d5..4de2724 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,5 +1,5 @@ const config = { - plugins: {"@tailwindcss/postcss": {}}, + plugins: { "@tailwindcss/postcss": {} }, }; export default config; diff --git a/src/app/globals.css b/src/app/globals.css index a980576..30ff08f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -47,17 +47,17 @@ } @theme { - --color-primary: #EE7749; - --color-light: #FFFFFF; + --color-primary: #ee7749; + --color-light: #ffffff; --color-dark: #000000; - --color-muted: #FAFAFA; + --color-muted: #fafafa; --color-university: #971318; - --color-danger: #FF5E79; - --color-success: #2EDB51; + --color-danger: #ff5e79; + --color-success: #2edb51; - --font-jamjuree: var(--font-jamjuree) + --font-jamjuree: var(--font-jamjuree); } input[type="number"] { -moz-appearance: textfield; -} \ No newline at end of file +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 772b169..691d0aa 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,13 +1,14 @@ import type { Metadata } from "next"; import { Bai_Jamjuree } from "next/font/google"; import "./globals.css"; +import { DictionaryProvider } from "@/contexts/dictionary-provider"; const jamjuree = Bai_Jamjuree({ subsets: ["latin"], variable: "--font-jamjuree", weight: ["200", "300", "400", "500", "600", "700"], - display: "swap" -}) + display: "swap", +}); export const metadata: Metadata = { title: "Create Next App", @@ -21,10 +22,8 @@ export default function RootLayout({ }>) { return ( - - {children} + + {children} ); diff --git a/src/components/Card.tsx b/src/components/Card.tsx index ce798d3..9663fc8 100644 --- a/src/components/Card.tsx +++ b/src/components/Card.tsx @@ -5,10 +5,12 @@ interface ICardProps { function Card({ children, className }: ICardProps) { return ( -
+
{children}
); } -export default Card; \ No newline at end of file +export default Card; diff --git a/src/components/input.tsx b/src/components/input.tsx index ea9fdc7..c5863ec 100644 --- a/src/components/input.tsx +++ b/src/components/input.tsx @@ -2,22 +2,33 @@ interface IInputProps extends React.InputHTMLAttributes { center_text?: boolean; } -export default function Input({ placeholder, type, disabled, className, name, value, center_text, min, max, onChange, ...rest }: IInputProps) { - - const textAlignment = center_text ? 'text-center' : 'text-left'; +export default function Input({ + placeholder, + type, + disabled, + className, + name, + value, + center_text, + min, + max, + onChange, + ...rest +}: IInputProps) { + const textAlignment = center_text ? "text-center" : "text-left"; return ( - - ) -} \ No newline at end of file + ); +} diff --git a/src/components/label.tsx b/src/components/label.tsx index f85e0af..ed0f6b4 100644 --- a/src/components/label.tsx +++ b/src/components/label.tsx @@ -4,36 +4,36 @@ interface ILabelProps { children: ReactNode; disabled?: boolean; htmlFor?: string; - size?: 'small' | 'medium' | 'large'; + size?: "small" | "medium" | "large"; onClick?: () => void; } -const Label = ({ - children, - disabled = false, +const Label = ({ + children, + disabled = false, htmlFor, - size = 'medium', + size = "medium", onClick, }: ILabelProps) => { const getSizeClasses = () => { switch (size) { - case 'small': - return 'text-xs'; - case 'large': - return 'text-base'; + case "small": + return "text-xs"; + case "large": + return "text-base"; default: - return 'text-sm'; + return "text-sm"; } }; - const colorClass = disabled ? 'text-gray-400' : 'text-gray-700'; - const cursorClass = disabled ? 'cursor-not-allowed' : 'cursor-pointer'; + const colorClass = disabled ? "text-gray-400" : "text-gray-700"; + const cursorClass = disabled ? "cursor-not-allowed" : "cursor-pointer"; return ( diff --git a/src/contexts/dictionary-provider.tsx b/src/contexts/dictionary-provider.tsx new file mode 100644 index 0000000..6557e0d --- /dev/null +++ b/src/contexts/dictionary-provider.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { createContext, useContext, useEffect, useState } from "react"; +import type { Dictionary, Language } from "@/internationalization/dictionaries"; +import { getDictionary } from "@/internationalization/dictionaries"; + +export type DictionaryLanguage = Language; + +interface DictionaryContextData { + dictionary: Dictionary; + language: DictionaryLanguage; +} + +const DictionaryContext = createContext( + undefined, +); + +/* FIXME: Check this function when the api is available */ +export async function PreferedLanguage(): Promise { + const data = await fetch("https://api/preferences/language"); + if (!data.ok) { + throw new Error("Failed to fetch preferred language"); + } + const { language } = await data.json(); + return language as DictionaryLanguage; +} + +export function getBrowserLanguage(): DictionaryLanguage { + if (typeof navigator !== "undefined" && navigator.language) { + return navigator.language as DictionaryLanguage; + } + return "en-US"; +} + +export function DictionaryProvider({ + children, + language: propLanguage, +}: { + children: React.ReactNode; + language?: DictionaryLanguage; +}) { + const [language, setLanguage] = useState( + propLanguage || "en-US", + ); + + useEffect(() => { + if (!propLanguage) { + (async () => { + try { + const preferredLanguage = await PreferedLanguage(); + setLanguage(preferredLanguage); + } catch { + setLanguage(getBrowserLanguage()); + } + })(); + } + }, [propLanguage]); + + const dictionary = getDictionary(language); + + return ( + + {children} + + ); +} + +export function useDictionary() { + const context = useContext(DictionaryContext); + if (!context) { + throw new Error("useDictionary must be used within a DictionaryProvider"); + } + return context.dictionary; +} + +export function useLanguage() { + const context = useContext(DictionaryContext); + if (!context) { + throw new Error("useLanguage must be used within a DictionaryProvider"); + } + return context.language; +} diff --git a/src/internationalization/dictionaries.ts b/src/internationalization/dictionaries.ts new file mode 100644 index 0000000..48c7bc6 --- /dev/null +++ b/src/internationalization/dictionaries.ts @@ -0,0 +1,14 @@ +import en from "./dictionaries/en.json"; +import pt from "./dictionaries/pt.json"; + +const dictionaries = { + "en-US": en, + "pt-PT": pt, +}; + +export type Language = keyof typeof dictionaries; +export type Dictionary = (typeof dictionaries)[Language]; + +export const getDictionary = (lang: Language): Dictionary => { + return dictionaries[lang] || dictionaries["en-US"]; +}; diff --git a/src/internationalization/dictionaries/en.json b/src/internationalization/dictionaries/en.json new file mode 100644 index 0000000..e69de29 diff --git a/src/internationalization/dictionaries/pt.json b/src/internationalization/dictionaries/pt.json new file mode 100644 index 0000000..e69de29 diff --git a/src/stories/Button.stories.ts b/src/stories/Button.stories.ts index 577870f..0218e29 100644 --- a/src/stories/Button.stories.ts +++ b/src/stories/Button.stories.ts @@ -1,22 +1,22 @@ -import type { Meta, StoryObj } from '@storybook/nextjs-vite'; +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; -import { fn } from 'storybook/test'; +import { fn } from "storybook/test"; -import { Button } from './Button'; +import { Button } from "./Button"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = { - title: 'Example/Button', + title: "Example/Button", component: Button, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout - layout: 'centered', + layout: "centered", }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs - tags: ['autodocs'], + tags: ["autodocs"], // More on argTypes: https://storybook.js.org/docs/api/argtypes argTypes: { - backgroundColor: { control: 'color' }, + backgroundColor: { control: "color" }, }, // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args args: { onClick: fn() }, @@ -29,26 +29,26 @@ type Story = StoryObj; export const Primary: Story = { args: { primary: true, - label: 'Button', + label: "Button", }, }; export const Secondary: Story = { args: { - label: 'Button', + label: "Button", }, }; export const Large: Story = { args: { - size: 'large', - label: 'Button', + size: "large", + label: "Button", }, }; export const Small: Story = { args: { - size: 'small', - label: 'Button', + size: "small", + label: "Button", }, }; diff --git a/src/stories/Button.tsx b/src/stories/Button.tsx index d96916c..0773d46 100644 --- a/src/stories/Button.tsx +++ b/src/stories/Button.tsx @@ -1,4 +1,4 @@ -import './button.css'; +import "./button.css"; export interface ButtonProps { /** Is this the principal call to action on the page? */ @@ -6,7 +6,7 @@ export interface ButtonProps { /** What background color to use */ backgroundColor?: string; /** How large should the button be? */ - size?: 'small' | 'medium' | 'large'; + size?: "small" | "medium" | "large"; /** Button contents */ label: string; /** Optional click handler */ @@ -16,16 +16,20 @@ export interface ButtonProps { /** Primary UI component for user interaction */ export const Button = ({ primary = false, - size = 'medium', + size = "medium", backgroundColor, label, ...props }: ButtonProps) => { - const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + const mode = primary + ? "storybook-button--primary" + : "storybook-button--secondary"; return (