feat: it-tools v3 base

This commit is contained in:
Corentin Thomasset 2024-09-30 09:04:13 +02:00
parent 1c35ac3704
commit f8b5cbfd87
No known key found for this signature in database
GPG key ID: DBD997E935996158
530 changed files with 7529 additions and 33524 deletions

View file

@ -0,0 +1,16 @@
import { map } from 'lodash-es';
export const locales = [
{
key: 'en',
file: 'en',
name: 'English',
},
{
key: 'fr',
file: 'fr',
name: 'Français',
},
] as const;
export const localeKeys = map(locales, 'key');

View file

@ -0,0 +1,81 @@
import type { ParentComponent } from 'solid-js';
import type { LocaleKey } from './i18n.types';
import * as i18n from '@solid-primitives/i18n';
import { makePersisted } from '@solid-primitives/storage';
import { merge } from 'lodash-es';
import { createContext, createResource, createSignal, Show, useContext } from 'solid-js';
import defaultDict from '../../locales/en.json';
import { locales } from './i18n.constants';
export {
useI18n,
};
type RawDictionary = typeof defaultDict;
type Dictionary = i18n.Flatten<RawDictionary>;
const RootI18nContext = createContext<{
t: i18n.Translator<Dictionary>;
getLocale: () => LocaleKey;
setLocale: (locale: LocaleKey) => void;
locales: typeof locales;
} | undefined>(undefined);
function useI18n() {
const context = useContext(RootI18nContext);
if (!context) {
throw new Error('I18n context not found');
}
const { t, getLocale, setLocale, locales } = context;
return {
t,
getLocale,
setLocale,
locales,
};
}
async function fetchDictionary(locale: LocaleKey): Promise<Dictionary> {
const dict: RawDictionary = (await import(`../../locales/${locale}.json`));
const mergedDict = merge({}, defaultDict, dict);
const flattened = i18n.flatten(mergedDict);
return flattened;
}
export function getBrowserLocale(): LocaleKey {
const browserLocale = navigator.language?.split('-')[0];
if (!browserLocale) {
return 'en';
}
return locales.find(locale => locale.key === browserLocale)?.key ?? 'en';
}
export const RootI18nProvider: ParentComponent = (props) => {
const browserLocale = getBrowserLocale();
const [getLocale, setLocale] = makePersisted(createSignal<LocaleKey>(browserLocale), { name: 'it_tools_locale', storage: localStorage });
const [dict] = createResource(getLocale, fetchDictionary);
return (
<Show when={dict()}>
{dict => (
<RootI18nContext.Provider
value={{
t: i18n.translator(dict),
getLocale,
setLocale,
locales,
}}
>
{props.children}
</RootI18nContext.Provider>
)}
</Show>
);
};

View file

@ -0,0 +1,5 @@
import type { locales } from './i18n.constants';
export type LocaleKey = typeof locales[number]['key'];

View file

@ -0,0 +1,80 @@
import type { Component } from 'solid-js';
import { A } from '@solidjs/router';
import { useI18n } from '../i18n/i18n.provider';
import { useToolsStore } from '../tools/tools.store';
import { Badge } from '../ui/components/badge';
import { Button } from '../ui/components/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/components/card';
import { cn } from '../ui/utils/cn';
export const HomePage: Component = () => {
const { t } = useI18n();
const { tools } = useToolsStore();
return (
<div>
<div class="w-full bg-[linear-gradient(to_right,#80808010_1px,transparent_1px),linear-gradient(to_bottom,#80808010_1px,transparent_1px)] bg-[size:48px_48px] pt-20">
<div class="flex justify-center gap-24 items-center p-6">
<div class="max-w-xl flex flex-col gap-6 ">
<div class="flex items-center gap-2">
<Badge class="text-primary bg-primary/10 hover:bg-primary/10">
{t('home.open-source')}
</Badge>
<Badge class="text-primary bg-primary/10 hover:bg-primary/10">
{t('home.free')}
</Badge>
<Badge class="text-primary bg-primary/10 hover:bg-primary/10">
{t('home.self-hostable')}
</Badge>
</div>
<h1 class="text-5xl font-semibold border-b border-transparent hover:no-underline h-auto py-0 px-1 ml--1 rounded-none !transition-border-color-250">
<span class="font-bold ">IT</span>
<span class="text-90% text-primary font-extrabold border border-5px leading-none border-current rounded-xl px-2 py-0.5 ml-3">TOOLS</span>
</h1>
<p class="text-xl text-muted-foreground">
{t('app.description')}
</p>
<div>
<Button variant="default" as={A} href="tools">
{t('home.all-tools')}
<div class="i-tabler-arrow-right ml-2 text-base"></div>
</Button>
</div>
</div>
<div class="relative hidden md:block">
<div class="absolute top-4 left-0 w-full h-full flex items-center justify-center blur-2xl rounded-full opacity-20 bg-gradient-to-br from-primary to-transparent" />
<div class="i-tabler-terminal text-9xl text-primary m-8" />
</div>
</div>
<div class="bg-gradient-to-t dark:from-background to-transparent h-24 mt-24"></div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 max-w-1200px mx-auto p-6">
{tools.map(tool => (
<A href={tool.slug}>
<Card class="hover:(shadow-md transform scale-101) transition-transform">
<CardHeader>
<div class={cn(tool.icon, 'size-12 text-muted-foreground/60')} />
<CardTitle class="text-base font-semibold">
{tool.name}
</CardTitle>
<CardDescription>
{tool.description}
</CardDescription>
</CardHeader>
</Card>
</A>
))}
</div>
</div>
);
};

