Skip to content

Commit 7c63756

Browse files
authoredSep 15, 2024··
feat(i18n): added i18n base (#146)
1 parent 1cd0b90 commit 7c63756

File tree

15 files changed

+533
-109
lines changed

15 files changed

+533
-109
lines changed
 

‎packages/app-client/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@corentinth/chisels": "^1.0.2",
3030
"@enclosed/lib": "workspace:*",
3131
"@kobalte/core": "^0.13.4",
32+
"@solid-primitives/i18n": "^2.1.1",
3233
"@solid-primitives/storage": "^4.2.1",
3334
"@solidjs/router": "^0.14.3",
3435
"@unocss/reset": "^0.62.2",

‎packages/app-client/src/index.tsx

+12-9
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ColorModeProvider, ColorModeScript, createLocalStorageManager } from '@
44
import { Router } from '@solidjs/router';
55
import { render, Suspense } from 'solid-js/web';
66
import { ConfigProvider } from './modules/config/config.provider';
7+
import { I18nProvider } from './modules/i18n/i18n.provider';
78
import { NoteContextProvider } from './modules/notes/notes.context';
89
import { routes } from './routes';
910
import '@unocss/reset/tailwind.css';
@@ -22,15 +23,17 @@ render(
2223
root={props => (
2324
<Suspense>
2425
<ConfigProvider>
25-
<NoteContextProvider>
26-
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
27-
<ColorModeProvider
28-
initialColorMode={initialColorMode}
29-
storageManager={localStorageManager}
30-
>
31-
<div class="min-h-screen font-sans text-sm font-400">{props.children}</div>
32-
</ColorModeProvider>
33-
</NoteContextProvider>
26+
<I18nProvider>
27+
<NoteContextProvider>
28+
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
29+
<ColorModeProvider
30+
initialColorMode={initialColorMode}
31+
storageManager={localStorageManager}
32+
>
33+
<div class="min-h-screen font-sans text-sm font-400">{props.children}</div>
34+
</ColorModeProvider>
35+
</NoteContextProvider>
36+
</I18nProvider>
3437
</ConfigProvider>
3538
</Suspense>
3639
)}
+133
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
{
2+
"app": {
3+
"title": "Enclosed",
4+
"description": "Send private and secure notes"
5+
},
6+
"navbar": {
7+
"new-note": "New note",
8+
"theme": {
9+
"light-mode": "Light mode",
10+
"dark-mode": "Dark mode",
11+
"system-mode": "System"
12+
},
13+
"settings": {
14+
"documentation": "Documentation",
15+
"cli": "Enclosed CLI",
16+
"support": "Support Enclosed",
17+
"report-bug": "Report a bug",
18+
"logout": "Logout",
19+
"contribute-to-i18n": "Contribute to i18n"
20+
}
21+
},
22+
"footer": {
23+
"crafted-by": "Crafted by",
24+
"source-code": "Source code available on",
25+
"github": "GitHub",
26+
"version": "Version"
27+
},
28+
"login": {
29+
"title": "Login to Enclosed",
30+
"description": "This is a private instance of Enclosed. Enter your credentials to be able to create notes.",
31+
"email": "Email",
32+
"password": "Password",
33+
"submit": "Login",
34+
"errors": {
35+
"invalid-credentials": "Invalid email or password.",
36+
"unknown": "An unknown error occurred. Please try again later."
37+
},
38+
"footer": [
39+
"Don't have an account?",
40+
"Contact the owner of the instance."
41+
]
42+
},
43+
"create": {
44+
"errors": {
45+
"empty-note": "Please enter a note content or attach a file.",
46+
"rate-limit": "You have exceeded the rate limit for creating notes. Please try again later.",
47+
"too-large": "The note content and attachments are too large. Please reduce the size and try again.",
48+
"unauthorized": "You are not authorized to create notes. Please login and try again.",
49+
"unknown": "An error occurred while creating the note, please try again."
50+
},
51+
"share": {
52+
"button": "Share note",
53+
"title": "Shared note",
54+
"description": "Here is a note shared with you."
55+
},
56+
"settings": {
57+
"placeholder": "Type your note here...",
58+
"password": {
59+
"label": "Note password",
60+
"placeholder": "Password..."
61+
},
62+
"expiration": "Expiration delay",
63+
"delays": {
64+
"1h": "1 hour",
65+
"1d": "1 day",
66+
"1w": "1 week",
67+
"1m": "1 month"
68+
},
69+
"delete-after-reading": {
70+
"label": "Delete after reading",
71+
"description": "Delete the note after reading"
72+
},
73+
"attach-files": "Attach files",
74+
"drop-files": {
75+
"title": "Drop files here",
76+
"description": "Drag and drop files here to attach them to the note"
77+
},
78+
"create": "Create note"
79+
},
80+
"success": {
81+
"title": "Note created successfully",
82+
"description": "Your note has been created. You can now share it using the following link.",
83+
"with-deletion": "The note will be deleted after reading.",
84+
"copy-link": "Copy link",
85+
"copy-success": "Link copied"
86+
}
87+
},
88+
"view": {
89+
"note-content": "Note content",
90+
"download": "Download",
91+
"download-all": "Download all files",
92+
"request-password": {
93+
"description": "This note is password protected. Please enter the password to unlock it.",
94+
"form": {
95+
"label": "Password",
96+
"placeholder": "Note password...",
97+
"unlock-button": "Unlock note",
98+
"invalid": "The password you entered is invalid or the note URL is incorrect."
99+
}
100+
},
101+
"error": {
102+
"invalid-url": {
103+
"title": "Invalid note URL",
104+
"description": "This note URL is invalid. Please make sure you are using the correct URL."
105+
},
106+
"rate-limit": {
107+
"title": "Rate limit exceeded",
108+
"description": "You have exceeded the rate limit for fetching notes. Please try again later."
109+
},
110+
"unauthorized": {
111+
"title": "Unauthorized",
112+
"description": "This note is private. You need to be logged in to view it.",
113+
"button": "Log in"
114+
},
115+
"note-not-found": {
116+
"title": "Note not found",
117+
"description": "This note does not exist, has expired, or has been deleted."
118+
},
119+
"fetch-error": {
120+
"title": "An error occurred",
121+
"description": "An error occurred while fetching the note. Please try again later."
122+
},
123+
"decryption": {
124+
"title": "An error occurred",
125+
"description": "An error occurred while decrypting the note. The url may be invalid."
126+
}
127+
}
128+
},
129+
"copy": {
130+
"label": "Copy to clipboard",
131+
"success": "Copied!"
132+
}
133+
}
+134
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
{
2+
"app": {
3+
"title": "Enclosed",
4+
"description": "Envoyez des notes privées et sécurisées"
5+
},
6+
"navbar": {
7+
"new-note": "Nouvelle note",
8+
"theme": {
9+
"light-mode": "Mode clair",
10+
"dark-mode": "Mode sombre",
11+
"system-mode": "Mode système"
12+
},
13+
"settings": {
14+
"documentation": "Documentation",
15+
"cli": "Enclosed CLI",
16+
"support": "Soutenir Enclosed",
17+
"report-bug": "Signaler un bug",
18+
"logout": "Se déconnecter",
19+
"contribute-to-i18n": "Contribuer à l'i18n"
20+
}
21+
},
22+
"footer": {
23+
"crafted-by": "Créé par",
24+
"source-code": "Code source disponible sur",
25+
"github": "GitHub",
26+
"version": "Version"
27+
},
28+
"login": {
29+
"title": "Connexion à Enclosed",
30+
"description": "Ceci est une instance privée de Enclosed. Entrez vos identifiants pour pouvoir créer des notes.",
31+
"email": "Email",
32+
"password": "Mot de passe",
33+
"submit": "Se connecter",
34+
"errors": {
35+
"invalid-credentials": "Email ou mot de passe invalide.",
36+
"unknown": "Une erreur inconnue est survenue. Veuillez réessayer plus tard."
37+
},
38+
"footer": [
39+
"Vous n'avez pas de compte ?",
40+
"Contactez le propriétaire de l'instance."
41+
]
42+
},
43+
"create": {
44+
"errors": {
45+
"empty-note": "Veuillez entrer le contenu d'une note ou joindre un fichier.",
46+
"rate-limit": "Vous avez dépassé la limite de création de notes. Veuillez réessayer plus tard.",
47+
"too-large": "Le contenu de la note et les pièces jointes sont trop volumineux. Veuillez réduire la taille et réessayer.",
48+
"unauthorized": "Vous n'êtes pas autorisé à créer des notes. Veuillez vous connecter et réessayer.",
49+
"unknown": "Une erreur est survenue lors de la création de la note, veuillez réessayer."
50+
},
51+
"share": {
52+
"button": "Partager la note",
53+
"title": "Note partagée",
54+
"description": "Voici une note partagée avec vous."
55+
},
56+
"settings": {
57+
"placeholder": "Saisissez votre note ici...",
58+
"password": {
59+
"label": "Mot de passe de la note",
60+
"placeholder": "Mot de passe..."
61+
},
62+
"expiration": "Délai d'expiration",
63+
"delays": {
64+
"1h": "1 heure",
65+
"1d": "1 jour",
66+
"1w": "1 semaine",
67+
"1m": "1 mois"
68+
},
69+
"delete-after-reading": {
70+
"label": "Supprimer après lecture",
71+
"description": "Supprimer la note après lecture"
72+
},
73+
"attach-files": "Joindre des fichiers",
74+
"drop-files": {
75+
"title": "Déposez les fichiers ici",
76+
"description": "Glissez et déposez les fichiers ici pour les joindre à la note"
77+
},
78+
"create": "Créer une note"
79+
},
80+
"success": {
81+
"title": "Note créée avec succès",
82+
"description": "Votre note a été créée. Vous pouvez maintenant la partager à l'aide du lien suivant.",
83+
"with-deletion": "La note sera supprimée après lecture.",
84+
"copy-link": "Copier le lien",
85+
"copy-success": "Lien copié"
86+
}
87+
},
88+
"view": {
89+
"note-content": "Contenu de la note",
90+
"download": "Télécharger",
91+
"download-all": "Télécharger tous les fichiers",
92+
"request-password": {
93+
"description": "Cette note est protégée par un mot de passe. Veuillez entrer le mot de passe pour la déverrouiller.",
94+
"form": {
95+
"label": "Mot de passe",
96+
"placeholder": "Mot de passe de la note...",
97+
"unlock-button": "Déverrouiller la note",
98+
"invalid": "Le mot de passe que vous avez entré est incorrect ou l'URL de la note est incorrecte."
99+
}
100+
},
101+
"error": {
102+
103+
"invalid-url": {
104+
"title": "URL de note invalide",
105+
"description": "Cette URL de note est invalide. Veuillez vous assurer d'utiliser la bonne URL."
106+
},
107+
"rate-limit": {
108+
"title": "Limite de taux dépassée",
109+
"description": "Vous avez dépassé la limite de taux pour récupérer des notes. Veuillez réessayer plus tard."
110+
},
111+
"unauthorized": {
112+
"title": "Non autorisé",
113+
"description": "Cette note est privée. Vous devez être connecté pour la voir.",
114+
"button": "Se connecter"
115+
},
116+
"note-not-found": {
117+
"title": "Note introuvable",
118+
"description": "Cette note n'existe pas, a expiré ou a été supprimée."
119+
},
120+
"fetch-error": {
121+
"title": "Une erreur s'est produite",
122+
"description": "Une erreur s'est produite lors de la récupération de la note. Veuillez réessayer plus tard."
123+
},
124+
"decryption": {
125+
"title": "Une erreur s'est produite",
126+
"description": "Une erreur s'est produite lors du déchiffrement de la note. L'URL peut être invalide."
127+
}
128+
}
129+
},
130+
"copy": {
131+
"label": "Copier",
132+
"success": "Copié!"
133+
}
134+
}

‎packages/app-client/src/modules/auth/pages/login.page.tsx

+19-15
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { useConfig } from '@/modules/config/config.provider';
2+
import { useI18n } from '@/modules/i18n/i18n.provider';
23
import { isHttpErrorWithStatusCode } from '@/modules/shared/http/http-errors';
34
import { Alert, AlertDescription } from '@/modules/ui/components/alert';
45
import { Button } from '@/modules/ui/components/button';
56
import { TextField, TextFieldLabel, TextFieldRoot } from '@/modules/ui/components/textfield';
67
import { safely } from '@corentinth/chisels';
78
import { useNavigate } from '@solidjs/router';
8-
import { sample } from 'lodash-es';
9+
import { castArray, sample } from 'lodash-es';
910
import { type Component, createSignal, onMount, Show } from 'solid-js';
1011
import { login } from '../auth.services';
1112
import { authStore } from '../auth.store';
@@ -42,6 +43,7 @@ export const LoginPage: Component = () => {
4243
const [getError, setError] = createSignal<{ message: string; details?: string } | null>(null);
4344
const [getEmail, setEmail] = createSignal('');
4445
const [getPassword, setPassword] = createSignal('');
46+
const { t } = useI18n();
4547

4648
const { config } = useConfig();
4749
const navigate = useNavigate();
@@ -59,12 +61,12 @@ export const LoginPage: Component = () => {
5961
}));
6062

