mirror of
https://github.com/ershisan99/DevToysWeb.git
synced 2025-12-16 12:32:48 +00:00
renewal
recreate project by using https://github.com/shadcn/next-template App: - support dark mode - add toggle theme button - add clear search button - add search button - add current page indicator - add tool group pages - add settings tool - add 1 tab format option to Json format tool - add paste button to some tools - add file button to some tools - add copy button to some tools - add clear button to some tools - change favicon - change search hit rate - change each page title - change icons from Material Icons to Lucide - change sidebar scroll area - change editor from Ace to Monaco - change parsable separators of number base converter - change default value of format option of number base converter - change default values of some tool forms - change some styles - remove disabled tools - remove real-time search - fix uri encoding tool Dev: - MUI + Emotion -> Radix UI + Tailwind CSS - Next.js 12 Pages -> Next.js 13 App Router - React 17 -> React 18 - many other packages upgraded - use useState instead of recoil - use Next.js typedRoutes instead of pathpida - clean npm scripts - format import statements by Prettier - no component separations between container and presenter - effective component memoizations - add vscode settings - many refactors
This commit is contained in:
43
.babelrc
43
.babelrc
@@ -1,43 +0,0 @@
|
|||||||
// https://emotion.sh/docs/css-prop##babel-preset
|
|
||||||
// https://mui.com/guides/minimizing-bundle-size/#option-2
|
|
||||||
// https://github.com/vercel/next.js/discussions/17822
|
|
||||||
{
|
|
||||||
"presets": [
|
|
||||||
[
|
|
||||||
"next/babel",
|
|
||||||
{
|
|
||||||
"preset-react": {
|
|
||||||
"runtime": "automatic",
|
|
||||||
"importSource": "@emotion/react"
|
|
||||||
},
|
|
||||||
"preset-env": {
|
|
||||||
"debug": false,
|
|
||||||
"targets": {
|
|
||||||
"browsers": ">1%, not ie 11, not op_mini all"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"plugins": [
|
|
||||||
[
|
|
||||||
"babel-plugin-import",
|
|
||||||
{
|
|
||||||
"libraryName": "@mui/material",
|
|
||||||
"libraryDirectory": "",
|
|
||||||
"camel2DashComponentName": false
|
|
||||||
},
|
|
||||||
"core"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"babel-plugin-import",
|
|
||||||
{
|
|
||||||
"libraryName": "@mui/icons-material",
|
|
||||||
"libraryDirectory": "",
|
|
||||||
"camel2DashComponentName": false
|
|
||||||
},
|
|
||||||
"icons"
|
|
||||||
],
|
|
||||||
["@emotion/babel-plugin"]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"ms-azuretools.vscode-docker",
|
"ms-azuretools.vscode-docker",
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
"artdiniz.quitcontrol-vscode",
|
"artdiniz.quitcontrol-vscode",
|
||||||
"styled-components.vscode-styled-components"
|
"bradlc.vscode-tailwindcss"
|
||||||
],
|
],
|
||||||
"runServices": ["app"],
|
"runServices": ["app"],
|
||||||
"shutdownAction": "stopCompose"
|
"shutdownAction": "stopCompose"
|
||||||
|
|||||||
10
.editorconfig
Normal file
10
.editorconfig
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
@@ -1 +0,0 @@
|
|||||||
.gitignore
|
|
||||||
5
.eslintignore
Normal file
5
.eslintignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dist/*
|
||||||
|
.cache
|
||||||
|
public
|
||||||
|
node_modules
|
||||||
|
*.esm.js
|
||||||
@@ -1,49 +1,49 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/eslintrc",
|
||||||
"root": true,
|
"root": true,
|
||||||
"parserOptions": {
|
|
||||||
"project": "./tsconfig.json"
|
|
||||||
},
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"extends": [
|
"extends": [
|
||||||
"airbnb",
|
"airbnb",
|
||||||
"airbnb-typescript",
|
"airbnb-typescript",
|
||||||
"airbnb/hooks",
|
"airbnb/hooks",
|
||||||
"plugin:@typescript-eslint/recommended",
|
"plugin:@typescript-eslint/recommended",
|
||||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||||
|
"plugin:tailwindcss/recommended",
|
||||||
"next/core-web-vitals",
|
"next/core-web-vitals",
|
||||||
"prettier"
|
"prettier"
|
||||||
],
|
],
|
||||||
|
"plugins": ["tailwindcss"],
|
||||||
"rules": {
|
"rules": {
|
||||||
"no-console": "off",
|
"@next/next/no-html-link-for-pages": "off",
|
||||||
"no-else-return": "off",
|
|
||||||
"no-underscore-dangle": ["error", { "allow": ["_slug"] }],
|
|
||||||
"import/prefer-default-export": "off",
|
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
"@typescript-eslint/no-unused-vars": [
|
||||||
"react/require-default-props": "off",
|
|
||||||
"react/function-component-definition": "off",
|
|
||||||
"jsx-a11y/anchor-is-valid": [
|
|
||||||
"error",
|
"error",
|
||||||
{
|
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
|
||||||
"components": ["Link"],
|
|
||||||
"specialLink": ["hrefLeft", "hrefRight"],
|
|
||||||
"aspects": ["invalidHref", "preferButton"]
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"import/order": [
|
"consistent-return": "off",
|
||||||
"error",
|
"no-else-return": "off",
|
||||||
{
|
"no-nested-ternary": "off",
|
||||||
"groups": ["builtin", "external", "internal", ["parent", "sibling"], "object", "index"],
|
"import/prefer-default-export": "off",
|
||||||
"newlines-between": "always",
|
"react/function-component-definition": "off",
|
||||||
"alphabetize": { "order": "asc", "caseInsensitive": true }
|
"react/jsx-key": "off",
|
||||||
}
|
"react/jsx-props-no-spreading": "off",
|
||||||
]
|
"react/require-default-props": "off",
|
||||||
|
"tailwindcss/no-custom-classname": "off"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"tailwindcss": {
|
||||||
|
"callees": ["cn"],
|
||||||
|
"config": "./tailwind.config.js"
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"rootDir": ["./"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"overrides": [
|
"overrides": [
|
||||||
{
|
{
|
||||||
"files": "src/pages/_app.tsx",
|
"files": ["*.ts", "*.tsx"],
|
||||||
"rules": {
|
"parser": "@typescript-eslint/parser",
|
||||||
"react/jsx-props-no-spreading": "off"
|
"parserOptions": {
|
||||||
|
"project": "./tsconfig.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
26
.gitignore
vendored
26
.gitignore
vendored
@@ -1,19 +1,17 @@
|
|||||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
/node_modules
|
node_modules
|
||||||
/.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
coverage
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
.next/
|
||||||
/out/
|
out/
|
||||||
|
build
|
||||||
# production
|
|
||||||
/build
|
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -31,14 +29,14 @@ yarn-error.log*
|
|||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
|
||||||
# vercel
|
# turbo
|
||||||
.vercel
|
.turbo
|
||||||
|
|
||||||
|
.contentlayer
|
||||||
|
.env
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
# eslint
|
# eslint
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
# env
|
|
||||||
.env
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
.gitignore
|
|
||||||
12
.prettierignore
Normal file
12
.prettierignore
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
cache
|
||||||
|
.cache
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
|
public
|
||||||
|
CHANGELOG.md
|
||||||
|
.yarn
|
||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
build
|
||||||
|
.contentlayer
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"printWidth": 100,
|
|
||||||
"arrowParens": "avoid"
|
|
||||||
}
|
|
||||||
11
.vscode/extensions.json
vendored
Normal file
11
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"bierner.github-markdown-preview",
|
||||||
|
"davidanson.vscode-markdownlint",
|
||||||
|
"irongeek.vscode-env",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"ms-azuretools.vscode-docker",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"bradlc.vscode-tailwindcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"files.associations": {
|
||||||
|
"*.css": "tailwindcss"
|
||||||
|
},
|
||||||
|
"tailwindCSS.experimental.classRegex": [["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"]],
|
||||||
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
"typescript.enablePromptUseWorkspaceTsdk": true
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
A web clone of [DevToys](https://github.com/veler/DevToys)
|
A web clone of [DevToys](https://github.com/veler/DevToys)
|
||||||
|
|
||||||
|
## Known issues
|
||||||
|
|
||||||
|
- [Editor may not resize to fit container size](https://github.com/suren-atoyan/monaco-react/issues/346)
|
||||||
|
- CSS outlines messed up
|
||||||
|
|
||||||
## Todo
|
## Todo
|
||||||
|
|
||||||
- [x] Add site layout
|
- [x] Add site layout
|
||||||
@@ -15,5 +20,5 @@ A web clone of [DevToys](https://github.com/veler/DevToys)
|
|||||||
- [ ] Graphic
|
- [ ] Graphic
|
||||||
- [ ] Settings
|
- [ ] Settings
|
||||||
- [x] Settings menu item
|
- [x] Settings menu item
|
||||||
- [ ] Support dark mode
|
- [x] Support dark mode
|
||||||
- [ ] Support i18n
|
- [ ] Support i18n
|
||||||
|
|||||||
BIN
app/apple-icon.png
Normal file
BIN
app/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
11
app/converters/json-yaml/layout.tsx
Normal file
11
app/converters/json-yaml/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.converters.tools.jsonYaml.longTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
174
app/converters/json-yaml/page.tsx
Normal file
174
app/converters/json-yaml/page.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import YAML from "yaml";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { Editor, EditorProps } from "@/components/ui/editor";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectProps,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { ClearButton } from "@/components/buttons/clear";
|
||||||
|
import { CopyButton } from "@/components/buttons/copy";
|
||||||
|
import { FileButton } from "@/components/buttons/file";
|
||||||
|
import { PasteButton } from "@/components/buttons/paste";
|
||||||
|
import { Configuration } from "@/components/configuration";
|
||||||
|
import { Configurations } from "@/components/configurations";
|
||||||
|
import { ControlMenu } from "@/components/control-menu";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { PageSection } from "@/components/page-section";
|
||||||
|
|
||||||
|
const two = " ";
|
||||||
|
const four = " ";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [indentation, setIndentation] = useState(two);
|
||||||
|
const [json, setJson] = useState('{\n "foo": "bar"\n}');
|
||||||
|
const [yaml, setYaml] = useState("foo: bar");
|
||||||
|
|
||||||
|
const setJsonReactively = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
setJson(text);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
setYaml(YAML.stringify(parsed, { indent: indentation.length, simpleKeys: true }));
|
||||||
|
} catch {
|
||||||
|
setYaml("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[indentation.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setYamlReactively = useCallback(
|
||||||
|
(text: string) => {
|
||||||
|
setYaml(text);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = YAML.parse(text, { merge: true }) as unknown;
|
||||||
|
setJson(JSON.stringify(parsed, null, indentation));
|
||||||
|
} catch {
|
||||||
|
setJson("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[indentation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearBoth = useCallback(() => {
|
||||||
|
setJson("");
|
||||||
|
setYaml("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onIndentationChange: SelectProps["onValueChange"] = value => {
|
||||||
|
setIndentation(value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json) as unknown;
|
||||||
|
setJson(JSON.stringify(parsed, null, value));
|
||||||
|
setYaml(YAML.stringify(parsed, { indent: value.length, simpleKeys: true }));
|
||||||
|
} catch {
|
||||||
|
clearBoth();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onJsonChange: EditorProps["onChange"] = value => setJsonReactively(value ?? "");
|
||||||
|
const onYamlChange: EditorProps["onChange"] = value => setYamlReactively(value ?? "");
|
||||||
|
|
||||||
|
const indentationIcon = useMemo(() => <icons.Space size={24} className="-translate-y-1.5" />, []);
|
||||||
|
|
||||||
|
const indentationConfig = (
|
||||||
|
<Configuration
|
||||||
|
icon={indentationIcon}
|
||||||
|
title="Indentation"
|
||||||
|
control={
|
||||||
|
<Select value={indentation} onValueChange={onIndentationChange}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-28"
|
||||||
|
aria-label="toggle open/close state of indentation selection"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={indentation} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={two}>2 spaces</SelectItem>
|
||||||
|
<SelectItem value={four}>4 spaces</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonPasteButton = useMemo(
|
||||||
|
() => <PasteButton onClipboardRead={setJsonReactively} />,
|
||||||
|
[setJsonReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const yamlPasteButton = useMemo(
|
||||||
|
() => <PasteButton onClipboardRead={setYamlReactively} />,
|
||||||
|
[setYamlReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonFileButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<FileButton
|
||||||
|
accept=".json"
|
||||||
|
onFileRead={setJsonReactively}
|
||||||
|
iconOnly
|
||||||
|
aria-label="load a json file"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[setJsonReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const yamlFileButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<FileButton
|
||||||
|
accept=".yml,.yaml"
|
||||||
|
onFileRead={setYamlReactively}
|
||||||
|
iconOnly
|
||||||
|
aria-label="load a yaml file"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[setYamlReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonCopyButton = <CopyButton text={json} />;
|
||||||
|
const yamlCopyButton = <CopyButton text={yaml} />;
|
||||||
|
|
||||||
|
const clearButton = useMemo(
|
||||||
|
() => <ClearButton onClick={clearBoth} iconOnly aria-label="clear json and yaml" />,
|
||||||
|
[clearBoth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonControl = (
|
||||||
|
<ControlMenu list={[jsonPasteButton, jsonFileButton, jsonCopyButton, clearButton]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const yamlControl = (
|
||||||
|
<ControlMenu list={[yamlPasteButton, yamlFileButton, yamlCopyButton, clearButton]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRootSection
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
title={toolGroups.converters.tools.jsonYaml.longTitle}
|
||||||
|
>
|
||||||
|
<PageSection className="mb-6 mt-0" title="Configuration">
|
||||||
|
<Configurations list={[indentationConfig]} />
|
||||||
|
</PageSection>
|
||||||
|
<div className="flex flex-1 flex-col gap-x-4 gap-y-5 lg:flex-row">
|
||||||
|
<PageSection className="mt-0 min-h-[200px] flex-1" title="Json" control={jsonControl}>
|
||||||
|
<Editor language="json" value={json} onChange={onJsonChange} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection className="mt-0 min-h-[200px] flex-1" title="Yaml" control={yamlControl}>
|
||||||
|
<Editor language="yaml" value={yaml} onChange={onYamlChange} />
|
||||||
|
</PageSection>
|
||||||
|
</div>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/converters/layout.tsx
Normal file
11
app/converters/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.converters.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
11
app/converters/number-base/layout.tsx
Normal file
11
app/converters/number-base/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.converters.tools.numberBase.longTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
111
app/converters/number-base/page.tsx
Normal file
111
app/converters/number-base/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import * as baselib from "@/lib/base";
|
||||||
|
import { Input, InputProps } from "@/components/ui/input";
|
||||||
|
import { PasteButton } from "@/components/buttons/paste";
|
||||||
|
import { Configuration } from "@/components/configuration";
|
||||||
|
import { Configurations } from "@/components/configurations";
|
||||||
|
import { ControlMenu } from "@/components/control-menu";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
import { LabeledSwitch } from "@/components/labeled-switch";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { PageSection } from "@/components/page-section";
|
||||||
|
|
||||||
|
const baseConfig = {
|
||||||
|
10: { prefix: "", validate: baselib.isDecimal },
|
||||||
|
16: { prefix: "0x", validate: baselib.isHexadecimal },
|
||||||
|
8: { prefix: "0o", validate: baselib.isOctal },
|
||||||
|
2: { prefix: "0b", validate: baselib.isBinary },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [format, setFormat] = useState(true);
|
||||||
|
const [int, setInt] = useState<bigint | undefined>(BigInt(42));
|
||||||
|
|
||||||
|
const newDec = int?.toString(10) ?? "";
|
||||||
|
const newHex = int?.toString(16).toUpperCase() ?? "";
|
||||||
|
const newOct = int?.toString(8) ?? "";
|
||||||
|
const newBin = int?.toString(2) ?? "";
|
||||||
|
|
||||||
|
const dec = format ? baselib.formatDecimal(newDec) : newDec;
|
||||||
|
const hex = format ? baselib.formatHexadecimal(newHex) : newHex;
|
||||||
|
const oct = format ? baselib.formatOctal(newOct) : newOct;
|
||||||
|
const bin = format ? baselib.formatBinary(newBin) : newBin;
|
||||||
|
|
||||||
|
const trySetInt = (base: 10 | 16 | 8 | 2) => (value: string) => {
|
||||||
|
if (value === "") {
|
||||||
|
return setInt(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { prefix, validate } = baseConfig[base];
|
||||||
|
const unformatted = baselib.unformatNumber(value);
|
||||||
|
|
||||||
|
if (validate(unformatted)) {
|
||||||
|
setInt(BigInt(`${prefix}${unformatted}`));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trySetDec = useCallback((value: string) => trySetInt(10)(value), []);
|
||||||
|
const trySetHex = useCallback((value: string) => trySetInt(16)(value), []);
|
||||||
|
const trySetOct = useCallback((value: string) => trySetInt(8)(value), []);
|
||||||
|
const trySetBin = useCallback((value: string) => trySetInt(2)(value), []);
|
||||||
|
|
||||||
|
const onDecChange: InputProps["onChange"] = ({ currentTarget: { value } }) => trySetDec(value);
|
||||||
|
const onHexChange: InputProps["onChange"] = ({ currentTarget: { value } }) => trySetHex(value);
|
||||||
|
const onOctChange: InputProps["onChange"] = ({ currentTarget: { value } }) => trySetOct(value);
|
||||||
|
const onBinChange: InputProps["onChange"] = ({ currentTarget: { value } }) => trySetBin(value);
|
||||||
|
|
||||||
|
const formatNumberIcon = useMemo(() => <icons.CaseSensitive size={24} />, []);
|
||||||
|
|
||||||
|
const formatNumberConfig = useMemo(
|
||||||
|
() => (
|
||||||
|
<Configuration
|
||||||
|
icon={formatNumberIcon}
|
||||||
|
title="Format number"
|
||||||
|
control={
|
||||||
|
<LabeledSwitch
|
||||||
|
id="format-number-switch"
|
||||||
|
label={format ? "On" : "Off"}
|
||||||
|
checked={format}
|
||||||
|
onCheckedChange={setFormat}
|
||||||
|
aria-label="toggle whether to format numbers"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[format, formatNumberIcon]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decPasteButton = useMemo(() => <PasteButton onClipboardRead={trySetDec} />, [trySetDec]);
|
||||||
|
const hexPasteButton = useMemo(() => <PasteButton onClipboardRead={trySetHex} />, [trySetHex]);
|
||||||
|
const octPasteButton = useMemo(() => <PasteButton onClipboardRead={trySetOct} />, [trySetOct]);
|
||||||
|
const binPasteButton = useMemo(() => <PasteButton onClipboardRead={trySetBin} />, [trySetBin]);
|
||||||
|
|
||||||
|
const decControl = <ControlMenu list={[decPasteButton]} />;
|
||||||
|
const hexControl = <ControlMenu list={[hexPasteButton]} />;
|
||||||
|
const octControl = <ControlMenu list={[octPasteButton]} />;
|
||||||
|
const binControl = <ControlMenu list={[binPasteButton]} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.converters.tools.numberBase.longTitle}>
|
||||||
|
<PageSection className="mb-6" title="Configuration">
|
||||||
|
<Configurations list={[formatNumberConfig]} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="Decimal" control={decControl}>
|
||||||
|
<Input value={dec} onChange={onDecChange} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="Hexadecimal" control={hexControl}>
|
||||||
|
<Input value={hex} onChange={onHexChange} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="Octal" control={octControl}>
|
||||||
|
<Input value={oct} onChange={onOctChange} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="Binary" control={binControl}>
|
||||||
|
<Input value={bin} onChange={onBinChange} />
|
||||||
|
</PageSection>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/converters/page.tsx
Normal file
11
app/converters/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { ToolCards } from "@/components/tool-cards";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.converters.title}>
|
||||||
|
<ToolCards tools={Object.values(toolGroups.converters.tools)} />
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/encoders-decoders/base64/layout.tsx
Normal file
11
app/encoders-decoders/base64/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.encodersDecoders.tools.base64.longTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
98
app/encoders-decoders/base64/page.tsx
Normal file
98
app/encoders-decoders/base64/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { decode, encode, isValid } from "js-base64";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { Textarea, TextareaProps } from "@/components/ui/textarea";
|
||||||
|
import { ClearButton } from "@/components/buttons/clear";
|
||||||
|
import { CopyButton } from "@/components/buttons/copy";
|
||||||
|
import { FileButton } from "@/components/buttons/file";
|
||||||
|
import { PasteButton } from "@/components/buttons/paste";
|
||||||
|
import { ControlMenu } from "@/components/control-menu";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { PageSection } from "@/components/page-section";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [decoded, setDecoded] = useState("😀😂🤣");
|
||||||
|
const [encoded, setEncoded] = useState("8J+YgPCfmILwn6Sj");
|
||||||
|
|
||||||
|
const setDecodedReactively = useCallback((text: string) => {
|
||||||
|
setDecoded(text);
|
||||||
|
setEncoded(encode(text));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setEncodedReactively = useCallback((text: string) => {
|
||||||
|
setEncoded(text);
|
||||||
|
|
||||||
|
const newDecoded = decode(text);
|
||||||
|
|
||||||
|
if (isValid(text) && !newDecoded.includes("<22>")) {
|
||||||
|
setDecoded(newDecoded);
|
||||||
|
} else {
|
||||||
|
setDecoded("");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearBoth = useCallback(() => {
|
||||||
|
setDecoded("");
|
||||||
|
setEncoded("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDecodedChange: TextareaProps["onChange"] = ({ currentTarget: { value } }) =>
|
||||||
|
setDecodedReactively(value);
|
||||||
|
|
||||||
|
const onEncodedChange: TextareaProps["onChange"] = ({ currentTarget: { value } }) =>
|
||||||
|
setEncodedReactively(value);
|
||||||
|
|
||||||
|
const decodedPasteButton = useMemo(
|
||||||
|
() => <PasteButton onClipboardRead={setDecodedReactively} />,
|
||||||
|
[setDecodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const encodedPasteButton = useMemo(
|
||||||
|
() => <PasteButton onClipboardRead={setEncodedReactively} />,
|
||||||
|
[setEncodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedFileButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<FileButton onFileRead={setDecodedReactively} iconOnly aria-label="load a decoded file" />
|
||||||
|
),
|
||||||
|
[setDecodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const encodedFileButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<FileButton onFileRead={setEncodedReactively} iconOnly aria-label="load a encoded file" />
|
||||||
|
),
|
||||||
|
[setEncodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedCopyButton = useMemo(() => <CopyButton text={decoded} />, [decoded]);
|
||||||
|
const encodedCopyButton = useMemo(() => <CopyButton text={encoded} />, [encoded]);
|
||||||
|
|
||||||
|
const clearButton = useMemo(
|
||||||
|
() => <ClearButton onClick={clearBoth} iconOnly aria-label="clear decoded and encoded" />,
|
||||||
|
[clearBoth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedControl = (
|
||||||
|
<ControlMenu list={[decodedPasteButton, decodedFileButton, decodedCopyButton, clearButton]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const encodedControl = (
|
||||||
|
<ControlMenu list={[encodedPasteButton, encodedFileButton, encodedCopyButton, clearButton]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.encodersDecoders.tools.base64.longTitle}>
|
||||||
|
<PageSection title="Decoded" control={decodedControl}>
|
||||||
|
<Textarea value={decoded} onChange={onDecodedChange} rows={10} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="Encoded" control={encodedControl}>
|
||||||
|
<Textarea value={encoded} onChange={onEncodedChange} rows={10} />
|
||||||
|
</PageSection>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/encoders-decoders/html/layout.tsx
Normal file
11
app/encoders-decoders/html/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.encodersDecoders.tools.html.longTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
91
app/encoders-decoders/html/page.tsx
Normal file
91
app/encoders-decoders/html/page.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { decode, encode } from "html-entities";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { Textarea, TextareaProps } from "@/components/ui/textarea";
|
||||||
|
import { ClearButton } from "@/components/buttons/clear";
|
||||||
|
import { CopyButton } from "@/components/buttons/copy";
|
||||||
|
import { FileButton } from "@/components/buttons/file";
|
||||||
|
import { PasteButton } from "@/components/buttons/paste";
|
||||||
|
import { ControlMenu } from "@/components/control-menu";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { PageSection } from "@/components/page-section";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [decoded, setDecoded] = useState('> It\'s "HTML escaping".');
|
||||||
|
const [encoded, setEncoded] = useState("> It's "HTML escaping".");
|
||||||
|
|
||||||
|
const setDecodedReactively = useCallback((text: string) => {
|
||||||
|
setDecoded(text);
|
||||||
|
setEncoded(encode(text));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setEncodedReactively = useCallback((text: string) => {
|
||||||
|
setEncoded(text);
|
||||||
|
setDecoded(decode(text));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearBoth = useCallback(() => {
|
||||||
|
setDecoded("");
|
||||||
|
setEncoded("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDecodedChange: TextareaProps["onChange"] = ({ currentTarget: { value } }) =>
|
||||||
|
setDecodedReactively(value);
|
||||||
|
|
||||||
|
const onEncodedChange: TextareaProps["onChange"] = ({ currentTarget: { value } }) =>
|
||||||
|
setEncodedReactively(value);
|
||||||
|
|
||||||
|
const decodedPasteButton = useMemo(
|
||||||
|
() => <PasteButton onClipboardRead={setDecodedReactively} />,
|
||||||
|
[setDecodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const encodedPasteButton = useMemo(
|
||||||
|
() => <PasteButton onClipboardRead={setEncodedReactively} />,
|
||||||
|
[setEncodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedFileButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<FileButton onFileRead={setDecodedReactively} iconOnly aria-label="load a decoded file" />
|
||||||
|
),
|
||||||
|
[setDecodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const encodedFileButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<FileButton onFileRead={setEncodedReactively} iconOnly aria-label="load a encoded file" />
|
||||||
|
),
|
||||||
|
[setEncodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedCopyButton = useMemo(() => <CopyButton text={decoded} />, [decoded]);
|
||||||
|
const encodedCopyButton = useMemo(() => <CopyButton text={encoded} />, [encoded]);
|
||||||
|
|
||||||
|
const clearButton = useMemo(
|
||||||
|
() => <ClearButton onClick={clearBoth} iconOnly aria-label="clear decoded and encoded" />,
|
||||||
|
[clearBoth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedControl = (
|
||||||
|
<ControlMenu list={[decodedPasteButton, decodedFileButton, decodedCopyButton, clearButton]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const encodedControl = (
|
||||||
|
<ControlMenu list={[encodedPasteButton, encodedFileButton, encodedCopyButton, clearButton]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.encodersDecoders.tools.html.longTitle}>
|
||||||
|
<PageSection title="Decoded" control={decodedControl}>
|
||||||
|
<Textarea value={decoded} onChange={onDecodedChange} rows={10} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="Encoded" control={encodedControl}>
|
||||||
|
<Textarea value={encoded} onChange={onEncodedChange} rows={10} />
|
||||||
|
</PageSection>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/encoders-decoders/jwt/layout.tsx
Normal file
11
app/encoders-decoders/jwt/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.encodersDecoders.tools.jwt.longTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
65
app/encoders-decoders/jwt/page.tsx
Normal file
65
app/encoders-decoders/jwt/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { decode } from "@/lib/jwt";
|
||||||
|
import { Editor } from "@/components/ui/editor";
|
||||||
|
import { Textarea, TextareaProps } from "@/components/ui/textarea";
|
||||||
|
import { ClearButton } from "@/components/buttons/clear";
|
||||||
|
import { CopyButton } from "@/components/buttons/copy";
|
||||||
|
import { FileButton } from "@/components/buttons/file";
|
||||||
|
import { PasteButton } from "@/components/buttons/paste";
|
||||||
|
import { ControlMenu } from "@/components/control-menu";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { PageSection } from "@/components/page-section";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [jwt, setJwt] = useState(
|
||||||
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||||
|
);
|
||||||
|
|
||||||
|
const { headerObj, payloadObj } = decode(jwt);
|
||||||
|
const header = JSON.stringify(headerObj, null, 2) ?? "";
|
||||||
|
const payload = JSON.stringify(payloadObj, null, 2) ?? "";
|
||||||
|
|
||||||
|
const clearJwt = useCallback(() => setJwt(""), []);
|
||||||
|
|
||||||
|
const onJwtChange: TextareaProps["onChange"] = ({ currentTarget: { value } }) => setJwt(value);
|
||||||
|
|
||||||
|
const jwtTokenPasteButton = useMemo(() => <PasteButton onClipboardRead={setJwt} />, [setJwt]);
|
||||||
|
|
||||||
|
const jwtTokenFileButton = useMemo(
|
||||||
|
() => <FileButton onFileRead={setJwt} iconOnly aria-label="load a token file" />,
|
||||||
|
[setJwt]
|
||||||
|
);
|
||||||
|
|
||||||
|
const jwtTokenClearButton = useMemo(
|
||||||
|
() => <ClearButton onClick={clearJwt} iconOnly aria-label="clear token" />,
|
||||||
|
[clearJwt]
|
||||||
|
);
|
||||||
|
|
||||||
|
const heaederCopyButton = useMemo(() => <CopyButton text={header} />, [header]);
|
||||||
|
const payloadCopyButton = useMemo(() => <CopyButton text={payload} />, [payload]);
|
||||||
|
|
||||||
|
const jwtTokenControl = (
|
||||||
|
<ControlMenu list={[jwtTokenPasteButton, jwtTokenFileButton, jwtTokenClearButton]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const heaederControl = <ControlMenu list={[heaederCopyButton]} />;
|
||||||
|
const payloadControl = <ControlMenu list={[payloadCopyButton]} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.encodersDecoders.tools.jwt.longTitle}>
|
||||||
|
<PageSection title="Jwt Token" control={jwtTokenControl}>
|
||||||
|
<Textarea value={jwt} onChange={onJwtChange} rows={3} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="Header" control={heaederControl}>
|
||||||
|
<Editor height={180} language="json" value={header} options={{ readOnly: true }} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="Payload" control={payloadControl}>
|
||||||
|
<Editor height={180} language="json" value={payload} options={{ readOnly: true }} />
|
||||||
|
</PageSection>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/encoders-decoders/layout.tsx
Normal file
11
app/encoders-decoders/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.encodersDecoders.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
11
app/encoders-decoders/page.tsx
Normal file
11
app/encoders-decoders/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { ToolCards } from "@/components/tool-cards";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.encodersDecoders.title}>
|
||||||
|
<ToolCards tools={Object.values(toolGroups.encodersDecoders.tools)} />
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/encoders-decoders/url/layout.tsx
Normal file
11
app/encoders-decoders/url/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.encodersDecoders.tools.url.longTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
100
app/encoders-decoders/url/page.tsx
Normal file
100
app/encoders-decoders/url/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { Textarea, TextareaProps } from "@/components/ui/textarea";
|
||||||
|
import { ClearButton } from "@/components/buttons/clear";
|
||||||
|
import { CopyButton } from "@/components/buttons/copy";
|
||||||
|
import { FileButton } from "@/components/buttons/file";
|
||||||
|
import { PasteButton } from "@/components/buttons/paste";
|
||||||
|
import { ControlMenu } from "@/components/control-menu";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { PageSection } from "@/components/page-section";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [decoded, setDecoded] = useState('> It\'s "URL encoding"?');
|
||||||
|
const [encoded, setEncoded] = useState("%3E%20It's%20%22URL%20encoding%22%3F");
|
||||||
|
|
||||||
|
const setDecodedReactively = useCallback((text: string) => {
|
||||||
|
setDecoded(text);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setEncoded(encodeURIComponent(text));
|
||||||
|
} catch {
|
||||||
|
setEncoded("");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setEncodedReactively = useCallback((text: string) => {
|
||||||
|
setEncoded(text);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDecoded(decodeURIComponent(text));
|
||||||
|
} catch {
|
||||||
|
setDecoded("");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearBoth = useCallback(() => {
|
||||||
|
setDecoded("");
|
||||||
|
setEncoded("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDecodedChange: TextareaProps["onChange"] = ({ currentTarget: { value } }) =>
|
||||||
|
setDecodedReactively(value);
|
||||||
|
|
||||||
|
const onEncodedChange: TextareaProps["onChange"] = ({ currentTarget: { value } }) =>
|
||||||
|
setEncodedReactively(value);
|
||||||
|
|
||||||
|
const decodedPasteButton = useMemo(
|
||||||
|
() => <PasteButton onClipboardRead={setDecodedReactively} />,
|
||||||
|
[setDecodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const encodedPasteButton = useMemo(
|
||||||
|
() => <PasteButton onClipboardRead={setEncodedReactively} />,
|
||||||
|
[setEncodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedFileButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<FileButton onFileRead={setDecodedReactively} iconOnly aria-label="load a decoded file" />
|
||||||
|
),
|
||||||
|
[setDecodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const encodedFileButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<FileButton onFileRead={setEncodedReactively} iconOnly aria-label="load a encoded file" />
|
||||||
|
),
|
||||||
|
[setEncodedReactively]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedCopyButton = useMemo(() => <CopyButton text={decoded} />, [decoded]);
|
||||||
|
const encodedCopyButton = useMemo(() => <CopyButton text={encoded} />, [encoded]);
|
||||||
|
|
||||||
|
const clearButton = useMemo(
|
||||||
|
() => <ClearButton onClick={clearBoth} iconOnly aria-label="clear decoded and encoded" />,
|
||||||
|
[clearBoth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const decodedControl = (
|
||||||
|
<ControlMenu list={[decodedPasteButton, decodedFileButton, decodedCopyButton, clearButton]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const encodedControl = (
|
||||||
|
<ControlMenu list={[encodedPasteButton, encodedFileButton, encodedCopyButton, clearButton]} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.encodersDecoders.tools.url.longTitle}>
|
||||||
|
<PageSection title="Decoded" control={decodedControl}>
|
||||||
|
<Textarea value={decoded} onChange={onDecodedChange} rows={10} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="Encoded" control={encodedControl}>
|
||||||
|
<Textarea value={encoded} onChange={onEncodedChange} rows={10} />
|
||||||
|
</PageSection>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
11
app/formatters/json/layout.tsx
Normal file
11
app/formatters/json/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.formatters.tools.json.longTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
111
app/formatters/json/page.tsx
Normal file
111
app/formatters/json/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { Editor, EditorProps } from "@/components/ui/editor";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { ClearButton } from "@/components/buttons/clear";
|
||||||
|
import { CopyButton } from "@/components/buttons/copy";
|
||||||
|
import { FileButton } from "@/components/buttons/file";
|
||||||
|
import { PasteButton } from "@/components/buttons/paste";
|
||||||
|
import { Configuration } from "@/components/configuration";
|
||||||
|
import { Configurations } from "@/components/configurations";
|
||||||
|
import { ControlMenu } from "@/components/control-menu";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { PageSection } from "@/components/page-section";
|
||||||
|
|
||||||
|
const two = " ";
|
||||||
|
const four = " ";
|
||||||
|
const zero = "";
|
||||||
|
const tab = "\t";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [indentation, setIndentation] = useState(two);
|
||||||
|
const [input, setInput] = useState('{\n"foo":"bar"\n}');
|
||||||
|
|
||||||
|
let output: string;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(input) as unknown;
|
||||||
|
output = JSON.stringify(parsed, null, indentation);
|
||||||
|
} catch {
|
||||||
|
output = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearInput = useCallback(() => setInput(""), []);
|
||||||
|
|
||||||
|
const onJsonChange: EditorProps["onChange"] = value => setInput(value ?? "");
|
||||||
|
|
||||||
|
const indentationIcon = useMemo(() => <icons.Space size={24} className="-translate-y-1.5" />, []);
|
||||||
|
|
||||||
|
const indentationConfig = useMemo(
|
||||||
|
() => (
|
||||||
|
<Configuration
|
||||||
|
icon={indentationIcon}
|
||||||
|
title="Indentation"
|
||||||
|
control={
|
||||||
|
<Select value={indentation} onValueChange={setIndentation}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-28"
|
||||||
|
aria-label="toggle open/close state of indentation selection"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={indentation} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={two}>2 spaces</SelectItem>
|
||||||
|
<SelectItem value={four}>4 spaces</SelectItem>
|
||||||
|
<SelectItem value={tab}>1 tab</SelectItem>
|
||||||
|
<SelectItem value={zero}>minified</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[indentation, indentationIcon]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputPasteButton = useMemo(() => <PasteButton onClipboardRead={setInput} />, []);
|
||||||
|
|
||||||
|
const inputFileButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<FileButton accept=".json" onFileRead={setInput} iconOnly aria-label="load a json file" />
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputClearButton = useMemo(
|
||||||
|
() => <ClearButton onClick={clearInput} iconOnly aria-label="clear json" />,
|
||||||
|
[clearInput]
|
||||||
|
);
|
||||||
|
|
||||||
|
const outputCopyButton = useMemo(() => <CopyButton text={output} />, [output]);
|
||||||
|
|
||||||
|
const inputControl = <ControlMenu list={[inputPasteButton, inputFileButton, inputClearButton]} />;
|
||||||
|
const outputControl = <ControlMenu list={[outputCopyButton]} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRootSection
|
||||||
|
className="flex h-full flex-col"
|
||||||
|
title={toolGroups.formatters.tools.json.longTitle}
|
||||||
|
>
|
||||||
|
<PageSection className="mt-0" title="Configuration">
|
||||||
|
<Configurations list={[indentationConfig]} />
|
||||||
|
</PageSection>
|
||||||
|
<div className="flex flex-1 flex-col gap-x-4 gap-y-5 lg:flex-row">
|
||||||
|
<PageSection className="min-h-[200px] flex-1" title="Input" control={inputControl}>
|
||||||
|
<Editor language="json" value={input} onChange={onJsonChange} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection className="min-h-[200px] flex-1" title="Output" control={outputControl}>
|
||||||
|
<Editor language="json" value={output} options={{ readOnly: true }} />
|
||||||
|
</PageSection>
|
||||||
|
</div>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/formatters/layout.tsx
Normal file
11
app/formatters/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.formatters.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
11
app/formatters/page.tsx
Normal file
11
app/formatters/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { ToolCards } from "@/components/tool-cards";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.formatters.title}>
|
||||||
|
<ToolCards tools={Object.values(toolGroups.formatters.tools)} />
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/generators/hash/layout.tsx
Normal file
11
app/generators/hash/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.generators.tools.hash.longTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
129
app/generators/hash/page.tsx
Normal file
129
app/generators/hash/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import createHash from "create-hash";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea, TextareaProps } from "@/components/ui/textarea";
|
||||||
|
import { ClearButton } from "@/components/buttons/clear";
|
||||||
|
import { CopyButton } from "@/components/buttons/copy";
|
||||||
|
import { FileButton } from "@/components/buttons/file";
|
||||||
|
import { PasteButton } from "@/components/buttons/paste";
|
||||||
|
import { Configuration } from "@/components/configuration";
|
||||||
|
import { Configurations } from "@/components/configurations";
|
||||||
|
import { ControlMenu } from "@/components/control-menu";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
import { LabeledSwitch } from "@/components/labeled-switch";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { PageSection } from "@/components/page-section";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [uppercase, setUppercase] = useState(false);
|
||||||
|
const [input, setInput] = useState("Hello there !");
|
||||||
|
|
||||||
|
const newMd5 = createHash("md5").update(input).digest("hex");
|
||||||
|
const newSha1 = createHash("sha1").update(input).digest("hex");
|
||||||
|
const newSha256 = createHash("sha256").update(input).digest("hex");
|
||||||
|
const newSha512 = createHash("sha512").update(input).digest("hex");
|
||||||
|
|
||||||
|
const md5 = uppercase ? newMd5.toUpperCase() : newMd5;
|
||||||
|
const sha1 = uppercase ? newSha1.toUpperCase() : newSha1;
|
||||||
|
const sha256 = uppercase ? newSha256.toUpperCase() : newSha256;
|
||||||
|
const sha512 = uppercase ? newSha512.toUpperCase() : newSha512;
|
||||||
|
|
||||||
|
const clearInput = useCallback(() => setInput(""), []);
|
||||||
|
|
||||||
|
const onInputChange: TextareaProps["onChange"] = ({ currentTarget: { value } }) =>
|
||||||
|
setInput(value);
|
||||||
|
|
||||||
|
const uppercaseIcon = useMemo(() => <icons.CaseSensitive size={24} />, []);
|
||||||
|
|
||||||
|
const uppercaseConfig = useMemo(
|
||||||
|
() => (
|
||||||
|
<Configuration
|
||||||
|
icon={uppercaseIcon}
|
||||||
|
title="Uppercase"
|
||||||
|
control={
|
||||||
|
<LabeledSwitch
|
||||||
|
id="uppercase-switch"
|
||||||
|
label={uppercase ? "On" : "Off"}
|
||||||
|
checked={uppercase}
|
||||||
|
onCheckedChange={setUppercase}
|
||||||
|
aria-label="toggle whether to generate in uppercase"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[uppercase, uppercaseIcon]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputPasteButton = useMemo(() => <PasteButton onClipboardRead={setInput} />, []);
|
||||||
|
|
||||||
|
const inputFileButton = useMemo(
|
||||||
|
() => <FileButton onFileRead={setInput} iconOnly aria-label="load a file" />,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputClearButton = useMemo(
|
||||||
|
() => <ClearButton onClick={clearInput} iconOnly aria-label="clear input" />,
|
||||||
|
[clearInput]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputControl = <ControlMenu list={[inputPasteButton, inputFileButton, inputClearButton]} />;
|
||||||
|
|
||||||
|
const md5CopyButton = useMemo(
|
||||||
|
() => <CopyButton text={md5} iconOnly aria-label="copy generated md5" />,
|
||||||
|
[md5]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sha1CopyButton = useMemo(
|
||||||
|
() => <CopyButton text={sha1} iconOnly aria-label="copy generated sha1" />,
|
||||||
|
[sha1]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sha256CopyButton = useMemo(
|
||||||
|
() => <CopyButton text={sha256} iconOnly aria-label="copy generated sha256" />,
|
||||||
|
[sha256]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sha512CopyButton = useMemo(
|
||||||
|
() => <CopyButton text={sha512} iconOnly aria-label="copy generated sha512" />,
|
||||||
|
[sha512]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.generators.tools.hash.longTitle}>
|
||||||
|
<PageSection title="Configuration">
|
||||||
|
<Configurations list={[uppercaseConfig]} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection className="my-4" title="Input" control={inputControl}>
|
||||||
|
<Textarea value={input} onChange={onInputChange} rows={5} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="MD5">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input className="flex-1" value={md5} readOnly />
|
||||||
|
<ControlMenu list={[md5CopyButton]} />
|
||||||
|
</div>
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="SHA1">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input className="flex-1" value={sha1} readOnly />
|
||||||
|
<ControlMenu list={[sha1CopyButton]} />
|
||||||
|
</div>
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="SHA256">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input className="flex-1" value={sha256} readOnly />
|
||||||
|
<ControlMenu list={[sha256CopyButton]} />
|
||||||
|
</div>
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="SHA512">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input className="flex-1" value={sha512} readOnly />
|
||||||
|
<ControlMenu list={[sha512CopyButton]} />
|
||||||
|
</div>
|
||||||
|
</PageSection>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/generators/layout.tsx
Normal file
11
app/generators/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.generators.title,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
11
app/generators/page.tsx
Normal file
11
app/generators/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { ToolCards } from "@/components/tool-cards";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.generators.title}>
|
||||||
|
<ToolCards tools={Object.values(toolGroups.generators.tools)} />
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/generators/uuid/layout.tsx
Normal file
11
app/generators/uuid/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: toolGroups.generators.tools.uuid.longTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
171
app/generators/uuid/page.tsx
Normal file
171
app/generators/uuid/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { range } from "fp-ts/NonEmptyArray";
|
||||||
|
import * as t from "io-ts";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
import { uuid } from "@/lib/uuid";
|
||||||
|
import { useScrollFollow } from "@/hooks/useScrollFollow";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input, InputProps } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectProps,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { ClearButton } from "@/components/buttons/clear";
|
||||||
|
import { CopyButton } from "@/components/buttons/copy";
|
||||||
|
import { Configuration } from "@/components/configuration";
|
||||||
|
import { Configurations } from "@/components/configurations";
|
||||||
|
import { ControlMenu } from "@/components/control-menu";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
import { LabeledSwitch } from "@/components/labeled-switch";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { PageSection } from "@/components/page-section";
|
||||||
|
|
||||||
|
const v1 = "1";
|
||||||
|
const v4 = "4";
|
||||||
|
|
||||||
|
const uuidVersions = t.keyof({ [v1]: null, [v4]: null });
|
||||||
|
type UuidVersion = t.TypeOf<typeof uuidVersions>;
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [hyphens, setHyphens] = useState(true);
|
||||||
|
const [uppercase, setUppercase] = useState(false);
|
||||||
|
const [uuidVersion, setUuidVersion] = useState<UuidVersion>("4");
|
||||||
|
const [generates, setGenerates] = useState(1);
|
||||||
|
const [uuids, setUuids] = useState<string[]>([]);
|
||||||
|
const ref = useScrollFollow<HTMLTextAreaElement>([uuids]);
|
||||||
|
|
||||||
|
const uuidsString = uuids.join("\n");
|
||||||
|
|
||||||
|
const clearUuids = useCallback(() => setUuids([]), []);
|
||||||
|
|
||||||
|
const onUuidVersionChange: SelectProps["onValueChange"] = value => {
|
||||||
|
if (uuidVersions.is(value)) {
|
||||||
|
setUuidVersion(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGeneratesChange: InputProps["onChange"] = ({ currentTarget: { value } }) => {
|
||||||
|
const newGenerates = Number(value);
|
||||||
|
|
||||||
|
if (newGenerates >= 1 && newGenerates <= 1000) {
|
||||||
|
setGenerates(newGenerates);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGenerateClick = () => {
|
||||||
|
const newUuids = range(1, generates).map(_ => uuid(uuidVersion, hyphens, uppercase));
|
||||||
|
setUuids([...uuids, ...newUuids]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hyphensConfig = useMemo(
|
||||||
|
() => (
|
||||||
|
<Configuration
|
||||||
|
icon={<icons.Minus size={24} />}
|
||||||
|
title="Hyphens"
|
||||||
|
control={
|
||||||
|
<LabeledSwitch
|
||||||
|
id="hyphens-switch"
|
||||||
|
label={hyphens ? "On" : "Off"}
|
||||||
|
checked={hyphens}
|
||||||
|
onCheckedChange={setHyphens}
|
||||||
|
aria-label="toggle whether to add hyphens"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[hyphens]
|
||||||
|
);
|
||||||
|
|
||||||
|
const uppercaseConfig = useMemo(
|
||||||
|
() => (
|
||||||
|
<Configuration
|
||||||
|
icon={<icons.CaseSensitive size={24} />}
|
||||||
|
title="Uppercase"
|
||||||
|
control={
|
||||||
|
<LabeledSwitch
|
||||||
|
id="uppercase-switch"
|
||||||
|
label={uppercase ? "On" : "Off"}
|
||||||
|
checked={uppercase}
|
||||||
|
onCheckedChange={setUppercase}
|
||||||
|
aria-label="toggle whether to generate in uppercase"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[uppercase]
|
||||||
|
);
|
||||||
|
|
||||||
|
const uuidVersionConfig = useMemo(
|
||||||
|
() => (
|
||||||
|
<Configuration
|
||||||
|
icon={<icons.Settings2 size={24} />}
|
||||||
|
title="UUID version"
|
||||||
|
description="Choose the version of UUID to generate"
|
||||||
|
control={
|
||||||
|
<Select value={uuidVersion} onValueChange={onUuidVersionChange}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-28"
|
||||||
|
aria-label="toggle open/close state of uuid version selection"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={uuidVersion} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={v1}>1</SelectItem>
|
||||||
|
<SelectItem value={v4}>4 (GUID)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[uuidVersion]
|
||||||
|
);
|
||||||
|
|
||||||
|
const generatesInput = useMemo(
|
||||||
|
() => (
|
||||||
|
<Input
|
||||||
|
className="w-24 font-sans"
|
||||||
|
type="number"
|
||||||
|
value={generates}
|
||||||
|
onChange={onGeneratesChange}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[generates]
|
||||||
|
);
|
||||||
|
|
||||||
|
const uuidsCopyButton = useMemo(() => <CopyButton text={uuidsString} />, [uuidsString]);
|
||||||
|
|
||||||
|
const uuidsClearButton = useMemo(
|
||||||
|
() => <ClearButton onClick={clearUuids} iconOnly aria-label="clear uuids" />,
|
||||||
|
[clearUuids]
|
||||||
|
);
|
||||||
|
|
||||||
|
const uuidsControl = <ControlMenu list={[uuidsCopyButton, uuidsClearButton]} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRootSection title={toolGroups.generators.tools.uuid.longTitle}>
|
||||||
|
<PageSection title="Configuration">
|
||||||
|
<Configurations list={[hyphensConfig, uppercaseConfig, uuidVersionConfig]} />
|
||||||
|
</PageSection>
|
||||||
|
<PageSection className="mt-6" title="Generate">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" onClick={onGenerateClick}>
|
||||||
|
Generate UUID(s)
|
||||||
|
</Button>
|
||||||
|
<span>×</span>
|
||||||
|
{generatesInput}
|
||||||
|
</div>
|
||||||
|
</PageSection>
|
||||||
|
<PageSection title="UUID(s)" control={uuidsControl}>
|
||||||
|
<Textarea {...{ ref }} value={uuidsString} rows={10} readOnly />
|
||||||
|
</PageSection>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/icon.svg
Normal file
9
app/icon.svg
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="180px" height="180px" viewBox="-40.96 -40.96 593.92 593.92" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" fill="#000000">
|
||||||
|
|
||||||
|
<g id="SVGRepo_bgCarrier" stroke-width="0" transform="translate(0,0), scale(1)">
|
||||||
|
|
||||||
|
<rect x="-40.96" y="-40.96" width="593.92" height="593.92" rx="83.1488" fill="#6f32ac" strokewidth="0"/>
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
61
app/layout.tsx
Normal file
61
app/layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import "@/styles/globals.css";
|
||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { siteConfig } from "@/config/site";
|
||||||
|
import { fontMono, fontSans } from "@/lib/fonts";
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
import { Sidebar } from "@/components/sidebar";
|
||||||
|
import { SiteHeader } from "@/components/site-header";
|
||||||
|
import { TailwindIndicator } from "@/components/tailwind-indicator";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
generator: "Next.js",
|
||||||
|
applicationName: siteConfig.name,
|
||||||
|
referrer: "origin-when-cross-origin",
|
||||||
|
title: {
|
||||||
|
default: siteConfig.name,
|
||||||
|
template: `%s - ${siteConfig.name}`,
|
||||||
|
},
|
||||||
|
description: siteConfig.description,
|
||||||
|
openGraph: {
|
||||||
|
title: siteConfig.name,
|
||||||
|
siteName: siteConfig.name,
|
||||||
|
type: "website",
|
||||||
|
},
|
||||||
|
themeColor: [
|
||||||
|
{ media: "(prefers-color-scheme: light)", color: "white" },
|
||||||
|
{ media: "(prefers-color-scheme: dark)", color: "black" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
|
return (
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<body
|
||||||
|
className={cn(
|
||||||
|
"h-screen bg-background font-sans text-sm font-medium text-foreground antialiased",
|
||||||
|
fontSans.variable,
|
||||||
|
fontMono.variable
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ThemeProvider attribute="class" defaultTheme="system" disableTransitionOnChange>
|
||||||
|
<div className="relative flex h-full flex-col">
|
||||||
|
<SiteHeader />
|
||||||
|
<div className="flex flex-1 overflow-y-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="h-full flex-1 overflow-y-auto rounded-tl-md border bg-page p-12">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TailwindIndicator />
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
app/not-found.tsx
Normal file
9
app/not-found.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<PageRootSection title="Not Found">
|
||||||
|
<p>Could not find requested resource</p>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
app/page.tsx
Normal file
11
app/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { homeTools, singleTools } from "@/config/tools";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { ToolCards } from "@/components/tool-cards";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return (
|
||||||
|
<PageRootSection title={singleTools.allTools.longTitle}>
|
||||||
|
<ToolCards tools={homeTools} />
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
app/search/layout.tsx
Normal file
10
app/search/layout.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
// TODO: use query param
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Search results",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
26
app/search/page.tsx
Normal file
26
app/search/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import Fuse from "fuse.js";
|
||||||
|
|
||||||
|
import { homeTools } from "@/config/tools";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { ToolCards } from "@/components/tool-cards";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const params = useSearchParams();
|
||||||
|
|
||||||
|
const q = params.get("q")?.trim() ?? "";
|
||||||
|
|
||||||
|
const fuse = new Fuse(homeTools, { keys: ["keywords"], threshold: 0.45 });
|
||||||
|
const keyWordsOptions = q.split(" ").map(word => ({ keywords: word }));
|
||||||
|
const result = fuse.search({ $or: keyWordsOptions });
|
||||||
|
const tools = result.map(({ item }) => item);
|
||||||
|
|
||||||
|
const [title, child] =
|
||||||
|
tools.length === 0
|
||||||
|
? ["No results found", null]
|
||||||
|
: [`Search results for "${q}"`, <ToolCards {...{ tools }} />];
|
||||||
|
|
||||||
|
return <PageRootSection {...{ title }}>{child}</PageRootSection>;
|
||||||
|
}
|
||||||
11
app/settings/layout.tsx
Normal file
11
app/settings/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
|
||||||
|
import { singleTools } from "@/config/tools";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: singleTools.settings.longTitle,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
58
app/settings/page.tsx
Normal file
58
app/settings/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
import { singleTools } from "@/config/tools";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Configuration } from "@/components/configuration";
|
||||||
|
import { Configurations } from "@/components/configurations";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
import { PageRootSection } from "@/components/page-root-section";
|
||||||
|
import { PageSection } from "@/components/page-section";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { theme = "system", setTheme } = useTheme();
|
||||||
|
|
||||||
|
const appThemeIcon = useMemo(() => <icons.Paintbrush size={24} />, []);
|
||||||
|
|
||||||
|
const appThemeConfig = useMemo(
|
||||||
|
() => (
|
||||||
|
<Configuration
|
||||||
|
icon={appThemeIcon}
|
||||||
|
title="App theme"
|
||||||
|
description="Select which app theme to display"
|
||||||
|
control={
|
||||||
|
<Select value={theme} onValueChange={setTheme}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="w-28"
|
||||||
|
aria-label="toggle open/close state of app theme selection"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={theme} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="light">Light</SelectItem>
|
||||||
|
<SelectItem value="dark">Dark</SelectItem>
|
||||||
|
<SelectItem value="system">System</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[appThemeIcon, setTheme, theme]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageRootSection title={singleTools.settings.longTitle}>
|
||||||
|
<PageSection>
|
||||||
|
<Configurations list={[appThemeConfig]} />
|
||||||
|
</PageSection>
|
||||||
|
</PageRootSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
components/buttons/base.tsx
Normal file
30
components/buttons/base.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Button, ButtonProps } from "@/components/ui/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
export type BaseButtonProps = ButtonProps & {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
iconOnly?: true;
|
||||||
|
labelText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BaseButton({ icon, iconOnly, labelText, ...props }: BaseButtonProps) {
|
||||||
|
const button = (
|
||||||
|
<Button className="w-fit border" {...props}>
|
||||||
|
{icon}
|
||||||
|
{!iconOnly && <span className="ml-1">{labelText}</span>}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return iconOnly ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{labelText}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
button
|
||||||
|
);
|
||||||
|
}
|
||||||
13
components/buttons/clear.tsx
Normal file
13
components/buttons/clear.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
|
||||||
|
import { BaseButton, BaseButtonProps } from "./base";
|
||||||
|
|
||||||
|
export type ClearButtonProps = Omit<BaseButtonProps, "icon" | "labelText">;
|
||||||
|
|
||||||
|
export function ClearButton({ iconOnly, ...props }: ClearButtonProps) {
|
||||||
|
const icon = useMemo(() => <icons.X size={16} />, []);
|
||||||
|
|
||||||
|
return <BaseButton {...props} {...{ icon, iconOnly }} labelText="Clear" />;
|
||||||
|
}
|
||||||
24
components/buttons/copy.tsx
Normal file
24
components/buttons/copy.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
|
||||||
|
import { BaseButton, BaseButtonProps } from "./base";
|
||||||
|
|
||||||
|
export type CopyButtonProps = Omit<BaseButtonProps, "icon" | "labelText" | "onClick"> & {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CopyButton({ text, iconOnly, ...props }: CopyButtonProps) {
|
||||||
|
const onClick: BaseButtonProps["onClick"] = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(text).catch(e => {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [text]);
|
||||||
|
|
||||||
|
const icon = useMemo(() => <icons.Copy size={16} />, []);
|
||||||
|
|
||||||
|
return <BaseButton {...props} {...{ icon, iconOnly, onClick }} labelText="Copy" />;
|
||||||
|
}
|
||||||
66
components/buttons/file.tsx
Normal file
66
components/buttons/file.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
|
||||||
|
import { BaseButton, BaseButtonProps } from "./base";
|
||||||
|
|
||||||
|
type InputProps = React.ComponentProps<"input">;
|
||||||
|
|
||||||
|
export type FileButtonProps = Pick<InputProps, "accept"> &
|
||||||
|
Omit<BaseButtonProps, "icon" | "labelText" | "onClick"> & {
|
||||||
|
maxFileSizeMb?: number;
|
||||||
|
onFileRead: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FileButton({
|
||||||
|
accept,
|
||||||
|
iconOnly,
|
||||||
|
maxFileSizeMb = 20,
|
||||||
|
onFileRead,
|
||||||
|
...props
|
||||||
|
}: FileButtonProps) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const onClick = () => ref.current?.click();
|
||||||
|
|
||||||
|
const onChange: NonNullable<InputProps["onChange"]> = useCallback(
|
||||||
|
({ currentTarget }) => {
|
||||||
|
const file = Array.from(currentTarget.files ?? []).at(0);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: reject if the file is unsupported
|
||||||
|
|
||||||
|
if (file.size > maxFileSizeMb * 2 ** 20) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
return alert(`The file is too big. Up to ${maxFileSizeMb}MiB.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = ({ target }) => {
|
||||||
|
if (typeof target?.result === "string") {
|
||||||
|
onFileRead(target?.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file);
|
||||||
|
|
||||||
|
// clear selected file to accept the same file again
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
currentTarget.value = "";
|
||||||
|
},
|
||||||
|
[maxFileSizeMb, onFileRead]
|
||||||
|
);
|
||||||
|
|
||||||
|
const icon = useMemo(() => <icons.File size={16} />, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BaseButton {...props} {...{ icon, iconOnly, onClick }} labelText="Load a file" />
|
||||||
|
<input hidden type="file" {...{ ref, accept, onChange }} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
components/buttons/paste.tsx
Normal file
27
components/buttons/paste.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
|
||||||
|
import { BaseButton, BaseButtonProps } from "./base";
|
||||||
|
|
||||||
|
export type PasteButtonProps = Omit<BaseButtonProps, "icon" | "labelText" | "onClick"> & {
|
||||||
|
onClipboardRead: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PasteButton({ iconOnly, onClipboardRead, ...props }: PasteButtonProps) {
|
||||||
|
const onClick: BaseButtonProps["onClick"] = useCallback(() => {
|
||||||
|
navigator.clipboard
|
||||||
|
.readText()
|
||||||
|
.then(onClipboardRead)
|
||||||
|
.catch(e => {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [onClipboardRead]);
|
||||||
|
|
||||||
|
const icon = useMemo(() => <icons.Clipboard size={16} />, []);
|
||||||
|
|
||||||
|
return <BaseButton {...props} {...{ icon, iconOnly, onClick }} labelText="Paste" />;
|
||||||
|
}
|
||||||
23
components/configuration.tsx
Normal file
23
components/configuration.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
type Props = {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
control: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Configuration({ icon, title, description, control }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-16 items-center gap-6 rounded border bg-configuration px-4">
|
||||||
|
{icon}
|
||||||
|
{description ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{description}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>{title}</span>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-1 justify-end">{control}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/configurations.tsx
Normal file
20
components/configurations.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import equal from "fast-deep-equal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
list: React.ReactNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function RawConfigurations({ list }: Props) {
|
||||||
|
return (
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{list.map((config, i) => (
|
||||||
|
// re-render does not change the order
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<li key={i}>{config}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Configurations = memo(RawConfigurations, equal);
|
||||||
20
components/control-menu.tsx
Normal file
20
components/control-menu.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import equal from "fast-deep-equal";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
list: React.ReactNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function RawControlMenu({ list }: Props) {
|
||||||
|
return (
|
||||||
|
<menu className="flex gap-2">
|
||||||
|
{list.map((control, i) => (
|
||||||
|
// re-render does not change the order
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
<li key={i}>{control}</li>
|
||||||
|
))}
|
||||||
|
</menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ControlMenu = memo(RawControlMenu, equal);
|
||||||
71
components/icons.tsx
Normal file
71
components/icons.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
AlignLeft,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Binary,
|
||||||
|
Braces,
|
||||||
|
CaseSensitive,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Clipboard,
|
||||||
|
Code2,
|
||||||
|
Copy,
|
||||||
|
Equal,
|
||||||
|
FileIcon,
|
||||||
|
Fingerprint,
|
||||||
|
Hash,
|
||||||
|
Home,
|
||||||
|
Key,
|
||||||
|
Link2,
|
||||||
|
LucideProps,
|
||||||
|
Minus,
|
||||||
|
Moon,
|
||||||
|
PackagePlus,
|
||||||
|
Paintbrush2,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Settings2,
|
||||||
|
Space,
|
||||||
|
SunMedium,
|
||||||
|
X,
|
||||||
|
type Icon as LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export type Icon = LucideIcon;
|
||||||
|
|
||||||
|
export const icons = {
|
||||||
|
AlignLeft,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Binary,
|
||||||
|
Braces,
|
||||||
|
CaseSensitive,
|
||||||
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
Clipboard,
|
||||||
|
Code: Code2,
|
||||||
|
Copy,
|
||||||
|
Equal,
|
||||||
|
File: FileIcon,
|
||||||
|
Fingerprint,
|
||||||
|
Hash,
|
||||||
|
Home,
|
||||||
|
Key,
|
||||||
|
Link: Link2,
|
||||||
|
PackagePlus,
|
||||||
|
Paintbrush: Paintbrush2,
|
||||||
|
Search,
|
||||||
|
Settings,
|
||||||
|
Settings2,
|
||||||
|
Space,
|
||||||
|
Sun: SunMedium,
|
||||||
|
Minus,
|
||||||
|
Moon,
|
||||||
|
X,
|
||||||
|
GitHub: (props: LucideProps) => (
|
||||||
|
<svg viewBox="0 0 438.549 438.549" {...props}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M409.132 114.573c-19.608-33.596-46.205-60.194-79.798-79.8-33.598-19.607-70.277-29.408-110.063-29.408-39.781 0-76.472 9.804-110.063 29.408-33.596 19.605-60.192 46.204-79.8 79.8C9.803 148.168 0 184.854 0 224.63c0 47.78 13.94 90.745 41.827 128.906 27.884 38.164 63.906 64.572 108.063 79.227 5.14.954 8.945.283 11.419-1.996 2.475-2.282 3.711-5.14 3.711-8.562 0-.571-.049-5.708-.144-15.417a2549.81 2549.81 0 01-.144-25.406l-6.567 1.136c-4.187.767-9.469 1.092-15.846 1-6.374-.089-12.991-.757-19.842-1.999-6.854-1.231-13.229-4.086-19.13-8.559-5.898-4.473-10.085-10.328-12.56-17.556l-2.855-6.57c-1.903-4.374-4.899-9.233-8.992-14.559-4.093-5.331-8.232-8.945-12.419-10.848l-1.999-1.431c-1.332-.951-2.568-2.098-3.711-3.429-1.142-1.331-1.997-2.663-2.568-3.997-.572-1.335-.098-2.43 1.427-3.289 1.525-.859 4.281-1.276 8.28-1.276l5.708.853c3.807.763 8.516 3.042 14.133 6.851 5.614 3.806 10.229 8.754 13.846 14.842 4.38 7.806 9.657 13.754 15.846 17.847 6.184 4.093 12.419 6.136 18.699 6.136 6.28 0 11.704-.476 16.274-1.423 4.565-.952 8.848-2.383 12.847-4.285 1.713-12.758 6.377-22.559 13.988-29.41-10.848-1.14-20.601-2.857-29.264-5.14-8.658-2.286-17.605-5.996-26.835-11.14-9.235-5.137-16.896-11.516-22.985-19.126-6.09-7.614-11.088-17.61-14.987-29.979-3.901-12.374-5.852-26.648-5.852-42.826 0-23.035 7.52-42.637 22.557-58.817-7.044-17.318-6.379-36.732 1.997-58.24 5.52-1.715 13.706-.428 24.554 3.853 10.85 4.283 18.794 7.952 23.84 10.994 5.046 3.041 9.089 5.618 12.135 7.708 17.705-4.947 35.976-7.421 54.818-7.421s37.117 2.474 54.823 7.421l10.849-6.849c7.419-4.57 16.18-8.758 26.262-12.565 10.088-3.805 17.802-4.853 23.134-3.138 8.562 21.509 9.325 40.922 2.279 58.24 15.036 16.18 22.559 35.787 22.559 58.817 0 16.178-1.958 30.497-5.853 42.966-3.9 12.471-8.941 22.457-15.125 29.979-6.191 7.521-13.901 13.85-23.131 18.986-9.232 5.14-18.182 8.85-26.84 11.136-8.662 2.286-18.415 4.004-29.263 5.146 9.894 8.562 14.842 22.077 14.842 40.539v60.237c0 3.422 1.19 6.279 3.572 8.562 2.379 2.279 6.136 2.95 11.276 1.995 44.163-14.653 80.185-41.062 108.068-79.226 27.88-38.161 41.825-81.126 41.825-128.906-.01-39.771-9.818-76.454-29.414-110.049z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
7
components/indicator.tsx
Normal file
7
components/indicator.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
|
||||||
|
function RawIndicator() {
|
||||||
|
return <span className="inline-block h-[18px] w-[3px] rounded bg-indicator" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Indicator = memo(RawIndicator);
|
||||||
19
components/labeled-switch.tsx
Normal file
19
components/labeled-switch.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch, SwitchProps } from "@/components/ui/switch";
|
||||||
|
|
||||||
|
type Props = Omit<SwitchProps, "id"> & {
|
||||||
|
id: string;
|
||||||
|
label: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LabeledSwitch({ id, label, ...props }: Props) {
|
||||||
|
return (
|
||||||
|
// reverse to apply peer style
|
||||||
|
<div className="flex flex-row-reverse items-center">
|
||||||
|
<Switch className="peer" {...{ id }} {...props} />
|
||||||
|
<Label className="cursor-pointer pr-3" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/page-root-section.tsx
Normal file
14
components/page-root-section.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageRootSection({ className, children, title }: Props) {
|
||||||
|
return (
|
||||||
|
<section {...{ className }}>
|
||||||
|
<h1 className="mb-6 text-2xl">{title}</h1>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/page-section.tsx
Normal file
25
components/page-section.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { cn } from "@/lib/style";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
title?: string;
|
||||||
|
control?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PageSection({ className, children, title, control }: Props) {
|
||||||
|
return (
|
||||||
|
<section className={cn("mt-3 flex flex-col", className)}>
|
||||||
|
{title &&
|
||||||
|
(control ? (
|
||||||
|
<div className="mb-1.5 flex w-full items-end">
|
||||||
|
<h2 className="text-base">{title}</h2>
|
||||||
|
<div className="ml-auto">{control}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h2 className="mb-1.5 text-base">{title}</h2>
|
||||||
|
))}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
components/sidebar.tsx
Normal file
29
components/sidebar.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
import { AllTools } from "./sidebar/all-tools";
|
||||||
|
import { SearchBar } from "./sidebar/search-bar";
|
||||||
|
import { Settings } from "./sidebar/settings";
|
||||||
|
import { ToolGroups } from "./sidebar/tool-groups";
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
return (
|
||||||
|
<nav className="flex w-72 flex-col">
|
||||||
|
<div className="mt-px px-4">
|
||||||
|
<SearchBar />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="mb-2 mt-4 px-2">
|
||||||
|
<AllTools />
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="p-2">
|
||||||
|
<ToolGroups />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="justify-end p-2">
|
||||||
|
<Settings />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/sidebar/all-tools.tsx
Normal file
20
components/sidebar/all-tools.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { singleTools } from "@/config/tools";
|
||||||
|
|
||||||
|
import { ToolLink } from "./tool-link";
|
||||||
|
|
||||||
|
export function AllTools() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolLink
|
||||||
|
Icon={singleTools.allTools.Icon}
|
||||||
|
shortTitle={singleTools.allTools.shortTitle}
|
||||||
|
href={singleTools.allTools.href}
|
||||||
|
highlight={pathname === singleTools.allTools.href ? "both" : "none"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
components/sidebar/search-bar.tsx
Normal file
64
components/sidebar/search-bar.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
import { Button, ButtonProps } from "@/components/ui/button";
|
||||||
|
import { Input, InputProps } from "@/components/ui/input";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
|
||||||
|
export function SearchBar() {
|
||||||
|
const router = useRouter();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
|
||||||
|
const search = () => {
|
||||||
|
if (text.trim()) {
|
||||||
|
router.push(`/search?q=${text.trim()}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeText: InputProps["onChange"] = ({ currentTarget }) => setText(currentTarget.value);
|
||||||
|
|
||||||
|
const searchIfEnter: InputProps["onKeyDown"] = ({ code }) => {
|
||||||
|
if (code === "Enter") {
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearText: ButtonProps["onClick"] = () => {
|
||||||
|
setText("");
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearIcon = useMemo(() => <icons.X className="p-1 text-muted-foreground" />, []);
|
||||||
|
|
||||||
|
const searchIcon = useMemo(
|
||||||
|
() => <icons.Search className="-scale-x-100 p-1 text-muted-foreground" />,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex w-full items-center">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
className="flex-1 pr-16 font-sans"
|
||||||
|
value={text}
|
||||||
|
onChange={changeText}
|
||||||
|
onKeyDown={searchIfEnter}
|
||||||
|
placeholder="Type to search for tools…"
|
||||||
|
/>
|
||||||
|
<div className="absolute right-1 flex gap-1">
|
||||||
|
<Button className={cn("h-6 p-0", !text && "hidden")} variant="ghost" onClick={clearText}>
|
||||||
|
{clearIcon}
|
||||||
|
<span className="sr-only">Clear search text</span>
|
||||||
|
</Button>
|
||||||
|
<Button className="h-6 p-0" variant="ghost" onClick={search} aria-label="search">
|
||||||
|
{searchIcon}
|
||||||
|
<span className="sr-only">Search tools</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
components/sidebar/settings.tsx
Normal file
20
components/sidebar/settings.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { singleTools } from "@/config/tools";
|
||||||
|
|
||||||
|
import { ToolLink } from "./tool-link";
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToolLink
|
||||||
|
Icon={singleTools.settings.Icon}
|
||||||
|
shortTitle={singleTools.settings.shortTitle}
|
||||||
|
href={singleTools.settings.href}
|
||||||
|
highlight={pathname === singleTools.settings.href ? "both" : "none"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
components/sidebar/tool-group.tsx
Normal file
69
components/sidebar/tool-group.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import * as Accordion from "@radix-ui/react-accordion";
|
||||||
|
|
||||||
|
import { ToolGroup as IToolGroup } from "@/config/tools";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
|
||||||
|
import { ToolLink } from "./tool-link";
|
||||||
|
|
||||||
|
type Props = IToolGroup & {
|
||||||
|
isOpend: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIXME: css outline messed up
|
||||||
|
export function ToolGroup({ Icon, title, href, tools, isOpend }: Props) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
const onClick = useCallback(() => triggerRef.current?.click(), []);
|
||||||
|
|
||||||
|
const chevronIcon = useMemo(
|
||||||
|
() => <icons.ChevronDown className="h-4 w-4 transition-transform duration-200" />,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.AccordionItem value={href}>
|
||||||
|
<Accordion.Header asChild>
|
||||||
|
<div className="relative flex">
|
||||||
|
<ToolLink
|
||||||
|
className="flex-1"
|
||||||
|
{...{ Icon, href, onClick }}
|
||||||
|
shortTitle={title}
|
||||||
|
highlight={
|
||||||
|
pathname === href
|
||||||
|
? "both"
|
||||||
|
: !isOpend && pathname.startsWith(`${href}/`)
|
||||||
|
? "indicatorOnly"
|
||||||
|
: "none"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Accordion.Trigger
|
||||||
|
ref={triggerRef}
|
||||||
|
className="absolute right-0 flex h-10 w-10 items-center justify-center rounded transition-all duration-0 hover:bg-accent [&[data-state=open]>svg]:rotate-180"
|
||||||
|
aria-label="toggle open/close state of the tool group"
|
||||||
|
>
|
||||||
|
{chevronIcon}
|
||||||
|
</Accordion.Trigger>
|
||||||
|
</div>
|
||||||
|
</Accordion.Header>
|
||||||
|
<Accordion.AccordionContent className="overflow-hidden transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down">
|
||||||
|
<ul>
|
||||||
|
{Object.values(tools).map(tool => (
|
||||||
|
<li className="mt-1" key={tool.href}>
|
||||||
|
<ToolLink
|
||||||
|
// -outline-offset-1: ugly hack for Chrome outlines
|
||||||
|
className="pl-8 -outline-offset-1"
|
||||||
|
{...tool}
|
||||||
|
highlight={isOpend && pathname === tool.href ? "both" : "none"}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Accordion.AccordionContent>
|
||||||
|
</Accordion.AccordionItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
components/sidebar/tool-groups.tsx
Normal file
38
components/sidebar/tool-groups.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import * as Accordion from "@radix-ui/react-accordion";
|
||||||
|
|
||||||
|
import { toolGroups } from "@/config/tools";
|
||||||
|
|
||||||
|
import { ToolGroup } from "./tool-group";
|
||||||
|
|
||||||
|
const isGroupedTool = (path: string) =>
|
||||||
|
Object.values(toolGroups)
|
||||||
|
.map(({ href }) => href as string)
|
||||||
|
.some(group => path.startsWith(`${group}/`));
|
||||||
|
|
||||||
|
export function ToolGroups() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [expandedGroups, setExpandedGroups] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isGroupedTool(pathname)) {
|
||||||
|
const group = `/${pathname.split("/")[1]}`;
|
||||||
|
setExpandedGroups(prev => Array.from(new Set([...prev, group])));
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Accordion.Root type="multiple" value={expandedGroups} onValueChange={setExpandedGroups}>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{Object.values(toolGroups).map(group => (
|
||||||
|
<li key={group.href}>
|
||||||
|
<ToolGroup {...group} isOpend={expandedGroups.includes(group.href)} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Accordion.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
components/sidebar/tool-link.tsx
Normal file
38
components/sidebar/tool-link.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo, useMemo } from "react";
|
||||||
|
import Link, { LinkProps } from "next/link";
|
||||||
|
|
||||||
|
import { Tool } from "@/config/tools";
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
import { Indicator } from "@/components/indicator";
|
||||||
|
|
||||||
|
type Props = Pick<Tool, "Icon" | "shortTitle"> &
|
||||||
|
Pick<LinkProps<unknown>, "className" | "href" | "onClick"> & {
|
||||||
|
highlight: "both" | "indicatorOnly" | "none";
|
||||||
|
};
|
||||||
|
|
||||||
|
function RawToolLink({ Icon, shortTitle: title, href, onClick, className, highlight }: Props) {
|
||||||
|
const icon = useMemo(() => <Icon size={16} />, [Icon]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 items-center gap-3 rounded hover:bg-accent",
|
||||||
|
highlight === "both" && "bg-accent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...{ href, onClick }}
|
||||||
|
>
|
||||||
|
<span className={cn("invisible flex items-center", highlight !== "none" && "visible")}>
|
||||||
|
<Indicator />
|
||||||
|
</span>
|
||||||
|
<span className="flex select-none items-center">
|
||||||
|
{icon}
|
||||||
|
<span className="ml-4">{title}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolLink = memo(RawToolLink);
|
||||||
42
components/site-header.tsx
Normal file
42
components/site-header.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { siteConfig } from "@/config/site";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
|
|
||||||
|
export function SiteHeader() {
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 items-center justify-between px-4">
|
||||||
|
<div className="flex items-baseline space-x-2.5">
|
||||||
|
<Link className="text-lg" href="/">
|
||||||
|
{siteConfig.name}
|
||||||
|
</Link>
|
||||||
|
<small className="text-xs">
|
||||||
|
web clone of{" "}
|
||||||
|
<a
|
||||||
|
className="text-link hover:underline"
|
||||||
|
href={siteConfig.links.devtoys}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
DevToys
|
||||||
|
</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<a
|
||||||
|
className="group rounded-md p-2"
|
||||||
|
href={siteConfig.links.github}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<icons.GitHub className="h-6 w-6 group-hover:opacity-70" />
|
||||||
|
<span className="sr-only">GitHub</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
components/tailwind-indicator.tsx
Normal file
14
components/tailwind-indicator.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export function TailwindIndicator() {
|
||||||
|
if (process.env.NODE_ENV === "production") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-gray-800 p-3 font-mono text-xs text-white">
|
||||||
|
<div className="block sm:hidden">xs</div>
|
||||||
|
<div className="hidden sm:block md:hidden lg:hidden xl:hidden 2xl:hidden">sm</div>
|
||||||
|
<div className="hidden md:block lg:hidden xl:hidden 2xl:hidden">md</div>
|
||||||
|
<div className="hidden lg:block xl:hidden 2xl:hidden">lg</div>
|
||||||
|
<div className="hidden xl:block 2xl:hidden">xl</div>
|
||||||
|
<div className="hidden 2xl:block">2xl</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
components/theme-provider.tsx
Normal file
8
components/theme-provider.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
import { ThemeProviderProps } from "next-themes/dist/types";
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
36
components/theme-toggle.tsx
Normal file
36
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const sunIcon = useMemo(
|
||||||
|
() => (
|
||||||
|
<icons.Sun className="h-7 w-7 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const moonIcon = useMemo(
|
||||||
|
() => (
|
||||||
|
<icons.Moon className="absolute h-7 w-7 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="h-10 w-10 p-0"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setTheme(resolvedTheme === "light" ? "dark" : "light")}
|
||||||
|
>
|
||||||
|
{sunIcon}
|
||||||
|
{moonIcon}
|
||||||
|
<span className="sr-only">Toggle theme</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
components/tool-card.tsx
Normal file
21
components/tool-card.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { Tool } from "@/config/tools";
|
||||||
|
|
||||||
|
export type ToolCardProps = Pick<Tool, "Icon" | "longTitle" | "description" | "href">;
|
||||||
|
|
||||||
|
export function ToolCard({ Icon, longTitle, description, href }: ToolCardProps) {
|
||||||
|
return (
|
||||||
|
<Link className="rounded" {...{ href }}>
|
||||||
|
<div className="group flex h-80 w-44 flex-col items-center overflow-hidden rounded border bg-card p-5 pt-0 text-card-foreground hover:bg-card-hover">
|
||||||
|
<div className="flex h-44 shrink-0 items-center">
|
||||||
|
<div className="rounded bg-card-icon p-4 group-hover:bg-card-icon-hover">
|
||||||
|
<Icon size={64} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="w-full font-semibold">{longTitle}</h2>
|
||||||
|
<p className="mt-1.5 w-full text-xs text-card-muted-foreground">{description}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
components/tool-cards.tsx
Normal file
17
components/tool-cards.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { ToolCard, ToolCardProps } from "@/components/tool-card";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tools: readonly ToolCardProps[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToolCards({ tools }: Props) {
|
||||||
|
return (
|
||||||
|
<ul className="flex flex-wrap gap-x-4 gap-y-8">
|
||||||
|
{tools.map(tool => (
|
||||||
|
<li key={tool.href}>
|
||||||
|
<ToolCard {...tool} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
components/ui/button.tsx
Normal file
39
components/ui/button.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { VariantProps, cva } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
|
||||||
|
export const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center transition-colors disabled:opacity-50 disabled:pointer-events-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary-hover",
|
||||||
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary-hover",
|
||||||
|
ghost: "hover:bg-accent",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 rounded-md py-2 px-3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||||
|
VariantProps<typeof buttonVariants>;
|
||||||
|
|
||||||
|
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, ...props }, ref) => (
|
||||||
|
<button
|
||||||
|
{...{ ref }}
|
||||||
|
className={cn(buttonVariants({ variant, size }), className)}
|
||||||
|
type="button"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Button.displayName = "Button";
|
||||||
39
components/ui/editor.tsx
Normal file
39
components/ui/editor.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import MonacoEditor from "@monaco-editor/react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
export type EditorProps = React.ComponentPropsWithoutRef<typeof MonacoEditor>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE: This component maybe doesn't shrink according to the container component's width
|
||||||
|
*
|
||||||
|
* @see https://github.com/suren-atoyan/monaco-react/issues/346
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export const Editor = React.forwardRef<HTMLTextAreaElement, EditorProps>(
|
||||||
|
({ options, theme, ...props }, ref) => {
|
||||||
|
const { theme: appTheme } = useTheme();
|
||||||
|
const themeToUse = theme ?? (appTheme === "light" ? "light" : "vs-dark");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MonacoEditor
|
||||||
|
{...{ ref }}
|
||||||
|
theme={themeToUse}
|
||||||
|
// FIXME: why is `options` any?
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
options={{
|
||||||
|
tabFocusMode: true,
|
||||||
|
detectIndentation: false,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
automaticLayout: true,
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
...options, // NOTE: merge shallowly
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Editor.displayName = "Editor";
|
||||||
20
components/ui/input.tsx
Normal file
20
components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
{...{ ref }}
|
||||||
|
className={cn(
|
||||||
|
"border-b-1 flex h-9 w-full rounded border border-b-muted-foreground bg-input px-3 py-2 font-mono outline-none placeholder:text-muted-foreground hover:bg-input-hover focus:border-b-2 focus:border-b-indicator focus:bg-input-focus focus:pb-[7px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
spellCheck="false"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Input.displayName = "Input";
|
||||||
21
components/ui/label.tsx
Normal file
21
components/ui/label.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
|
||||||
|
export const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
{...{ ref }}
|
||||||
|
className={cn(
|
||||||
|
"leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
108
components/ui/select.tsx
Normal file
108
components/ui/select.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
import { icons } from "@/components/icons";
|
||||||
|
import { Indicator } from "@/components/indicator";
|
||||||
|
|
||||||
|
export type SelectProps = React.ComponentPropsWithoutRef<typeof Select>;
|
||||||
|
|
||||||
|
export const Select = SelectPrimitive.Root;
|
||||||
|
export const SelectGroup = SelectPrimitive.Group;
|
||||||
|
export const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
export const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
{...{ ref }}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between rounded-md border bg-select px-2.5 py-1.5 placeholder:text-muted-foreground hover:bg-select-hover disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<icons.ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
export const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
{...{ ref, position }}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 overflow-hidden rounded-md border bg-select-content text-select-content-foreground shadow-md animate-in fade-in-80",
|
||||||
|
position === "popper" && "translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
{...{ ref }}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
export const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
{...{ ref }}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm px-2.5 py-1.5 outline-none hover:bg-select-item-hover data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-0">
|
||||||
|
<SelectPrimitive.ItemIndicator className="flex items-center">
|
||||||
|
<Indicator />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
export const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
{...{ ref }}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
22
components/ui/separator.tsx
Normal file
22
components/ui/separator.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
|
||||||
|
export const Separator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||||
|
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
{...{ ref, decorative, orientation }}
|
||||||
|
className={cn(
|
||||||
|
"bg-separator",
|
||||||
|
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||||
28
components/ui/switch.tsx
Normal file
28
components/ui/switch.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
|
||||||
|
export type SwitchProps = React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>;
|
||||||
|
|
||||||
|
export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, SwitchProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
{...{ ref }}
|
||||||
|
className={cn(
|
||||||
|
"group inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border border-muted-foreground bg-switch hover:bg-switch-hover disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-switch data-[state=checked]:border-transparent data-[state=checked]:bg-indicator data-[state=checked]:hover:bg-indicator-hover data-[state=checked]:disabled:hover:bg-indicator",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-3.5 w-3.5 rounded-full bg-foreground/80 shadow-lg transition-transform group-hover:h-4 group-hover:w-4 group-disabled:h-3.5 group-disabled:w-3.5 data-[state=checked]:translate-x-[22px] data-[state=unchecked]:translate-x-0.5 data-[state=checked]:bg-background"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
20
components/ui/textarea.tsx
Normal file
20
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
|
||||||
|
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
{...{ ref }}
|
||||||
|
className={cn(
|
||||||
|
"border-b-1 flex w-full resize-none rounded border border-b-muted-foreground bg-textarea px-3 py-2 font-mono outline-none placeholder:text-muted-foreground hover:bg-textarea-hover focus:border-b-2 focus:border-b-indicator focus:bg-textarea-focus focus:pb-[7px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
spellCheck="false"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
25
components/ui/tooltip.tsx
Normal file
25
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/style";
|
||||||
|
|
||||||
|
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||||
|
export const Tooltip = TooltipPrimitive.Root;
|
||||||
|
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
export const TooltipContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
{...{ ref, sideOffset }}
|
||||||
|
className={cn(
|
||||||
|
"z-50 overflow-hidden rounded-md border bg-tooltip px-3 py-1.5 text-tooltip-foreground shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
8
config/site.ts
Normal file
8
config/site.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export const siteConfig = {
|
||||||
|
name: "DevToysWeb",
|
||||||
|
description: "A web clone of DevToys",
|
||||||
|
links: {
|
||||||
|
github: "https://github.com/rusconn/DevToysWeb",
|
||||||
|
devtoys: "https://devtoys.app",
|
||||||
|
},
|
||||||
|
};
|
||||||
149
config/tools.ts
Normal file
149
config/tools.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { ValidHref } from "@/types/route";
|
||||||
|
import { icons, type Icon } from "@/components/icons";
|
||||||
|
|
||||||
|
type ToolGroups = {
|
||||||
|
[key: string]: ToolGroup;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolGroup = {
|
||||||
|
Icon: Icon;
|
||||||
|
title: string;
|
||||||
|
href: ValidHref;
|
||||||
|
tools: { [key: string]: Tool };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Tool = {
|
||||||
|
Icon: Icon;
|
||||||
|
shortTitle: string;
|
||||||
|
longTitle: string;
|
||||||
|
description: string;
|
||||||
|
keywords: string;
|
||||||
|
href: ValidHref;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toolGroups = {
|
||||||
|
converters: {
|
||||||
|
Icon: icons.ArrowRightLeft,
|
||||||
|
title: "Converters",
|
||||||
|
href: "/converters",
|
||||||
|
tools: {
|
||||||
|
jsonYaml: {
|
||||||
|
Icon: icons.ArrowRightLeft,
|
||||||
|
shortTitle: "Json <> Yaml",
|
||||||
|
longTitle: "Json <> Yaml Converter",
|
||||||
|
description: "Convert Json data to Yaml and vice versa",
|
||||||
|
keywords: "json yaml converter",
|
||||||
|
href: "/converters/json-yaml",
|
||||||
|
},
|
||||||
|
numberBase: {
|
||||||
|
Icon: icons.Hash,
|
||||||
|
shortTitle: "Number Base",
|
||||||
|
longTitle: "Number Base Converter",
|
||||||
|
description: "Convert numbers from one base to another",
|
||||||
|
keywords: "number base converter",
|
||||||
|
href: "/converters/number-base",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
encodersDecoders: {
|
||||||
|
Icon: icons.Binary,
|
||||||
|
title: "Encoders / Decoders",
|
||||||
|
href: "/encoders-decoders",
|
||||||
|
tools: {
|
||||||
|
html: {
|
||||||
|
Icon: icons.Code,
|
||||||
|
shortTitle: "HTML",
|
||||||
|
longTitle: "HTML Encoder / Decoder",
|
||||||
|
description:
|
||||||
|
"Encode or decode all the applicable characters to their corresponding HTML entities",
|
||||||
|
keywords: "html encoder escaper decocder unescaper",
|
||||||
|
href: "/encoders-decoders/html",
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
Icon: icons.Link,
|
||||||
|
shortTitle: "URL",
|
||||||
|
longTitle: "URL Encoder / Decoder",
|
||||||
|
description:
|
||||||
|
"Encode or decode all the applicable characters to their corresponding URL entities",
|
||||||
|
keywords: "url encoder escaper decocder unescaper",
|
||||||
|
href: "/encoders-decoders/url",
|
||||||
|
},
|
||||||
|
base64: {
|
||||||
|
Icon: icons.Equal,
|
||||||
|
shortTitle: "Base 64",
|
||||||
|
longTitle: "Base 64 Encoder / Decoder",
|
||||||
|
description: "Encode and decode Base64 data",
|
||||||
|
keywords: "base64 encoder decocder",
|
||||||
|
href: "/encoders-decoders/base64",
|
||||||
|
},
|
||||||
|
jwt: {
|
||||||
|
Icon: icons.Key,
|
||||||
|
shortTitle: "JWT",
|
||||||
|
longTitle: "JWT Decoder",
|
||||||
|
description: "Decode a JWT header, payload and signature",
|
||||||
|
keywords: "jwt json web token decocder",
|
||||||
|
href: "/encoders-decoders/jwt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
formatters: {
|
||||||
|
Icon: icons.AlignLeft,
|
||||||
|
title: "Formatters",
|
||||||
|
href: "/formatters",
|
||||||
|
tools: {
|
||||||
|
json: {
|
||||||
|
Icon: icons.Braces,
|
||||||
|
shortTitle: "Json",
|
||||||
|
longTitle: "JSON Formatter",
|
||||||
|
description: "Indent or minify JSON data",
|
||||||
|
keywords: "json formatter",
|
||||||
|
href: "/formatters/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
generators: {
|
||||||
|
Icon: icons.PackagePlus,
|
||||||
|
title: "Generators",
|
||||||
|
href: "/generators",
|
||||||
|
tools: {
|
||||||
|
hash: {
|
||||||
|
Icon: icons.Fingerprint,
|
||||||
|
shortTitle: "Hash",
|
||||||
|
longTitle: "Hash Generator",
|
||||||
|
description: "Calculate MD5, SHA1, SHA256 and SHA512 hash from text data",
|
||||||
|
keywords: "hash generator md5 sha1 sha256 sha512",
|
||||||
|
href: "/generators/hash",
|
||||||
|
},
|
||||||
|
uuid: {
|
||||||
|
Icon: icons.Hash,
|
||||||
|
shortTitle: "UUID",
|
||||||
|
longTitle: "UUID Generator",
|
||||||
|
description: "Generate UUIDs version 1 and 4",
|
||||||
|
keywords: "guid uuid1 uuid4 generator",
|
||||||
|
href: "/generators/uuid",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const satisfies ToolGroups;
|
||||||
|
|
||||||
|
export const singleTools = {
|
||||||
|
allTools: {
|
||||||
|
Icon: icons.Home,
|
||||||
|
shortTitle: "All tools",
|
||||||
|
longTitle: "All tools",
|
||||||
|
href: "/",
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
Icon: icons.Settings,
|
||||||
|
shortTitle: "Settings",
|
||||||
|
longTitle: "Settings",
|
||||||
|
description: "Customize DevToysWeb look & feel",
|
||||||
|
keywords: "settings customization configurations devtoysweb look&feel",
|
||||||
|
href: "/settings",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const homeTools: Tool[] = [
|
||||||
|
...Object.values(toolGroups).flatMap(({ tools }) => Object.values<Tool>(tools)),
|
||||||
|
singleTools.settings,
|
||||||
|
];
|
||||||
23
hooks/useScrollFollow.ts
Normal file
23
hooks/useScrollFollow.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { DependencyList, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export const useScrollFollow = <T extends HTMLElement = HTMLElement>(
|
||||||
|
deps: DependencyList,
|
||||||
|
behavior: ScrollBehavior = "smooth"
|
||||||
|
) => {
|
||||||
|
const ref = useRef<T>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { current } = ref;
|
||||||
|
|
||||||
|
if (current) {
|
||||||
|
current.scrollTo({
|
||||||
|
left: current.scrollWidth,
|
||||||
|
top: current.scrollHeight,
|
||||||
|
behavior,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
};
|
||||||
22
lib/base.ts
Normal file
22
lib/base.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { chunksOf, intersperse } from "fp-ts/Array";
|
||||||
|
|
||||||
|
const match = (regex: RegExp) => (x: string) => regex.test(x);
|
||||||
|
|
||||||
|
export const isDecimal = match(/^[0-9]*$/);
|
||||||
|
export const isHexadecimal = match(/^[0-9A-F]*$/i);
|
||||||
|
export const isOctal = match(/^[0-7]*$/);
|
||||||
|
export const isBinary = match(/^[0-1]*$/);
|
||||||
|
|
||||||
|
const formatNumber = (digits: number, sep: string) => (s: string) => {
|
||||||
|
const a = [...s].reverse();
|
||||||
|
const b = chunksOf(digits)(a);
|
||||||
|
const c = intersperse([sep])(b);
|
||||||
|
return c.flat().reverse().join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDecimal = formatNumber(3, ",");
|
||||||
|
export const formatHexadecimal = formatNumber(4, " ");
|
||||||
|
export const formatOctal = formatNumber(3, " ");
|
||||||
|
export const formatBinary = formatNumber(4, " ");
|
||||||
|
|
||||||
|
export const unformatNumber = (x: string) => x.replaceAll(/[ ,]/g, "");
|
||||||
11
lib/fonts.ts
Normal file
11
lib/fonts.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { JetBrains_Mono as FontMono, Inter as FontSans } from "next/font/google";
|
||||||
|
|
||||||
|
export const fontSans = FontSans({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-sans",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fontMono = FontMono({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-mono",
|
||||||
|
});
|
||||||
6
lib/style.ts
Normal file
6
lib/style.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { v1 as uuidv1, v4 as uuidv4 } from "uuid";
|
import { v1 as uuidv1, v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
export const uuid = (version: 1 | 4, hyphens: boolean, uppercase: boolean) => {
|
const uuidvn = {
|
||||||
let generated = version === 1 ? uuidv1() : uuidv4();
|
1: uuidv1,
|
||||||
|
4: uuidv4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uuid = (version: 1 | 4 | "1" | "4", hyphens = true, uppercase = true) => {
|
||||||
|
let generated = uuidvn[version]();
|
||||||
|
|
||||||
if (!hyphens) {
|
if (!hyphens) {
|
||||||
generated = generated.replaceAll("-", "");
|
generated = generated.replaceAll("-", "");
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
experimental: {
|
||||||
|
typedRoutes: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
export default nextConfig;
|
||||||
92
package.json
92
package.json
@@ -5,57 +5,67 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"fix": "run-s fix:*",
|
"ci": "yarn typecheck && yarn lint && yarn format:check",
|
||||||
"fix:path": "yarn path",
|
"fix": "yarn lint:fix && yarn format:write",
|
||||||
"fix:type": "yarn typecheck",
|
"lint": "next lint",
|
||||||
"fix:lint": "yarn lint --fix",
|
"lint:fix": "next lint --fix",
|
||||||
"fix:format": "yarn format",
|
"preview": "next build && next start",
|
||||||
"path": "pathpida -o ./src/libs --ignorePath .gitignore --enableStatic",
|
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint src --cache",
|
"format:write": "yarn format --write",
|
||||||
"format": "prettier --write './**/*.{js,jsx,ts,tsx,json,css}'"
|
"format:check": "yarn format --check",
|
||||||
|
"format": "prettier \"**/*.{js,jsx,ts,tsx,css,json,md}\" --cache"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/cache": "^11.7.1",
|
"@monaco-editor/react": "^4.5.1",
|
||||||
"@emotion/react": "^11.8.2",
|
"@radix-ui/react-accordion": "^1.1.2",
|
||||||
"@emotion/server": "^11.4.0",
|
"@radix-ui/react-label": "^2.0.2",
|
||||||
"@emotion/styled": "^11.8.1",
|
"@radix-ui/react-select": "^1.2.2",
|
||||||
"@fontsource/roboto": "^4.5.3",
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
"@mui/icons-material": "^5.5.1",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@mui/material": "^5.5.1",
|
"@radix-ui/react-tooltip": "^1.0.6",
|
||||||
"ace-builds": "^1.4.14",
|
"class-variance-authority": "^0.6.0",
|
||||||
|
"clsx": "^1.2.1",
|
||||||
|
"cmdk": "^0.2.0",
|
||||||
"create-hash": "^1.2.0",
|
"create-hash": "^1.2.0",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fp-ts": "^2.11.9",
|
"fp-ts": "^2.16.0",
|
||||||
"fuse.js": "^6.5.3",
|
"fuse.js": "^6.6.2",
|
||||||
"html-entities": "^2.3.3",
|
"html-entities": "^2.3.3",
|
||||||
"js-base64": "^3.7.2",
|
"io-ts": "^2.2.20",
|
||||||
|
"js-base64": "^3.7.5",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"next": "12.1.0",
|
"lucide-react": "^0.221.0",
|
||||||
"react": "17.0.2",
|
"next": "13.4.4",
|
||||||
"react-ace": "^9.5.0",
|
"next-themes": "^0.2.1",
|
||||||
"react-dom": "17.0.2",
|
"react": "18.2.0",
|
||||||
"recoil": "^0.6.1",
|
"react-day-picker": "^8.7.1",
|
||||||
"uuid": "^8.3.2",
|
"react-dom": "18.2.0",
|
||||||
"yaml": "^1.10.2"
|
"sharp": "^0.32.1",
|
||||||
|
"tailwind-merge": "^1.12.0",
|
||||||
|
"tailwindcss-animate": "^1.0.5",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"yaml": "^2.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@emotion/babel-plugin": "^11.7.2",
|
"@ianvs/prettier-plugin-sort-imports": "^3.7.2",
|
||||||
"@types/create-hash": "^1.2.2",
|
"@types/create-hash": "^1.2.2",
|
||||||
"@types/node": "^16.11.45",
|
"@types/node": "~16.18.33",
|
||||||
"@types/react": "17.0.41",
|
"@types/react": "~18.2.7",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/react-dom": "~18.2.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.15.0",
|
"@types/uuid": "^9.0.1",
|
||||||
"@typescript-eslint/parser": "^5.15.0",
|
"@typescript-eslint/eslint-plugin": "^5.59.7",
|
||||||
"babel-plugin-import": "^1.13.3",
|
"@typescript-eslint/parser": "^5.59.7",
|
||||||
"eslint": "8.11.0",
|
"autoprefixer": "^10.4.14",
|
||||||
|
"eslint": "^8.41.0",
|
||||||
"eslint-config-airbnb": "^19.0.4",
|
"eslint-config-airbnb": "^19.0.4",
|
||||||
"eslint-config-airbnb-typescript": "^16.1.4",
|
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||||
"eslint-config-next": "12.1.0",
|
"eslint-config-next": "~13.4.4",
|
||||||
"eslint-config-prettier": "^8.5.0",
|
"eslint-config-prettier": "^8.8.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
"pathpida": "^0.18.0",
|
"eslint-plugin-tailwindcss": "^3.12.0",
|
||||||
"prettier": "^2.6.0",
|
"postcss": "^8.4.23",
|
||||||
"typescript": "4.6.2"
|
"prettier": "^2.8.8",
|
||||||
|
"tailwindcss": "^3.3.2",
|
||||||
|
"typescript": "^5.0.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
29
prettier.config.js
Normal file
29
prettier.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/** @type {import("@ianvs/prettier-plugin-sort-imports").PrettierConfig} */
|
||||||
|
module.exports = {
|
||||||
|
printWidth: 100,
|
||||||
|
arrowParens: "avoid",
|
||||||
|
importOrder: [
|
||||||
|
"^(react/(.*)$)|^(react$)",
|
||||||
|
"^(next/(.*)$)|^(next$)",
|
||||||
|
"<THIRD_PARTY_MODULES>",
|
||||||
|
"",
|
||||||
|
"^types$",
|
||||||
|
"^@/types/(.*)$",
|
||||||
|
"^@/config/(.*)$",
|
||||||
|
"^@/lib/(.*)$",
|
||||||
|
"^@/hooks/(.*)$",
|
||||||
|
"^@/components/ui/(.*)$",
|
||||||
|
"^@/components/(.*)$",
|
||||||
|
"^@/styles/(.*)$",
|
||||||
|
"^@/app/(.*)$",
|
||||||
|
"",
|
||||||
|
"^[./]",
|
||||||
|
],
|
||||||
|
importOrderSeparation: false,
|
||||||
|
importOrderSortSpecifiers: true,
|
||||||
|
importOrderBuiltinModulesToTop: true,
|
||||||
|
importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"],
|
||||||
|
importOrderMergeDuplicateImports: true,
|
||||||
|
importOrderCombineTypeAndValueImports: true,
|
||||||
|
plugins: ["@ianvs/prettier-plugin-sort-imports"],
|
||||||
|
};
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,37 +0,0 @@
|
|||||||
import { css, Theme } from "@mui/material/styles";
|
|
||||||
import { config } from "ace-builds";
|
|
||||||
import { memo } from "react";
|
|
||||||
import AceEditor, { IAceEditorProps } from "react-ace";
|
|
||||||
|
|
||||||
// https://github.com/securingsincity/react-ace/issues/725
|
|
||||||
config.set("basePath", "https://cdn.jsdelivr.net/npm/ace-builds@1.4.14/src-min-noconflict/");
|
|
||||||
config.setModuleUrl(
|
|
||||||
"ace/mode/javascript_worker",
|
|
||||||
"https://cdn.jsdelivr.net/npm/ace-builds@1.4.14/src-min-noconflict/worker-javascript.js"
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Props = IAceEditorProps;
|
|
||||||
|
|
||||||
const editor = (theme: Theme) => css`
|
|
||||||
box-shadow: ${theme.shadows[1]};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledComponent = (props: Props) => (
|
|
||||||
<AceEditor
|
|
||||||
wrapEnabled
|
|
||||||
width="auto"
|
|
||||||
height="100%"
|
|
||||||
theme="textmate"
|
|
||||||
showPrintMargin={false}
|
|
||||||
highlightActiveLine={false}
|
|
||||||
editorProps={{ $blockScrolling: true }}
|
|
||||||
setOptions={{ mergeUndoDeltas: false }}
|
|
||||||
css={editor}
|
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Component = memo(StyledComponent);
|
|
||||||
|
|
||||||
export default Component;
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { Box, Paper, Stack, Typography } from "@mui/material";
|
|
||||||
import { css, Theme } from "@mui/material/styles";
|
|
||||||
import { memo, ReactNode } from "react";
|
|
||||||
|
|
||||||
export type Props = {
|
|
||||||
icon: ReactNode;
|
|
||||||
title: string;
|
|
||||||
input: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
const paper = (theme: Theme) => css`
|
|
||||||
padding: ${theme.spacing(2)};
|
|
||||||
height: ${theme.spacing(8)};
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledComponent = ({ icon, title, input }: Props) => (
|
|
||||||
<Paper css={paper}>
|
|
||||||
<Stack direction="row" alignItems="center" height={theme => theme.spacing(4)}>
|
|
||||||
<Stack direction="row" spacing={2}>
|
|
||||||
{icon}
|
|
||||||
<Typography>{title}</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Box marginLeft="auto">{input}</Box>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Component = memo(StyledComponent);
|
|
||||||
|
|
||||||
export default Component;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Stack } from "@mui/material";
|
|
||||||
import equal from "fast-deep-equal";
|
|
||||||
import { memo } from "react";
|
|
||||||
|
|
||||||
import Configuration, { Props as ConfigurationProps } from "./Configuration";
|
|
||||||
|
|
||||||
export type Props = {
|
|
||||||
configurations: ConfigurationProps[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const StyledComponent = ({ configurations }: Props) => (
|
|
||||||
<Stack spacing={1}>
|
|
||||||
{configurations.map(({ icon, title, input }) => (
|
|
||||||
<Configuration key={title} {...{ icon, title, input }} />
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const Component = memo(StyledComponent, equal);
|
|
||||||
|
|
||||||
export default Component;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user