View file

@ -0,0 +1,8 @@
{
"uppercase": "Uppercase letters (A-Z)",
"lowercase": "Lowercase letters (a-z)",
"numbers": "Numbers (0-9)",
"symbols": "Special characters (!@#...)",
"length": "Length",
"result-placeholder": "Your token will appear here"
}

View file

@ -0,0 +1,8 @@
{
"uppercase": "Lettres majuscules (A-Z)",
"lowercase": "Lettres minuscules (a-z)",
"numbers": "Chiffres (0-9)",
"symbols": "Caractères spéciaux (!@#...)",
"length": "Longueur",
"result-placeholder": "Le token apparaîtra ici"
}

View file

@ -0,0 +1,28 @@
import { sample as sampleImpl, times } from 'lodash-es';
export function createToken({
withUppercase = true,
withLowercase = true,
withNumbers = true,
withSymbols = false,
length = 64,
alphabet,
sample = sampleImpl,
}: {
withUppercase?: boolean;
withLowercase?: boolean;
withNumbers?: boolean;
withSymbols?: boolean;
length?: number;
alphabet?: string;
sample?: (str: string) => string;
}) {
const allAlphabet = alphabet ?? [
withUppercase ? 'ABCDEFGHIJKLMOPQRSTUVWXYZ' : '',
withLowercase ? 'abcdefghijklmopqrstuvwxyz' : '',
withNumbers ? '0123456789' : '',
withSymbols ? '.,;:!?./-"\'#{([-|\\@)]=}*+' : '',
].join('');
return times(length, () => sample(allAlphabet)).join('');
}

View file

@ -0,0 +1,71 @@
import { Switch, SwitchControl, SwitchLabel, SwitchThumb } from '@/modules/ui/components/switch';
import { TextArea } from '@/modules/ui/components/textarea';
import { TextFieldRoot } from '@/modules/ui/components/textfield';
import { type Component, createSignal } from 'solid-js';
import { useCurrentTool } from '../../tools.provider';
import defaultDictionary from './locales/en.json';
import { createToken } from './token-generator.models';
const TokenGeneratorTool: Component = () => {
const [getUseUpperCase, setUseUpperCase] = createSignal(true);
const [getUseLowerCase, setUseLowerCase] = createSignal(true);
const [getUseNumbers, setUseNumbers] = createSignal(true);
const [getUseSpecialCharacters, setUseSpecialCharacters] = createSignal(false);
const [getLength] = createSignal(64);
const { t } = useCurrentTool({ defaultDictionary });
const getToken = () => createToken({
withUppercase: getUseUpperCase(),
withLowercase: getUseLowerCase(),
withNumbers: getUseNumbers(),
withSymbols: getUseSpecialCharacters(),
length: getLength(),
});
return (
<div class="space-y-2 mx-auto max-w-1200px p-6">
<Switch class="flex items-center space-x-2" checked={getUseUpperCase()} onChange={setUseUpperCase}>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
<SwitchLabel class="text-sm font-medium leading-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70">
{t('uppercase')}
</SwitchLabel>
</Switch>
<Switch class="flex items-center space-x-2" checked={getUseLowerCase()} onChange={setUseLowerCase}>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
<SwitchLabel class="text-sm font-medium leading-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70">
{t('lowercase')}
</SwitchLabel>
</Switch>
<Switch class="flex items-center space-x-2" checked={getUseNumbers()} onChange={setUseNumbers}>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
<SwitchLabel class="text-sm font-medium leading-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70">
{t('numbers')}
</SwitchLabel>
</Switch>
<Switch class="flex items-center space-x-2" checked={getUseSpecialCharacters()} onChange={setUseSpecialCharacters}>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
<SwitchLabel class="text-sm font-medium leading-none data-[disabled]:cursor-not-allowed data-[disabled]:opacity-70">
{t('symbols')}
</SwitchLabel>
</Switch>
<TextFieldRoot>
<TextArea placeholder="Your token will appear here" value={getToken()} readonly />
</TextFieldRoot>
</div>
);
};
export default TokenGeneratorTool;

