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,17 @@
{
"$schema": "https://shadcn-solid.com/schema.json",
"uno": {
"config": "uno.config.ts",
"css": {
"path": "src/client/app.css",
"variable": true
},
"color": "neutral",
"prefix": ""
},
"alias": {
"component": "@/modules/ui/components",
"ui": "@/modules/ui/components",
"cn": "@/modules/ui/utils/cn"
}
}

View file

@ -0,0 +1,21 @@
import antfu from '@antfu/eslint-config';
export default antfu({
stylistic: {
semi: true,
},
rules: {
// To allow export on top of files
'ts/no-use-before-define': ['error', { allowNamedExports: true, functions: false }],
'curly': ['error', 'all'],
'vitest/consistent-test-it': ['error', { fn: 'test' }],
'ts/consistent-type-definitions': ['error', 'type'],
'style/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'unused-imports/no-unused-vars': ['error', {
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
}],
},
});

21
packages/app/index.html Normal file
View file

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>IT Tools - Handy online tools for developers</title>
<meta name="title" content="IT Tools - Handy online tools for developers" />
<meta name="description" content="Collection of handy online tools for developers, with great UX. IT Tools is a free and open-source collection of handy online tools for developers & people working in IT." />
<link rel="author" href="humans.txt" />
<link rel="canonical" href="https://enclosed.cc/" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/client.tsx" type="module"></script>
</body>
</html>

1174
packages/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

49
packages/app/package.json Normal file
View file

@ -0,0 +1,49 @@
{
"name": "@it-tools/app",
"type": "module",
"version": "0.0.0",
"packageManager": "pnpm@9.11.0",
"description": "Collection of handy online tools for developers, with great UX.",
"author": "Corentin Thomasset <corentinth@proton.me> (https://corentin.tech)",
"repository": {
"type": "git",
"url": "https://github.com/CorentinTh/it-tools"
},
"keywords": [],
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"typecheck": "tsc --noEmit",
"test": "pnpm run test:unit",
"test:unit": "vitest run",
"test:unit:watch": "vitest watch"
},
"dependencies": {
"@corentinth/chisels": "^1.0.4",
"@kobalte/core": "^0.13.6",
"@solid-primitives/i18n": "^2.1.1",
"@solid-primitives/storage": "^4.2.1",
"@solidjs/router": "^0.14.7",
"@unocss/reset": "^0.62.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lodash-es": "^4.17.21",
"solid-js": "^1.9.1",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@antfu/eslint-config": "^3.7.3",
"@iconify-json/tabler": "^1.2.3",
"@types/lodash-es": "^4.17.12",
"eslint": "^9.11.1",
"typescript": "^5.6.2",
"unocss": "^0.62.4",
"unocss-preset-animations": "^1.1.0",
"vite": "^5.4.8",
"vite-plugin-solid": "^2.10.2",
"vitest": "^2.1.1"
}
}

View file

@ -0,0 +1,4 @@
/* TEAM */
Developer: Corentin Thomasset
Site: https://corentin.tech
Twitter: @cthmsst

75
packages/app/src/app.css Normal file
View file

@ -0,0 +1,75 @@
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
[data-kb-theme="dark"] {
--background: 0 0% 9%;
--foreground: 0 0% 98%;
--card: 0 0% 7%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 83 79% 55%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--warning: 31 98% 50%;
--warning-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}

View file

@ -0,0 +1,70 @@
import type { LocaleKey } from './modules/i18n/i18n.types';
import { A, Navigate, type RouteDefinition, useParams } from '@solidjs/router';
import { map } from 'lodash-es';
import { localeKeys, locales } from './modules/i18n/i18n.constants';
import { getBrowserLocale, useI18n } from './modules/i18n/i18n.provider';
import { HomePage } from './modules/pages/home.page';
import { ToolPage } from './modules/tools/pages/tool.page';
import { toolSlugs } from './modules/tools/tools.registry';
import { Button } from './modules/ui/components/button';
import { AppLayout } from './modules/ui/layouts/app.layout';
export const routes: RouteDefinition[] = [
{
path: '/',
component: () => {
const { getLocale } = useI18n();
return <Navigate href={`/${getLocale()}`} />;
},
},
{
path: '/',
component: AppLayout,
children: [
{
path: '/:localeKey',
matchFilters: {
localeKey: localeKeys,
},
component: (props) => {
const params = useParams();
const { setLocale } = useI18n();
setLocale(params.localeKey as LocaleKey);
return props.children;
},
children: [
{
path: '/',
component: HomePage,
},
{
path: '/:toolSlug',
matchFilters: {
toolSlug: toolSlugs,
},
component: ToolPage,
},
],
},
{
path: '*404',
component: () => (
<div class="flex flex-col items-center justify-center mt-6">
<div class="text-3xl font-light text-muted-foreground">404</div>
<h1 class="font-semibold text-lg my-2">Page Not Found</h1>
<p class="text-muted-foreground">The page you are looking for does not exist.</p>
<p class="text-muted-foreground">Please check the URL and try again.</p>
<Button as={A} href="/" class="mt-4" variant="secondary">
<div class="i-tabler-arrow-left mr-2"></div>
Go back home
</Button>
</div>
),
},
],
},
];