6163
if (isHttpErrorWithStatusCode({ error, statusCode: 401 })) {
62-
setError({ message: 'Invalid email or password.' });
64+
setError({ message: t('login.errors.invalid-credentials') });
6365
return;
6466
}
6567

6668
if (error) {
67-
setError({ message: 'An error occurred. Please try again later.' });
69+
setError({ message: t('login.errors.unknown') });
6870
return;
6971
}
7072

@@ -80,11 +82,11 @@ export const LoginPage: Component = () => {
8082
<div class="h-full hidden xl:flex flex-1 max-w-36% text-white p-6 flex-col justify-between bg-zinc-900">
8183
<div>
8284
<Button variant="link" class="text-white text-lg border-b border-transparent hover:(no-underline !border-border) h-auto py-0 px-0 rounded-none !transition-border-color-250">
83-
Enclosed
85+
{t('app.title')}
8486
</Button>
8587

8688
<span class="text-muted-foreground hidden sm:block">
87-
Send private and secure notes
89+
{t('app.description')}
8890
</span>
8991
</div>
9092

@@ -105,10 +107,10 @@ export const LoginPage: Component = () => {
105107
<div class="px-6 mt-12 lg:mt-200px flex-1">
106108
<div class="md:max-w-sm mx-auto">
107109
<h1 class="text-lg font-semibold">
108-
Login to Enclosed
110+
{t('login.title')}
109111
</h1>
110112
<div class="text-muted-foreground text-pretty">
111-
This is a private instance of Enclosed. Enter your credentials to be able to create and view notes.
113+
{t('login.description')}
112114
</div>
113115

114116
<form onSubmit={(e) => {
@@ -117,10 +119,12 @@ export const LoginPage: Component = () => {
117119
}}
118120
>
119121
<TextFieldRoot class="my-4">
120-
<TextFieldLabel class="sr-only">Email</TextFieldLabel>
122+
<TextFieldLabel class="sr-only">
123+
{t('login.email')}
124+
</TextFieldLabel>
121125
<TextField
122126
type="email"
123-
placeholder="Email"
127+
placeholder={t('login.email')}
124128
onInput={(e) => {
125129
setEmail(e.currentTarget.value);
126130
setError(null);
@@ -130,10 +134,12 @@ export const LoginPage: Component = () => {
130134
</TextFieldRoot>
131135

132136
<TextFieldRoot class="mt-4">
133-
<TextFieldLabel class="sr-only">Password</TextFieldLabel>
137+
<TextFieldLabel class="sr-only">
138+
{t('login.password')}
139+
</TextFieldLabel>
134140
<TextField
135141
type="password"
136-
placeholder="Password"
142+
placeholder={t('login.password')}
137143
onInput={(e) => {
138144
setPassword(e.currentTarget.value);
139145
setError(null);
@@ -143,13 +149,11 @@ export const LoginPage: Component = () => {
143149
</TextFieldRoot>
144150

145151
<Button class="mt-4 w-full" variant="default" type="submit">
146-
Login
152+
{t('login.submit')}
147153
</Button>
148154

149155
<p class="text-center text-muted-foreground text-sm mt-4">
150-
Don't have an account?
151-
<br />
152-
Contact the owner of the instance.
156+
{castArray(t('login.footer')).map(text => (<div>{text}</div>))}
153157
</p>
154158

155159
<Show when={getError()}>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import type { ParentComponent } from 'solid-js';
2+
import * as i18n from '@solid-primitives/i18n';
3+
import { makePersisted } from '@solid-primitives/storage';
4+
import { merge } from 'lodash-es';
5+
import { createContext, createResource, createSignal, Show, useContext } from 'solid-js';
6+
import defaultDict from '../../locales/en.json';
7+
8+
export {
9+
useI18n,
10+
};
11+
12+
const locales = [
13+
{
14+
key: 'en',
15+
file: 'en',
16+
name: 'English',
17+
},
18+
{
19+
key: 'fr',
20+
file: 'fr',
21+
name: 'Français',
22+
},
23+
] as const;
24+
25+
type Locale = typeof locales[number]['key'];
26+
type RawDictionary = typeof defaultDict;
27+
type Dictionary = i18n.Flatten<RawDictionary>;
28+
29+
const I18nContext = createContext<{
30+
t: i18n.Translator<Dictionary>;
31+
getLocale: () => Locale;
32+
setLocale: (locale: Locale) => void;
33+
locales: typeof locales;
34+
} | undefined>(undefined);
35+
36+
function useI18n() {
37+
const context = useContext(I18nContext);
38+
39+
if (!context) {
40+
throw new Error('I18n context not found');
41+
}
42+
43+
return context;
44+
}
45+
46+
async function fetchDictionary(locale: Locale): Promise<Dictionary> {
47+
const dict: RawDictionary = (await import(`../../locales/${locale}.json`));
48+
const mergedDict = merge({}, defaultDict, dict);
49+
const flattened = i18n.flatten(mergedDict);
50+
51+
return flattened;
52+
}
53+
54+
function getBrowserLocale(): Locale {
55+
const browserLocale = navigator.language?.split('-')[0];
56+
57+
if (!browserLocale) {
58+
return 'en';
59+
}
60+
61+
return locales.find(locale => locale.key === browserLocale)?.key ?? 'en';
62+
}
63+
64+
export const I18nProvider: ParentComponent = (props) => {
65+
const browserLocale = getBrowserLocale();
66+
const [getLocale, setLocale] = makePersisted(createSignal<Locale>(browserLocale), { name: 'enclosed_locale', storage: localStorage });
67+
68+
const [dict] = createResource(getLocale, fetchDictionary);
69+
70+
return (
71+
<Show when={dict()}>
72+
{dict => (
73+
<I18nContext.Provider
74+
value={{
75+
t: i18n.translator(dict),
76+
getLocale,
77+
setLocale,
78+
locales,
79+
}}
80+
>
81+
{props.children}
82+
</I18nContext.Provider>
83+
)}
84+
</Show>
85+
);
86+
};

‎packages/app-client/src/modules/notes/components/file-uploader.tsx

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { useI18n } from '@/modules/i18n/i18n.provider';
12
import { cn } from '@/modules/shared/style/cn';
23
import { Button } from '@/modules/ui/components/button';
34
import { type Component, type ComponentProps, createSignal, onCleanup, type ParentComponent, splitProps } from 'solid-js';
45

56
const DropArea: Component<{ onFilesDrop?: (args: { files: File[] }) => void }> = (props) => {
67
const [isDragging, setIsDragging] = createSignal(false);
8+
const { t } = useI18n();
79

810
const handleDragOver = (e: DragEvent) => {
911
e.preventDefault();
@@ -46,9 +48,11 @@ const DropArea: Component<{ onFilesDrop?: (args: { files: File[] }) => void }> =
4648
>
4749
<div class="flex items-center justify-center h-full text-center flex-col">
4850
<div class="i-tabler-file-plus text-6xl text-muted-foreground mx-auto"></div>
49-
<div class="text-xl my-2 font-semibold text-muted-foreground">Drop files here</div>
51+
<div class="text-xl my-2 font-semibold text-muted-foreground">
52+
{t('create.settings.drop-files.title')}
53+
</div>
5054
<div class="text-base text-muted-foreground">
51-
Drag and drop files here to attach them to the note
55+
{t('create.settings.drop-files.description')}
5256
</div>
5357
</div>
5458
</div>
@@ -97,7 +101,7 @@ export const FileUploaderButton: ParentComponent<{
97101
/>
98102
<DropArea onFilesDrop={uploadFiles} />
99103
<Button onClick={onButtonClick} {...rest}>
100-
{props.children ?? 'Upload File'}
104+
{props.children}
101105
</Button>
102106
</>
103107
);

‎packages/app-client/src/modules/notes/components/note-password-field.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1+
import { useI18n } from '@/modules/i18n/i18n.provider';
12
import { Button } from '@/modules/ui/components/button';
23
import { TextField } from '@/modules/ui/components/textfield';
34
import { type Component, createSignal } from 'solid-js';
45
import { createRandomPassword } from '../notes.models';
56

67
export const NotePasswordField: Component<{ getPassword: () => string; setPassword: (value: string) => void }> = (props) => {
78
const [getShowPassword, setShowPassword] = createSignal(false);
9+
const { t } = useI18n();
810

911
const generateRandomPassword = () => {
1012
const password = createRandomPassword({ length: 16 });
@@ -15,7 +17,7 @@ export const NotePasswordField: Component<{ getPassword: () => string; setPasswo
1517

1618
return (
1719
<div class="border border-input rounded-md flex items-center pr-1">
18-
<TextField placeholder="Password..." value={props.getPassword()} onInput={e => props.setPassword(e.currentTarget.value)} class="border-none shadow-none focus-visible:ring-none" type={getShowPassword() ? 'text' : 'password'} />
20+
<TextField placeholder={t('create.settings.password.placeholder')} value={props.getPassword()} onInput={e => props.setPassword(e.currentTarget.value)} class="border-none shadow-none focus-visible:ring-none" type={getShowPassword() ? 'text' : 'password'} />
1921

2022
<Button variant="link" onClick={() => setShowPassword(!getShowPassword())} class="text-base size-9 p-0 text-muted-foreground hover:text-primary transition" aria-label={getShowPassword() ? 'Hide password' : 'Show password'}>
2123
<div classList={{ 'i-tabler-eye': !getShowPassword(), 'i-tabler-eye-off': getShowPassword() }}></div>

‎packages/app-client/src/modules/notes/pages/create-note.page.tsx

+37-25
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { authStore } from '@/modules/auth/auth.store';
22
import { useConfig } from '@/modules/config/config.provider';
33
import { getFileIcon } from '@/modules/files/files.models';
4+
import { useI18n } from '@/modules/i18n/i18n.provider';
45
import { isHttpErrorWithCode, isRateLimitError } from '@/modules/shared/http/http-errors';
56
import { cn } from '@/modules/shared/style/cn';
67
import { CopyButton } from '@/modules/shared/utils/copy';
@@ -29,6 +30,8 @@ export const CreateNotePage: Component = () => {
2930
const [getDeleteAfterReading, setDeleteAfterReading] = createSignal(false);
3031
const [getUploadedFiles, setUploadedFiles] = createSignal<File[]>([]);
3132

33+
const { t } = useI18n();
34+
3235
const { config } = useConfig();
3336
const navigate = useNavigate();
3437

@@ -61,7 +64,7 @@ export const CreateNotePage: Component = () => {
6164

6265
const createNote = async () => {
6366
if (!getContent() && getUploadedFiles().length === 0) {
64-
setError({ message: 'Please enter a note content or attach a file.' });
67+
setError({ message: t('create.errors.empty-note') });
6568
return;
6669
}
6770

@@ -83,21 +86,21 @@ export const CreateNotePage: Component = () => {
8386
}
8487

8588
if (isRateLimitError({ error })) {
86-
setError({ message: 'You have exceeded the rate limit for creating notes. Please try again later.' });
89+
setError({ message: t('create.errors.rate-limit') });
8790
return;
8891
}
8992

9093
if (isHttpErrorWithCode({ error, code: 'note.payload_too_large' })) {
91-
setError({ message: 'The note content and attachments are too large. Please reduce the size and try again.' });
94+
setError({ message: t('create.errors.too-large') });
9295
return;
9396
}
9497

9598
if (isHttpErrorWithCode({ error, code: 'auth.unauthorized' })) {
96-
setError({ message: 'You are not authorized to create notes. Please login and try again.' });
99+
setError({ message: t('create.errors.unauthorized') });
97100
return;
98101
}
99102

100-
setError({ message: 'An error occurred while creating the note, please try again.', details: error.message });
103+
setError({ message: t('create.errors.unknown'), details: error.message });
101104
};
102105

103106
function updateContent(text: string) {
@@ -114,8 +117,8 @@ export const CreateNotePage: Component = () => {
114117

115118
try {
116119
await navigator.share({
117-
title: 'Shared note',
118-
text: 'Here is a note for you',
120+
title: t('create.share.title'),
121+
text: t('create.share.description'),
119122
url: getNoteUrl(),
120123
});
121124
} catch (error) {
@@ -128,28 +131,32 @@ export const CreateNotePage: Component = () => {
128131
<Switch>
129132
<Match when={!getIsNoteCreated()}>
130133
<TextFieldRoot class="w-full ">
131-
<TextArea placeholder="Type your note here." class="flex-1 p-4 min-h-300px sm:min-h-700px" value={getContent()} onInput={e => updateContent(e.currentTarget.value)} />
134+
<TextArea placeholder={t('create.settings.placeholder')} class="flex-1 p-4 min-h-300px sm:min-h-700px" value={getContent()} onInput={e => updateContent(e.currentTarget.value)} />
132135
</TextFieldRoot>
133136

134137
<div class="w-full sm:w-320px flex flex-col gap-4 flex-shrink-0">
135138
<TextFieldRoot class="w-full">
136-
<TextFieldLabel>Note password</TextFieldLabel>
139+
<TextFieldLabel>
140+
{t('create.settings.password.label')}
141+
</TextFieldLabel>
137142
<NotePasswordField getPassword={getPassword} setPassword={setPassword} />
138143

139144
</TextFieldRoot>
140145

141146
<TextFieldRoot class="w-full">
142-
<TextFieldLabel>Expiration delay</TextFieldLabel>
147+
<TextFieldLabel>
148+
{t('create.settings.expiration')}
149+
</TextFieldLabel>
143150
<Tabs
144151
value={getTtlInSeconds().toString()}
145152
onChange={(value: string) => setTtlInSeconds(Number(value))}
146153
>
147154
<TabsList>
148155
<TabsIndicator />
149-
<TabsTrigger value="3600">1 hour</TabsTrigger>
150-
<TabsTrigger value="86400">1 day</TabsTrigger>
151-
<TabsTrigger value="604800">1 week</TabsTrigger>
152-
<TabsTrigger value="2592000">1 month</TabsTrigger>
156+
<TabsTrigger value="3600">{t('create.settings.delays.1h')}</TabsTrigger>
157+
<TabsTrigger value="86400">{t('create.settings.delays.1d')}</TabsTrigger>
158+
<TabsTrigger value="604800">{t('create.settings.delays.1w')}</TabsTrigger>
159+
<TabsTrigger value="2592000">{t('create.settings.delays.1m')}</TabsTrigger>
153160
</TabsList>
154161
</Tabs>
155162
</TextFieldRoot>
@@ -173,26 +180,28 @@ export const CreateNotePage: Component = () => {
173180
)} */}
174181

175182
<TextFieldRoot class="w-full">
176-
<TextFieldLabel>Delete after reading</TextFieldLabel>
183+
<TextFieldLabel>
184+
{t('create.settings.delete-after-reading.label')}
185+
</TextFieldLabel>
177186
<SwitchUiComponent class="flex items-center space-x-2" checked={getDeleteAfterReading()} onChange={setDeleteAfterReading}>
178187
<SwitchControl>
179188
<SwitchThumb />
180189
</SwitchControl>
181190
<SwitchLabel class="text-sm text-muted-foreground">
182-
Delete the note after reading
191+
{t('create.settings.delete-after-reading.description')}
183192
</SwitchLabel>
184193
</SwitchUiComponent>
185194
</TextFieldRoot>
186195

187196
<div>
188197
<FileUploaderButton variant="secondary" class="mt-2 w-full" multiple onFilesUpload={({ files }) => setUploadedFiles(prevFiles => [...prevFiles, ...files])}>
189198
<div class="i-tabler-upload mr-2 text-lg text-muted-foreground"></div>
190-
Attach files
199+
{t('create.settings.attach-files')}
191200
</FileUploaderButton>
192201

193202
<Button class="mt-2 w-full" onClick={createNote}>
194203
<div class="i-tabler-plus mr-2 text-lg text-muted-foreground"></div>
195-
Create note
204+
{t('create.settings.create')}
196205
</Button>
197206
</div>
198207

@@ -203,7 +212,6 @@ export const CreateNotePage: Component = () => {
203212
<div class="truncate" title={file.name}>
204213
{file.name}
205214
</div>
206-
{/* <div class="text-muted-foreground text-sm">{(file.size)}</div> */}
207215

208216
<Button class="size-9 ml-auto" variant="ghost" onClick={() => setUploadedFiles(prevFiles => prevFiles.filter(f => f !== file))}>
209217
<div class="i-tabler-x text-lg text-muted-foreground cursor-pointer flex-shrink-0"></div>
@@ -230,12 +238,16 @@ export const CreateNotePage: Component = () => {
230238
<div class="flex flex-col justify-center items-center gap-2 w-full mt-12 mx-auto">
231239
<div class="i-tabler-circle-check text-primary text-5xl"></div>
232240
<div class="text-xl font-semibold">
233-
Note created successfully
241+
{t('create.success.title')}
234242
</div>
235243

236244
<div class="text-muted-foreground text-center max-w-400px">
237-
Your note has been created. You can now share it using the following link.
238-
{getDeleteAfterReading() && (' This note will be deleted after reading.')}
245+
{
246+
[
247+
t('create.success.description'),
248+
getDeleteAfterReading() && t('create.success.with-deletion'),
249+
].filter(Boolean).join(' ')
250+
}
239251

240252
</div>
241253

@@ -250,14 +262,14 @@ export const CreateNotePage: Component = () => {
250262
class="flex-shrink-0 w-full sm:w-auto"
251263
autofocus
252264
text={getNoteUrl()}
253-
label="Copy link"
254-
copiedLabel="Link copied"
265+
label={t('create.success.copy-link')}
266+
copiedLabel={t('create.success.copy-success')}
255267
/>
256268

257269
<Show when={getIsShareApiSupported()}>
258270
<Button variant="secondary" class="flex-shrink-0 w-full sm:w-auto" onClick={shareNote}>
259271
<div class="i-tabler-share mr-2 text-lg"></div>
260-
Share
272+
{t('create.share.button')}
261273
</Button>
262274
</Show>
263275
</div>

‎packages/app-client/src/modules/notes/pages/view-note.page.tsx

+27-31
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { authStore } from '@/modules/auth/auth.store';
22
import { getFileIcon } from '@/modules/files/files.models';
3+
import { useI18n } from '@/modules/i18n/i18n.provider';
34
import { isHttpErrorWithCode, isRateLimitError } from '@/modules/shared/http/http-errors';
45
import { cn } from '@/modules/shared/style/cn';
56
import { CopyButton } from '@/modules/shared/utils/copy';
@@ -16,6 +17,7 @@ import { fetchNoteById } from '../notes.services';
1617

1718
const RequestPasswordForm: Component<{ onPasswordEntered: (args: { password: string }) => void; getIsPasswordInvalid: () => boolean; setIsPasswordInvalid: (value: boolean) => void }> = (props) => {
1819
const [getPassword, setPassword] = createSignal('');
20+
const { t } = useI18n();
1921

2022
function updatePassword(text: string) {
2123
setPassword(text);
@@ -27,7 +29,7 @@ const RequestPasswordForm: Component<{ onPasswordEntered: (args: { password: str
2729
<Card class="w-full max-w-sm mx-auto">
2830
<CardHeader>
2931
<CardDescription>
30-
This note is password protected. Please enter the password to unlock it.
32+
{t('view.request-password.description')}
3133
</CardDescription>
3234
</CardHeader>
3335
<CardContent>
@@ -38,23 +40,19 @@ const RequestPasswordForm: Component<{ onPasswordEntered: (args: { password: str
3840
>
3941
<div>
4042
<TextFieldRoot>
41-
<TextFieldLabel>Password</TextFieldLabel>
42-
<TextField type="password" placeholder="Note password..." value={getPassword()} onInput={e => updatePassword(e.currentTarget.value)} autofocus />
43+
<TextFieldLabel>{t('view.request-password.form.label')}</TextFieldLabel>
44+
<TextField type="password" placeholder={t('view.request-password.form.placeholder')} value={getPassword()} onInput={e => updatePassword(e.currentTarget.value)} autofocus />
4345
</TextFieldRoot>
4446
</div>
45-
<Button
46-
class="w-full mt-4"
47-
type="submit"
48-
>
47+
<Button class="w-full mt-4" type="submit">
4948
<div class="i-tabler-lock-open mr-2 text-lg"></div>
50-
Unlock note
49+
{t('view.request-password.form.unlock-button')}
5150
</Button>
52-
5351
</form>
5452
<Show when={props.getIsPasswordInvalid()}>
5553
<Alert class="mt-4" variant="destructive">
5654
<AlertDescription>
57-
The password you entered is invalid or the note URL is incorrect.
55+
{t('view.request-password.form.invalid')}
5856
</AlertDescription>
5957
</Alert>
6058
</Show>
@@ -78,15 +76,16 @@ export const ViewNotePage: Component = () => {
7876
const [getEncryptionKey, setEncryptionKey] = createSignal('');
7977
const [getIsPasswordProtected, setIsPasswordProtected] = createSignal(false);
8078

79+
const { t } = useI18n();
8180
const navigate = useNavigate();
8281

8382
onMount(async () => {
8483
const [parsedHashFragment, parsingError] = safelySync(() => parseNoteUrlHashFragment({ hashFragment: location.hash }));
8584

8685
if (parsingError) {
8786
setError({
88-
title: 'Invalid note URL',
89-
description: 'This note URL is invalid. Please make sure you are using the correct URL.',
87+
title: t('view.error.invalid-url.title'),
88+
description: t('view.error.invalid-url.description'),
9089
});
9190
return;
9291
}
@@ -98,8 +97,8 @@ export const ViewNotePage: Component = () => {
9897

9998
if (!encryptionKey) {
10099
setError({
101-
title: 'Invalid note URL',
102-
description: 'This note URL is invalid. Please make sure you are using the correct URL.',
100+
title: t('view.error.invalid-url.title'),
101+
description: t('view.error.invalid-url.description'),
103102
});
104103
return;
105104
}
@@ -108,16 +107,16 @@ export const ViewNotePage: Component = () => {
108107

109108
if (isRateLimitError({ error: fetchError })) {
110109
setError({
111-
title: 'Rate limit exceeded',
112-
description: 'You have exceeded the rate limit for fetching notes. Please try again later.',
110+
title: t('view.error.rate-limit.title'),
111+
description: t('view.error.rate-limit.description'),
113112
});
114113
return;
115114
}
116115

117116
if (isHttpErrorWithCode({ error: fetchError, code: 'auth.unauthorized' })) {
118117
setError({
119-
title: 'Unauthorized',
120-
description: 'This note is private. You need to be logged in to view it.',
118+
title: t('view.error.unauthorized.title'),
119+
description: t('view.error.unauthorized.description'),
121120
action: (
122121
<Button
123122
onClick={() => {
@@ -127,7 +126,7 @@ export const ViewNotePage: Component = () => {
127126
variant="secondary"
128127
>
129128
<div class="i-tabler-login-2 mr-2 text-lg"></div>
130-
Log in
129+
{t('view.error.unauthorized.button')}
131130
</Button>
132131
),
133132
});
@@ -136,16 +135,16 @@ export const ViewNotePage: Component = () => {
136135

137136
if (isHttpErrorWithCode({ error: fetchError, code: 'note.not_found' })) {
138137
setError({
139-
title: 'Note not found',
140-
description: 'This note does not exist, has expired, or has been deleted.',
138+
title: t('view.error.note-not-found.title'),
139+
description: t('view.error.note-not-found.description'),
141140
});
142141
return;
143142
}
144143

145144
if (fetchError) {
146145
setError({
147-
title: 'An error occurred',
148-
description: 'An error occurred while fetching the note. Please try again later.',
146+
title: t('view.error.fetch-error.title'),
147+
description: t('view.error.fetch-error.description'),
149148
});
150149
return;
151150
}
@@ -169,8 +168,8 @@ export const ViewNotePage: Component = () => {
169168

170169
if (decryptionError) {
171170
setError({
172-
title: 'An error occurred',
173-
description: 'An error occurred while decrypting the note. The url may be invalid.',
171+
title: t('view.error.decryption.title'),
172+
description: t('view.error.decryption.description'),
174173
});
175174
return;
176175
}
@@ -264,7 +263,7 @@ export const ViewNotePage: Component = () => {
264263
<div class="flex-1 mb-4">
265264
<div class="flex items-center gap-2 mb-4 justify-between">
266265
<div class="text-muted-foreground">
267-
Note content
266+
{t('view.note-content')}
268267
</div>
269268
<CopyButton text={getDecryptedNote()!} variant="secondary" />
270269
</div>
@@ -296,7 +295,7 @@ export const ViewNotePage: Component = () => {
296295
? <div class="i-tabler-loader-2 mr-2 text-lg animate-spin"></div>
297296
: <div class="i-tabler-file-zip mr-2 text-lg"></div>}
298297

299-
Download all files
298+
{t('view.download-all')}
300299
</Button>
301300
)}
302301
</div>
@@ -318,19 +317,16 @@ export const ViewNotePage: Component = () => {
318317
<div class="ml-auto">
319318
<Button variant="secondary" onClick={() => downloadFile({ file })}>
320319
<div class="i-tabler-download mr-2 text-lg"></div>
321-
Download
320+
{t('view.download')}
322321
</Button>
323322
</div>
324323
</CardContent>
325324
</Card>
326325
))
327326
}
328327
</div>
329-
330328
</div>
331-
332329
)}
333-
334330
</div>
335331
</Match>
336332
</Switch>

‎packages/app-client/src/modules/shared/utils/copy.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ComponentProps, ParentComponent } from 'solid-js';
2+
import { useI18n } from '@/modules/i18n/i18n.provider';
23
import { Button } from '@/modules/ui/components/button';
34
import { createSignal } from 'solid-js';
45

@@ -17,14 +18,15 @@ export function useCopy() {
1718

1819
export const CopyButton: ParentComponent<{ text: string; label?: string; copiedLabel?: string } & ComponentProps<typeof Button>> = (props) => {
1920
const { copy, getIsJustCopied } = useCopy();
21+
const { t } = useI18n();
2022

2123
return (
2224
<Button
2325
onClick={() => copy({ text: props.text })}
2426
{...props}
2527
>
2628
<div classList={{ 'i-tabler-copy': !getIsJustCopied(), 'i-tabler-check': getIsJustCopied() }} class="mr-2 text-lg" />
27-
{props.children || (getIsJustCopied() ? props.copiedLabel ?? 'Copied!' : props.label ?? 'Copy to clipboard')}
29+
{props.children || (getIsJustCopied() ? props.copiedLabel ?? t('copy.success') : props.label ?? t('copy.label'))}
2830
</Button>
2931
);
3032
};

‎packages/app-client/src/modules/ui/layouts/app.layout.tsx

+43-15
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { authStore } from '@/modules/auth/auth.store';
33
import { buildTimeConfig } from '@/modules/config/config.constants';
44
import { useConfig } from '@/modules/config/config.provider';
55
import { buildDocUrl } from '@/modules/docs/docs.models';
6+
import { useI18n } from '@/modules/i18n/i18n.provider';
67
import { useNoteContext } from '@/modules/notes/notes.context';
8+
import { cn } from '@/modules/shared/style/cn';
79
import { useThemeStore } from '@/modules/theme/theme.store';
810
import { Button } from '@/modules/ui/components/button';
911
import { DropdownMenu } from '@kobalte/core/dropdown-menu';
@@ -14,6 +16,7 @@ export const Navbar: Component = () => {
1416
const themeStore = useThemeStore();
1517
const { triggerResetNoteForm } = useNoteContext();
1618
const navigate = useNavigate();
19+
const { t, getLocale, setLocale, locales } = useI18n();
1720

1821
const { config } = useConfig();
1922

@@ -27,18 +30,18 @@ export const Navbar: Component = () => {
2730
<div class="flex items-center justify-between px-6 py-3 mx-auto max-w-1200px">
2831
<div class="flex items-baseline gap-4">
2932
<Button variant="link" class="text-lg font-semibold border-b border-transparent hover:(no-underline !border-border) h-auto py-0 px-1 ml--1 rounded-none !transition-border-color-250" onClick={newNoteClicked}>
30-
Enclosed
33+
{t('app.title')}
3134
</Button>
3235

3336
<span class="text-muted-foreground hidden sm:block">
34-
Send private and secure notes
37+
{t('app.description')}
3538
</span>
3639
</div>
3740

3841
<div class="flex gap-2 items-center">
3942
<Button variant="secondary" onClick={newNoteClicked}>
4043
<div class="i-tabler-plus mr-1 text-muted-foreground"></div>
41-
New note
44+
{t('navbar.new-note')}
4245
</Button>
4346

4447
<Button variant="ghost" class="text-lg px-0 size-9" as={A} href="https://github.com/CorentinTh/enclosed" target="_blank" rel="noopener noreferrer" aria-label="GitHub repository">
@@ -52,19 +55,39 @@ export const Navbar: Component = () => {
5255
<DropdownMenuContent class="w-42">
5356
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'light' })} class="flex items-center gap-2 cursor-pointer">
5457
<div class="i-tabler-sun text-lg"></div>
55-
Light mode
58+
{t('navbar.theme.light-mode')}
5659
</DropdownMenuItem>
5760
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'dark' })} class="flex items-center gap-2 cursor-pointer">
5861
<div class="i-tabler-moon text-lg"></div>
59-
Dark mode
62+
{t('navbar.theme.dark-mode')}
6063
</DropdownMenuItem>
6164
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'system' })} class="flex items-center gap-2 cursor-pointer">
6265
<div class="i-tabler-device-laptop text-lg"></div>
63-
System
66+
{t('navbar.theme.system-mode')}
6467
</DropdownMenuItem>
6568
</DropdownMenuContent>
6669
</DropdownMenu>
6770

71+
<DropdownMenu>
72+
<DropdownMenuTrigger as={Button} class="text-lg px-0 size-9" variant="ghost" aria-label="Language">
73+
<div class="i-custom-language size-4"></div>
74+
</DropdownMenuTrigger>
75+
<DropdownMenuContent>
76+
{locales.map(locale => (
77+
<DropdownMenuItem onClick={() => setLocale(locale.key)} class={cn('flex items-center gap-2 cursor-pointer', { 'font-semibold': getLocale() === locale.key })}>
78+
{locale.name}
79+
</DropdownMenuItem>
80+
))}
81+
82+
<DropdownMenuSeparator />
83+
84+
<DropdownMenuItem as="a" class="flex items-center gap-2 cursor-pointer" target="_blank" rel="noopener noreferrer" href="https://github.com/CorentinTh/enclosed/tree/main/packages/app-client/src/locales">
85+
{t('navbar.settings.contribute-to-i18n')}
86+
</DropdownMenuItem>
87+
88+
</DropdownMenuContent>
89+
</DropdownMenu>
90+
6891
<DropdownMenu>
6992
<DropdownMenuTrigger as={Button} class="text-lg px-0 size-9" variant="ghost" aria-label="Change theme">
7093
<div class="i-tabler-dots-vertical"></div>
@@ -73,27 +96,30 @@ export const Navbar: Component = () => {
7396
<DropdownMenuContent class="w-46">
7497
<DropdownMenuItem as="a" class="flex items-center gap-2 cursor-pointer" target="_blank" href={buildDocUrl({ path: '/' })}>
7598
<div class="i-tabler-file-text text-lg"></div>
76-
Documentation
99+
{t('navbar.settings.documentation')}
77100
</DropdownMenuItem>
78101

79102
<DropdownMenuItem as="a" class="flex items-center gap-2 cursor-pointer" target="_blank" href={buildDocUrl({ path: '/integrations/cli' })}>
80103
<div class="i-tabler-terminal text-lg"></div>
81-
Enclosed CLI
104+
{t('navbar.settings.cli')}
82105
</DropdownMenuItem>
83106

84-
<DropdownMenuSeparator />
107+
<DropdownMenuItem as="a" class="flex items-center gap-2 cursor-pointer" target="_blank" href="https://github.com/CorentinTh/enclosed/issues/new/choose" rel="noopener noreferrer">
108+
<div class="i-tabler-bug text-lg"></div>
109+
{t('navbar.settings.report-bug')}
110+
</DropdownMenuItem>
85111

86112
<DropdownMenuItem as="a" class="flex items-center gap-2 cursor-pointer" target="_blank" href="https://buymeacoffee.com/cthmsst" rel="noopener noreferrer">
87113
<div class="i-tabler-pig-money text-lg"></div>
88-
Support Enclosed
114+
{t('navbar.settings.support')}
89115
</DropdownMenuItem>
90116

91117
{config.isAuthenticationRequired && authStore.getIsAuthenticated() && (
92118
<>
93119
<DropdownMenuSeparator />
94120
<DropdownMenuItem class="flex items-center gap-2 cursor-pointer" onClick={() => authStore.logout()}>
95121
<div class="i-tabler-logout text-lg"></div>
96-
Logout
122+
{t('navbar.settings.logout')}
97123
</DropdownMenuItem>
98124
</>
99125
)}
@@ -108,23 +134,25 @@ export const Navbar: Component = () => {
108134
};
109135

110136
export const Footer: Component = () => {
137+
const { t } = useI18n();
138+
111139
return (
112140
<div class="bg-surface border-t border-border py-6 px-6 text-center text-muted-foreground flex flex-col sm:flex-row items-center justify-center gap-1">
113141
<div>
114-
Crafted by
142+
{t('footer.crafted-by')}
115143
{' '}
116144
<Button variant="link" as="a" href="https://corentin.tech" target="_blank" class="p-0 text-muted-foreground underline hover:text-primary transition font-normal h-auto">Corentin Thomasset</Button>
117145
.
118146
</div>
119147
<div>
120-
Source code available on
148+
{t('footer.source-code')}
121149
{' '}
122-
<Button variant="link" as="a" href="https://github.com/CorentinTh/enclosed" target="_blank" class="p-0 text-muted-foreground underline hover:text-primary transition font-normal h-auto">GitHub</Button>
150+
<Button variant="link" as="a" href="https://github.com/CorentinTh/enclosed" target="_blank" class="p-0 text-muted-foreground underline hover:text-primary transition font-normal h-auto">{t('footer.github')}</Button>
123151
.
124152
</div>
125153

126154
<div>
127-
Version
155+
{t('footer.version')}
128156
{' '}
129157
<Button variant="link" as="a" href={`https://github.com/CorentinTh/enclosed/tree/v${buildTimeConfig.enclosedVersion}`} target="_blank" class="p-0 text-muted-foreground underline hover:text-primary transition font-normal h-auto">
130158
v

‎packages/app-client/tsconfig.json

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"paths": {
1010
"@/*": ["./src/*"]
1111
},
12+
"resolveJsonModule": true,
1213
"types": ["vite/client"],
1314
"strict": true,
1415
"noEmit": true,

‎packages/app-client/uno.config.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ export default defineConfig({
2525
sans: 'Inter:400,500,600,700,800,900',
2626
},
2727
}),
28-
presetIcons(),
28+
presetIcons({
29+
collections: {
30+
custom: {
31+
language: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512"><path fill="currentColor" d="m478.33 433.6l-90-218a22 22 0 0 0-40.67 0l-90 218a22 22 0 1 0 40.67 16.79L316.66 406h102.67l18.33 44.39A22 22 0 0 0 458 464a22 22 0 0 0 20.32-30.4ZM334.83 362L368 281.65L401.17 362Zm-66.99-19.08a22 22 0 0 0-4.89-30.7c-.2-.15-15-11.13-36.49-34.73c39.65-53.68 62.11-114.75 71.27-143.49H330a22 22 0 0 0 0-44H214V70a22 22 0 0 0-44 0v20H54a22 22 0 0 0 0 44h197.25c-9.52 26.95-27.05 69.5-53.79 108.36c-31.41-41.68-43.08-68.65-43.17-68.87a22 22 0 0 0-40.58 17c.58 1.38 14.55 34.23 52.86 83.93c.92 1.19 1.83 2.35 2.74 3.51c-39.24 44.35-77.74 71.86-93.85 80.74a22 22 0 1 0 21.07 38.63c2.16-1.18 48.6-26.89 101.63-85.59c22.52 24.08 38 35.44 38.93 36.1a22 22 0 0 0 30.75-4.9Z" /></svg>',
32+
},
33+
},
34+
}),
2935
],
3036
transformers: [transformerVariantGroup(), transformerDirectives()],
3137
theme: {

‎pnpm-lock.yaml

+20-8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.