View file

@ -0,0 +1,9 @@
import { defineTool } from '../../tools.models';
export const tokenGeneratorTool = defineTool({
slug: 'token-generator',
entryFile: () => import('./token-generator.page'),
icon: 'i-tabler-key',
createdAt: new Date('2024-02-13'),
currentDirUrl: import.meta.url,
});

View file

@ -0,0 +1,35 @@
import type { LocaleKey } from '@/modules/i18n/i18n.types';
import type { Flatten } from '@solid-primitives/i18n';
import type { ToolI18nFactory } from '../tools.types';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { safely } from '@corentinth/chisels';
import { flatten, translator } from '@solid-primitives/i18n';
import { useParams } from '@solidjs/router';
import { merge } from 'lodash-es';
import { type Component, createContext, createResource, lazy, Show } from 'solid-js';
import { CurrentToolProvider } from '../tools.provider';
import { getToolDefinitionBySlug } from '../tools.registry';
export const ToolPage: Component = () => {
const params = useParams();
const { getLocale } = useI18n();
const toolDefinition = getToolDefinitionBySlug({ slug: params.toolSlug });
const ToolComponent = lazy(toolDefinition.entryFile);
const [toolDict] = createResource(getLocale, async (locale) => {
const [dict = { default: {} }] = await safely(import(`../definitions/${toolDefinition.dirName}/locales/${locale}.json`));
return dict;
});
return (
<Show when={toolDict()}>
{toolLocaleDict => (
<CurrentToolProvider toolLocaleDict={toolLocaleDict}>
<ToolComponent />
</CurrentToolProvider>
)}
</Show>
);
};

View file

@ -0,0 +1,17 @@
import type { Component } from 'solid-js';
export { defineTool };
function defineTool(toolDefinition: {
slug: string;
entryFile: () => Promise<{ default: Component }>;
currentDirUrl: string;
icon: string;
createdAt: Date;
}) {
return {
...toolDefinition,
key: toolDefinition.slug,
dirName: toolDefinition.currentDirUrl.split('/').slice(-2)[0],
};
}

View file

@ -0,0 +1,31 @@
import type { Accessor, ParentComponent } from 'solid-js';
import type { ToolI18nFactory } from './tools.types';
import { flatten, type Flatten, translator, type Translator } from '@solid-primitives/i18n';
import { merge } from 'lodash-es';
import { createContext, useContext } from 'solid-js';
type ToolProviderContext = {
toolLocaleDict: Accessor<Record<string, string>>;
};
const CurrentToolContext = createContext<ToolProviderContext>();
export function useCurrentTool<T>({ defaultDictionary }: { defaultDictionary: T }) {
const context = useContext(CurrentToolContext);
if (!context) {
throw new Error('useCurrentTool must be used within a CurrentToolProvider');
}
return {
t: translator(() => flatten(merge({}, defaultDictionary, context.toolLocaleDict()))),
};
}
export const CurrentToolProvider: ParentComponent<ToolProviderContext> = (props) => {
return (
<CurrentToolContext.Provider value={props}>
{props.children}
</CurrentToolContext.Provider>
);
};

View file

@ -0,0 +1,15 @@
import { keyBy, map } from 'lodash-es';
import { tokenGeneratorTool } from './definitions/token-generator/token-generator.tool';
export const toolDefinitions = [
tokenGeneratorTool,
];
export const toolSlugs = map(toolDefinitions, 'slug');
export const toolDefinitionBySlug = keyBy(toolDefinitions, 'slug');
export { getToolDefinitionBySlug };
function getToolDefinitionBySlug({ slug }: { slug: string }) {
return toolDefinitionBySlug[slug];
}

View file

@ -0,0 +1,18 @@
import { useI18n } from '../i18n/i18n.provider';
import { toolDefinitions } from './tools.registry';
export { useToolsStore };
function useToolsStore() {
const { t } = useI18n();
const tools = toolDefinitions.map((tool) => {
return {
...tool,
name: t(`tools.${tool.slug}.name` as any) ?? tool.slug,
description: t(`tools.${tool.slug}.description` as any) ?? tool.slug,
};
});
return { tools };
}