View file

@ -0,0 +1,38 @@
/* @refresh reload */
import { ColorModeProvider, ColorModeScript, createLocalStorageManager } from '@kobalte/core/color-mode';
import { Router } from '@solidjs/router';
import { render, Suspense } from 'solid-js/web';
import { routes } from './client-routes';
import { RootI18nProvider } from './modules/i18n/i18n.provider';
import '@unocss/reset/tailwind.css';
import 'virtual:uno.css';
import './app.css';
render(
() => {
const initialColorMode = 'system';
const colorModeStorageKey = 'it_tools_color_mode';
const localStorageManager = createLocalStorageManager(colorModeStorageKey);
return (
<Router
children={routes}
root={props => (
<Suspense>
<RootI18nProvider>
<ColorModeScript storageType={localStorageManager.type} storageKey={colorModeStorageKey} initialColorMode={initialColorMode} />
<ColorModeProvider
initialColorMode={initialColorMode}
storageManager={localStorageManager}
>
<div class="min-h-screen font-sans text-sm font-400">{props.children}</div>
</ColorModeProvider>
</RootI18nProvider>
</Suspense>
)}
/>
);
},
document.getElementById('root')!,
);

View file

@ -0,0 +1,31 @@
{
"app": {
"title": "IT-Tools",
"description": "The open-source collection of handy online tools to help developers in their daily life."
},
"navbar": {
"theme": {
"theme": "Theme",
"light-mode": "Light mode",
"dark-mode": "Dark mode",
"system-mode": "System"
},
"language": "Language",
"contribute-to-i18n": "Contribute to i18n",
"github": "GitHub",
"support": "Support IT-Tools",
"report-bug": "Report a bug"
},
"home": {
"all-tools": "All the tools",
"open-source": "Open Source",
"free": "Free",
"self-hostable": "Self-hostable"
},
"tools": {
"token-generator": {
"name": "Token Generator",
"description": "Generate random string with the characters you want, uppercase, lowercase letters, numbers and/or symbols."
}
}
}

View file

@ -0,0 +1,6 @@
{
"app": {
"title": "IT-Tools",
"description": "La collection open-source d'outils en ligne pour aider les devs dans leur vie quotidienne."
}
}

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

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"baseUrl": "./",
"module": "ESNext",
"moduleResolution": "node",
"paths": {
"@/*": ["./src/*"]
},
"resolveJsonModule": true,
"types": ["vite/client"],
"strict": true,
"noEmit": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src"]
}

115
packages/app/uno.config.ts Normal file
View file

@ -0,0 +1,115 @@
import {
defineConfig,
presetIcons,
presetUno,
presetWebFonts,
transformerDirectives,
transformerVariantGroup,
} from 'unocss';
import presetAnimations from 'unocss-preset-animations';
import { toolDefinitions } from './src/modules/tools/tools.registry';
export default defineConfig({
presets: [
presetUno({
dark: {
dark: '[data-kb-theme="dark"]',
light: '[data-kb-theme="light"]',
},
prefix: '',
}),
presetAnimations(),
presetWebFonts({
fonts: {
sans: 'Inter:400,500,600,700,800,900',
},
}),
presetIcons({
collections: {
custom: {
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>',
},
},
}),
],
transformers: [transformerVariantGroup(), transformerDirectives()],
theme: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
animation: {
keyframes: {
'accordion-down':
'{ from { height: 0 } to { height: var(--kb-accordion-content-height) } }',
'accordion-up':
'{ from { height: var(--kb-accordion-content-height) } to { height: 0 } }',
'collapsible-down':
'{ from { height: 0 } to { height: var(--kb-collapsible-content-height) } }',
'collapsible-up':
'{ from { height: var(--kb-collapsible-content-height) } to { height: 0 } }',
'caret-blink': '{ 0%,70%,100% { opacity: 1 } 20%,50% { opacity: 0 } }',
},
timingFns: {
'accordion-down': 'ease-out',
'accordion-up': 'ease-out',
'collapsible-down': 'ease-out',
'collapsible-up': 'ease-out',
'caret-blink': 'ease-out',
},
durations: {
'accordion-down': '0.2s',
'accordion-up': '0.2s',
'collapsible-down': '0.2s',
'collapsible-up': '0.2s',
'caret-blink': '1.25s',
},
counts: {
'caret-blink': 'infinite',
},
},
},
safelist: [
...toolDefinitions.map(tool => tool.icon),
],
});

View file

@ -0,0 +1,33 @@
import path from 'node:path';
import unoCssPlugin from 'unocss/vite';
import { defineConfig } from 'vite';
import solidPlugin from 'vite-plugin-solid';
import { configDefaults } from 'vitest/config';
export default defineConfig({
plugins: [
unoCssPlugin(),
solidPlugin(),
],
server: {
proxy: {
'/api': {
target: 'http://localhost:8787',
},
},
},
build: {
target: 'esnext',
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
exclude: [
...configDefaults.exclude,
'**/*.e2e.test.ts',
],
},
});