View file

@ -0,0 +1,3 @@
import type { Flatten, Translator } from '@solid-primitives/i18n';
export type ToolI18nFactory = <T extends Record<string, string>>(args: { defaultDictionary: T }) => { t: Translator<Flatten<T>> };

View file

@ -0,0 +1,42 @@
import { cn } from "@/modules/ui/utils/cn";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import { type ComponentProps, splitProps } from "solid-js";
export const badgeVariants = cva(
"inline-flex items-center rounded-md px-2.5 py-0.5 text-xs font-semibold transition-shadow focus-visible:(outline-none ring-1.5 ring-ring)",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "border text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export const Badge = (
props: ComponentProps<"div"> & VariantProps<typeof badgeVariants>,
) => {
const [local, rest] = splitProps(props, ["class", "variant"]);
return (
<div
class={cn(
badgeVariants({
variant: local.variant,
}),
local.class,
)}
{...rest}
/>
);
};

View file

@ -0,0 +1,60 @@
import type { ButtonRootProps } from '@kobalte/core/button';
import type { PolymorphicProps } from '@kobalte/core/polymorphic';
import type { VariantProps } from 'class-variance-authority';
import type { ValidComponent } from 'solid-js';
import { cn } from '@/modules/ui/utils/cn';
import { Button as ButtonPrimitive } from '@kobalte/core/button';
import { cva } from 'class-variance-authority';
import { splitProps } from 'solid-js';
export const buttonVariants = cva(
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-shadow focus-visible:(outline-none ring-1.5 ring-ring) disabled:(pointer-events-none opacity-50) bg-inherit',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
ghost: 'hover:(bg-accent text-accent-foreground)',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 px-3 text-xs',
lg: 'h-10 px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
type buttonProps<T extends ValidComponent = 'button'> = ButtonRootProps<T> &
VariantProps<typeof buttonVariants> & {
class?: string;
};
export function Button<T extends ValidComponent = 'button'>(props: PolymorphicProps<T, buttonProps<T>>) {
const [local, rest] = splitProps(props as buttonProps, [
'class',
'variant',
'size',
]);
return (
<ButtonPrimitive
class={cn(
buttonVariants({
size: local.size,
variant: local.variant,
}),
local.class,
)}
{...rest}
/>
);
}

View file

@ -0,0 +1,60 @@
import { cn } from "@/modules/ui/utils/cn";
import type { ComponentProps, ParentComponent } from "solid-js";
import { splitProps } from "solid-js";
export const Card = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div
class={cn(
"rounded-xl border bg-card text-card-foreground shadow",
local.class,
)}
{...rest}
/>
);
};
export const CardHeader = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div class={cn("flex flex-col space-y-1.5 p-6", local.class)} {...rest} />
);
};
export const CardTitle: ParentComponent<ComponentProps<"h1">> = (props) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<h1
class={cn("font-semibold leading-none tracking-tight", local.class)}
{...rest}
/>
);
};
export const CardDescription: ParentComponent<ComponentProps<"h3">> = (
props,
) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<h3 class={cn("text-sm text-muted-foreground", local.class)} {...rest} />
);
};
export const CardContent = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return <div class={cn("p-6 pt-0", local.class)} {...rest} />;
};
export const CardFooter = (props: ComponentProps<"div">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<div class={cn("flex items-center p-6 pt-0", local.class)} {...rest} />
);
};

View file

@ -0,0 +1,314 @@
import { cn } from "@/modules/ui/utils/cn";
import type {
DropdownMenuCheckboxItemProps,
DropdownMenuContentProps,
DropdownMenuGroupLabelProps,
DropdownMenuItemLabelProps,
DropdownMenuItemProps,
DropdownMenuRadioItemProps,
DropdownMenuRootProps,
DropdownMenuSeparatorProps,
DropdownMenuSubTriggerProps,
} from "@kobalte/core/dropdown-menu";
import { DropdownMenu as DropdownMenuPrimitive } from "@kobalte/core/dropdown-menu";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { ComponentProps, ParentProps, ValidComponent } from "solid-js";
import { mergeProps, splitProps } from "solid-js";
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
export const DropdownMenu = (props: DropdownMenuRootProps) => {
const merge = mergeProps<DropdownMenuRootProps[]>({ gutter: 4 }, props);
return <DropdownMenuPrimitive {...merge} />;
};
type dropdownMenuContentProps<T extends ValidComponent = "div"> =
DropdownMenuContentProps<T> & {
class?: string;
};
export const DropdownMenuContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuContentProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuContentProps, [
"class",
]);
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
class={cn(
"min-w-8rem z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[expanded]:(animate-in fade-in-0 zoom-in-95) data-[closed]:(animate-out fade-out-0 zoom-out-95) focus-visible:(outline-none ring-1.5 ring-ring) transition-shadow",
local.class,
)}
{...rest}
/>
</DropdownMenuPrimitive.Portal>
);
};
type dropdownMenuItemProps<T extends ValidComponent = "div"> =
DropdownMenuItemProps<T> & {
class?: string;
inset?: boolean;
};
export const DropdownMenuItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuItemProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuItemProps, [
"class",
"inset",
]);
return (
<DropdownMenuPrimitive.Item
class={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:(bg-accent text-accent-foreground) data-[disabled]:(pointer-events-none opacity-50)",
local.inset && "pl-8",
local.class,
)}
{...rest}
/>
);
};
type dropdownMenuGroupLabelProps<T extends ValidComponent = "span"> =
DropdownMenuGroupLabelProps<T> & {
class?: string;
};
export const DropdownMenuGroupLabel = <T extends ValidComponent = "span">(
props: PolymorphicProps<T, dropdownMenuGroupLabelProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuGroupLabelProps, [
"class",
]);
return (
<DropdownMenuPrimitive.GroupLabel
as="div"
class={cn("px-2 py-1.5 text-sm font-semibold", local.class)}
{...rest}
/>
);
};
type dropdownMenuItemLabelProps<T extends ValidComponent = "div"> =
DropdownMenuItemLabelProps<T> & {
class?: string;
};
export const DropdownMenuItemLabel = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuItemLabelProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuItemLabelProps, [
"class",
]);
return (
<DropdownMenuPrimitive.ItemLabel
as="div"
class={cn("px-2 py-1.5 text-sm font-semibold", local.class)}
{...rest}
/>
);
};
type dropdownMenuSeparatorProps<T extends ValidComponent = "hr"> =
DropdownMenuSeparatorProps<T> & {
class?: string;
};
export const DropdownMenuSeparator = <T extends ValidComponent = "hr">(
props: PolymorphicProps<T, dropdownMenuSeparatorProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuSeparatorProps, [
"class",
]);
return (
<DropdownMenuPrimitive.Separator
class={cn("-mx-1 my-1 h-px bg-muted", local.class)}
{...rest}
/>
);
};
export const DropdownMenuShortcut = (props: ComponentProps<"span">) => {
const [local, rest] = splitProps(props, ["class"]);
return (
<span
class={cn("ml-auto text-xs tracking-widest opacity-60", local.class)}
{...rest}
/>
);
};
type dropdownMenuSubTriggerProps<T extends ValidComponent = "div"> =
ParentProps<
DropdownMenuSubTriggerProps<T> & {
class?: string;
}
>;
export const DropdownMenuSubTrigger = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuSubTriggerProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuSubTriggerProps, [
"class",
"children",
]);
return (
<DropdownMenuPrimitive.SubTrigger
class={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[expanded]:bg-accent",
local.class,
)}
{...rest}
>
{local.children}
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
class="ml-auto h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m9 6l6 6l-6 6"
/>
<title>Arrow</title>
</svg>
</DropdownMenuPrimitive.SubTrigger>
);
};
type dropdownMenuSubContentProps<T extends ValidComponent = "div"> =
DropdownMenuSubTriggerProps<T> & {
class?: string;
};
export const DropdownMenuSubContent = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuSubContentProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuSubContentProps, [
"class",
]);
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.SubContent
class={cn(
"min-w-8rem z-50 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[expanded]:(animate-in fade-in-0 zoom-in-95) data-[closed]:(animate-out fade-out-0 zoom-out-95)",
local.class,
)}
{...rest}
/>
</DropdownMenuPrimitive.Portal>
);
};
type dropdownMenuCheckboxItemProps<T extends ValidComponent = "div"> =
ParentProps<
DropdownMenuCheckboxItemProps<T> & {
class?: string;
}
>;
export const DropdownMenuCheckboxItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuCheckboxItemProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuCheckboxItemProps, [
"class",
"children",
]);
return (
<DropdownMenuPrimitive.CheckboxItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:(bg-accent text-accent-foreground) data-[disabled]:(pointer-events-none opacity-50)",
local.class,
)}
{...rest}
>
<DropdownMenuPrimitive.ItemIndicator class="absolute left-2 inline-flex h-4 w-4 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-4 w-4"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m5 12l5 5L20 7"
/>
<title>Checkbox</title>
</svg>
</DropdownMenuPrimitive.ItemIndicator>
{props.children}
</DropdownMenuPrimitive.CheckboxItem>
);
};
type dropdownMenuRadioItemProps<T extends ValidComponent = "div"> = ParentProps<
DropdownMenuRadioItemProps<T> & {
class?: string;
}
>;
export const DropdownMenuRadioItem = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, dropdownMenuRadioItemProps<T>>,
) => {
const [local, rest] = splitProps(props as dropdownMenuRadioItemProps, [
"class",
"children",
]);
return (
<DropdownMenuPrimitive.RadioItem
class={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:(bg-accent text-accent-foreground) data-[disabled]:(pointer-events-none opacity-50)",
local.class,
)}
{...rest}
>
<DropdownMenuPrimitive.ItemIndicator class="absolute left-2 inline-flex h-4 w-4 items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="h-2 w-2"
>
<g
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="M0 0h24v24H0z" />
<path
fill="currentColor"
d="M7 3.34a10 10 0 1 1-4.995 8.984L2 12l.005-.324A10 10 0 0 1 7 3.34"
/>
</g>
<title>Radio</title>
</svg>
</DropdownMenuPrimitive.ItemIndicator>
{props.children}
</DropdownMenuPrimitive.RadioItem>
);
};

View file

@ -0,0 +1,62 @@
import { cn } from "@/modules/ui/utils/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
SwitchControlProps,
SwitchThumbProps,
} from "@kobalte/core/switch";
import { Switch as SwitchPrimitive } from "@kobalte/core/switch";
import type { ParentProps, ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
export const SwitchLabel = SwitchPrimitive.Label;
export const Switch = SwitchPrimitive;
export const SwitchErrorMessage = SwitchPrimitive.ErrorMessage;
export const SwitchDescription = SwitchPrimitive.Description;
type switchControlProps<T extends ValidComponent = "input"> = ParentProps<
SwitchControlProps<T> & { class?: string }
>;
export const SwitchControl = <T extends ValidComponent = "input">(
props: PolymorphicProps<T, switchControlProps<T>>,
) => {
const [local, rest] = splitProps(props as switchControlProps, [
"class",
"children",
]);
return (
<>
<SwitchPrimitive.Input class="[&:focus-visible+div]:(outline-none ring-1.5 ring-ring ring-offset-2 ring-offset-background)" />
<SwitchPrimitive.Control
class={cn(
"inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input shadow-sm transition-shadow data-[disabled]:(cursor-not-allowed opacity-50) data-[checked]:bg-primary transition-property-[box-shadow,color,background-color]",
local.class,
)}
{...rest}
>
{local.children}
</SwitchPrimitive.Control>
</>
);
};
type switchThumbProps<T extends ValidComponent = "div"> = VoidProps<
SwitchThumbProps<T> & { class?: string }
>;
export const SwitchThumb = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, switchThumbProps<T>>,
) => {
const [local, rest] = splitProps(props as switchThumbProps, ["class"]);
return (
<SwitchPrimitive.Thumb
class={cn(
"pointer-events-none block h-4 w-4 translate-x-0 rounded-full bg-background shadow-lg transition-transform data-[checked]:translate-x-4",
local.class,
)}
{...rest}
/>
);
};

View file

@ -0,0 +1,28 @@
import { cn } from "@/modules/ui/utils/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type { TextFieldTextAreaProps } from "@kobalte/core/text-field";
import { TextArea as TextFieldPrimitive } from "@kobalte/core/text-field";
import type { ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
type textAreaProps<T extends ValidComponent = "textarea"> = VoidProps<
TextFieldTextAreaProps<T> & {
class?: string;
}
>;
export const TextArea = <T extends ValidComponent = "textarea">(
props: PolymorphicProps<T, textAreaProps<T>>,
) => {
const [local, rest] = splitProps(props as textAreaProps, ["class"]);
return (
<TextFieldPrimitive
class={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-inherit px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:(outline-none ring-1.5 ring-ring) disabled:(cursor-not-allowed opacity-50) transition-shadow",
local.class,
)}
{...rest}
/>
);
};

View file

@ -0,0 +1,126 @@
import { cn } from "@/modules/ui/utils/cn";
import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import type {
TextFieldDescriptionProps,
TextFieldErrorMessageProps,
TextFieldInputProps,
TextFieldLabelProps,
TextFieldRootProps,
} from "@kobalte/core/text-field";
import { TextField as TextFieldPrimitive } from "@kobalte/core/text-field";
import { cva } from "class-variance-authority";
import type { ValidComponent, VoidProps } from "solid-js";
import { splitProps } from "solid-js";
type textFieldProps<T extends ValidComponent = "div"> =
TextFieldRootProps<T> & {
class?: string;
};
export const TextFieldRoot = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, textFieldProps<T>>,
) => {
const [local, rest] = splitProps(props as textFieldProps, ["class"]);
return <TextFieldPrimitive class={cn("space-y-1", local.class)} {...rest} />;
};
export const textfieldLabel = cva(
"text-sm data-[disabled]:(cursor-not-allowed opacity-70) font-medium",
{
variants: {
label: {
true: "data-[invalid]:text-destructive",
},
error: {
true: "text-destructive text-xs",
},
description: {
true: "font-normal text-muted-foreground",
},
},
defaultVariants: {
label: true,
},
},
);
type textFieldLabelProps<T extends ValidComponent = "label"> =
TextFieldLabelProps<T> & {
class?: string;
};
export const TextFieldLabel = <T extends ValidComponent = "label">(
props: PolymorphicProps<T, textFieldLabelProps<T>>,
) => {
const [local, rest] = splitProps(props as textFieldLabelProps, ["class"]);
return (
<TextFieldPrimitive.Label
class={cn(textfieldLabel(), local.class)}
{...rest}
/>
);
};
type textFieldErrorMessageProps<T extends ValidComponent = "div"> =
TextFieldErrorMessageProps<T> & {
class?: string;
};
export const TextFieldErrorMessage = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, textFieldErrorMessageProps<T>>,
) => {
const [local, rest] = splitProps(props as textFieldErrorMessageProps, [
"class",
]);
return (
<TextFieldPrimitive.ErrorMessage
class={cn(textfieldLabel({ error: true }), local.class)}
{...rest}
/>
);
};
type textFieldDescriptionProps<T extends ValidComponent = "div"> =
TextFieldDescriptionProps<T> & {
class?: string;
};
export const TextFieldDescription = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, textFieldDescriptionProps<T>>,
) => {
const [local, rest] = splitProps(props as textFieldDescriptionProps, [
"class",
]);
return (
<TextFieldPrimitive.Description
class={cn(textfieldLabel({ description: true }), local.class)}
{...rest}
/>
);
};
type textFieldInputProps<T extends ValidComponent = "input"> = VoidProps<
TextFieldInputProps<T> & {
class?: string;
}
>;
export const TextField = <T extends ValidComponent = "input">(
props: PolymorphicProps<T, textFieldInputProps<T>>,
) => {
const [local, rest] = splitProps(props as textFieldInputProps, ["class"]);
return (
<TextFieldPrimitive.Input
class={cn(
"flex h-9 w-full rounded-md border border-input bg-inherit px-3 py-1 text-sm shadow-sm file:(border-0 bg-transparent text-sm font-medium) placeholder:text-muted-foreground focus-visible:(outline-none ring-1.5 ring-ring) disabled:(cursor-not-allowed opacity-50) transition-shadow",
local.class,
)}
{...rest}
/>
);
};

View file

@ -0,0 +1,168 @@
import type { LocaleKey } from '@/modules/i18n/i18n.types';
import type { Component, ParentComponent } from 'solid-js';
import { useI18n } from '@/modules/i18n/i18n.provider';
import { Button } from '@/modules/ui/components/button';
import { A, useLocation, useNavigate } from '@solidjs/router';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../components/dropdown-menu';
import { useThemeStore } from '../themes/theme.store';
import { cn } from '../utils/cn';
const ThemeSwitcher: Component = () => {
const themeStore = useThemeStore();
const { t } = useI18n();
return (
<>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'light' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-sun text-lg"></div>
{t('navbar.theme.light-mode')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'dark' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-moon text-lg"></div>
{t('navbar.theme.dark-mode')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => themeStore.setColorMode({ mode: 'system' })} class="flex items-center gap-2 cursor-pointer">
<div class="i-tabler-device-laptop text-lg"></div>
{t('navbar.theme.system-mode')}
</DropdownMenuItem>
</>
);
};
const LanguageSwitcher: Component = () => {
const { t, getLocale, setLocale, locales } = useI18n();
const navigate = useNavigate();
const location = useLocation();
const changeLocale = (locale: LocaleKey) => {
setLocale(locale);
const pathWithoutLocale = location.pathname.split('/').slice(2).join('/');
const newPath = [locale, pathWithoutLocale].filter(Boolean).join('/');
navigate(`/${newPath}`);
};
return (
<>
{locales.map(locale => (
<DropdownMenuItem onClick={() => changeLocale(locale.key)} class={cn('flex items-center gap-2 cursor-pointer', { 'font-semibold': getLocale() === locale.key })}>
{locale.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem as="a" class="flex items-center gap-2 cursor-pointer" target="_blank" rel="noopener noreferrer" href="https://github.com/CorentinTh/it-tools">
{t('navbar.contribute-to-i18n')}
</DropdownMenuItem>
</>
);
};
export const Navbar: Component = () => {
const themeStore = useThemeStore();
const { t } = useI18n();
return (
<div class="border-b border-border bg-surface">
<div class="flex items-center justify-between px-6 py-3 mx-auto max-w-1200px">
<div class="flex items-baseline gap-4">
<Button variant="link" class="text-xl font-semibold border-b border-transparent hover:no-underline h-auto py-0 px-1 ml--1 rounded-none !transition-border-color-250" as={A} href="/" aria-label="Home">
<span class="font-bold text-foreground">IT</span>
<span class="text-80% font-extrabold border border-2px leading-none border-current rounded-md px-1 py-0.5 ml-1 text-lime">TOOLS</span>
</Button>
</div>
<div>
<Button variant="ghost" class="text-lg px-0 size-9 hidden md:inline-flex" as={A} href="https://github.com/CorentinTh/enclosed" target="_blank" rel="noopener noreferrer" aria-label="GitHub repository">
<div class="i-tabler-brand-github"></div>
</Button>
<DropdownMenu>
<DropdownMenuTrigger as={Button} class="text-lg px-0 size-9 hidden md:inline-flex" variant="ghost" aria-label="Change theme">
<div classList={{ 'i-tabler-moon': themeStore.getColorMode() === 'dark', 'i-tabler-sun': themeStore.getColorMode() === 'light' }}></div>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-42">
<ThemeSwitcher />
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as={Button} class="text-lg px-0 size-9 hidden md:inline-flex" variant="ghost" aria-label="Language">
<div class="i-custom-language size-4"></div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<LanguageSwitcher />
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger as={Button} class="text-lg px-0 size-9" variant="ghost" aria-label="Menu icon">
<div class="i-tabler-dots-vertical hidden md:block"></div>
<div class="i-tabler-menu-2 block md:hidden"></div>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-46">
{/* Mobile only items */}
<DropdownMenuItem as="a" class="flex items-center gap-2 cursor-pointer md:hidden" target="_blank" href="https://github.com/CorentinTh/enclosed" rel="noopener noreferrer">
<div class="i-tabler-brand-github text-lg"></div>
{t('navbar.github')}
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger as="a" class="flex items-center gap-2 md:hidden" aria-label="Change theme">
<div class="text-lg" classList={{ 'i-tabler-moon': themeStore.getColorMode() === 'dark', 'i-tabler-sun': themeStore.getColorMode() === 'light' }}></div>
{t('navbar.theme.theme')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<ThemeSwitcher />
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger as="a" class="flex items-center text-medium gap-2 md:hidden" aria-label="Change language">
<div class="i-custom-language size-4"></div>
{t('navbar.language')}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<LanguageSwitcher />
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Default items */}
<DropdownMenuItem as="a" class="flex items-center gap-2 cursor-pointer" target="_blank" href="https://github.com/CorentinTh/it-tools/issues/new/choose" rel="noopener noreferrer">
<div class="i-tabler-bug text-lg"></div>
{t('navbar.report-bug')}
</DropdownMenuItem>
<DropdownMenuItem as="a" class="flex items-center gap-2 cursor-pointer" target="_blank" href="https://buymeacoffee.com/cthmsst" rel="noopener noreferrer">
<div class="i-tabler-pig-money text-lg"></div>
{t('navbar.support')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
};
export const AppLayout: ParentComponent = (props) => {
return (
<div class="flex flex-col h-screen min-h-0">
<Navbar />
<div class="flex-1 pb-20 ">{props.children}</div>
</div>
);
};

View file

@ -0,0 +1,11 @@
import type { ConfigColorMode } from '@kobalte/core/color-mode';
import { useColorMode } from '@kobalte/core/color-mode';
export function useThemeStore() {
const { setColorMode, colorMode: getColorMode } = useColorMode();
return {
setColorMode: ({ mode }: { mode: ConfigColorMode }) => setColorMode(mode),
getColorMode,
};
}

View file

@ -0,0 +1,5 @@
import type { ClassValue } from 'clsx';
import clsx from 'clsx';
import { twMerge } from 'tailwind-merge';
export const cn = (...classLists: ClassValue[]) => twMerge(clsx(classLists));