diff --git a/src/core/registry/tool.registry.ts b/src/core/registry/tool.registry.ts index 2aef213..996e3e4 100644 --- a/src/core/registry/tool.registry.ts +++ b/src/core/registry/tool.registry.ts @@ -107,6 +107,17 @@ export const toolRegistry: ToolDefinition[] = [ route: "/tools/url-encoder", status: "available", }, + { + id: "jwt-secret", + name: "JWT Secret Generator", + category: "Crypto", + description: "Generate secure HS256, HS384, and HS512 JWT secrets locally.", + keywords: ["jwt", "secret", "hs256", "hs512", "hmac"], + icon: ShieldCheck, + persist: false, + route: "/tools/jwt-secret", + status: "available", + }, { id: "hash-generator", name: "Hash Generator", @@ -129,17 +140,6 @@ export const toolRegistry: ToolDefinition[] = [ route: "/tools/password-generator", status: "available", }, - { - id: "jwt-secret", - name: "JWT Secret Generator", - category: "Crypto", - description: "Generate secure HS256, HS384, and HS512 JWT secrets locally.", - keywords: ["jwt", "secret", "hs256", "hs512", "hmac"], - icon: ShieldCheck, - persist: false, - route: "/tools/jwt-secret", - status: "available", - }, { id: "uuid", name: "UUID Generator", diff --git a/src/features/case-converter/CaseConverterPage.tsx b/src/features/case-converter/CaseConverterPage.tsx index 792992d..24fabbe 100644 --- a/src/features/case-converter/CaseConverterPage.tsx +++ b/src/features/case-converter/CaseConverterPage.tsx @@ -1,60 +1,118 @@ import type { JSX } from "react"; import { useMemo, useState } from "react"; -import { RotateCcw } from "lucide-react"; +import { Copy, Download, RotateCcw, TextCursorInput } from "lucide-react"; import { Button } from "@/shared/ui/button"; import { - ResultRow, + PaneHeader, ToolSurface, ToolTextarea, ToolToolbar, - ToolTitle, } from "@/shared/components/ToolSurface"; import { convertCases } from "./case-converter.service"; +const sampleInput = `Forge developer workstation +markdownPreview renderer +JWT secret generator`; + export function CaseConverterPage(): JSX.Element { - const [input, setInput] = useState("Forge developer workstation"); + const [input, setInput] = useState(sampleInput); const variants = useMemo(() => convertCases(input), [input]); + async function copy(value: string): Promise { + await navigator.clipboard.writeText(value); + } + + function download(): void { + const blob = new Blob( + [variants.map((variant) => `${variant.label}: ${variant.value}`).join("\n")], + { type: "text/plain;charset=utf-8" }, + ); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + + anchor.href = url; + anchor.download = "forge-case-conversions.txt"; + anchor.click(); + URL.revokeObjectURL(url); + } + return ( - -

- Convert text into common naming conventions. -

- +
+
} right={ - + <> + + + + } /> -
- -
-
- {variants.map((variant) => ( - void navigator.clipboard.writeText(variant.value)} - value={variant.value} - /> - ))} +
+
+ + {input.length.toLocaleString()} chars + + } + title="Input" + tone="blue" + /> + +
+
+ +
+
+ {variants.map((variant) => ( +
+
+

+ {variant.label} +

+ +
+

+ {variant.value || "-"} +

+
+ ))} +
diff --git a/src/features/case-converter/case-converter.service.ts b/src/features/case-converter/case-converter.service.ts index ce358c7..8f9e59d 100644 --- a/src/features/case-converter/case-converter.service.ts +++ b/src/features/case-converter/case-converter.service.ts @@ -17,13 +17,18 @@ export function hasCaseConverterInput(input: string): boolean { export function convertCases(input: string): CaseVariant[] { const words = splitWords(input); + const lower = words.join(" "); return [ { label: "camelCase", value: toCamelCase(words) }, { label: "PascalCase", value: toPascalCase(words) }, { label: "snake_case", value: words.join("_") }, { label: "kebab-case", value: words.join("-") }, + { label: "dot.case", value: words.join(".") }, + { label: "path/case", value: words.join("/") }, { label: "CONSTANT_CASE", value: words.join("_").toUpperCase() }, + { label: "lowercase", value: lower }, + { label: "UPPERCASE", value: lower.toUpperCase() }, { label: "Title Case", value: words.map(capitalize).join(" ") }, { label: "Sentence case", value: toSentenceCase(words) }, ]; diff --git a/src/features/password-generator/PasswordGeneratorPage.tsx b/src/features/password-generator/PasswordGeneratorPage.tsx index df3ee19..9b490f9 100644 --- a/src/features/password-generator/PasswordGeneratorPage.tsx +++ b/src/features/password-generator/PasswordGeneratorPage.tsx @@ -8,6 +8,8 @@ import { RefreshCw, SlidersHorizontal, TextCursorInput, + Eye, + EyeOff, } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -410,17 +412,33 @@ function SecretOutput({ onCopy: () => void; result: GeneratedSecret; }): JSX.Element { + const [visible, setVisible] = useState(false); + return (
-
+

- {result.value} + {visible ? result.value : maskSecret(result.value)}

- +
+ + +
@@ -432,6 +450,14 @@ function SecretOutput({ ); } +function maskSecret(secret: string): string { + if (secret.length <= 12) { + return "*".repeat(secret.length); + } + + return `${secret.slice(0, 6)}${"*".repeat(Math.max(12, secret.length - 12))}${secret.slice(-6)}`; +} + function Metric({ label, value }: { label: string; value: string }): JSX.Element { return (
diff --git a/src/features/regex-tester/RegexTesterPage.tsx b/src/features/regex-tester/RegexTesterPage.tsx index 4faa40d..6937710 100644 --- a/src/features/regex-tester/RegexTesterPage.tsx +++ b/src/features/regex-tester/RegexTesterPage.tsx @@ -1,25 +1,28 @@ import type { JSX } from "react"; import { useMemo, useState } from "react"; -import { RotateCcw } from "lucide-react"; +import { Copy, RotateCcw, Sparkles } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { - ToolOutput, + PaneHeader, ToolSurface, ToolTextarea, ToolToolbar, - ToolTitle, } from "@/shared/components/ToolSurface"; import { testRegex } from "./regex-tester.service"; +const sampleText = `Forge includes Markdown Preview, HTML Preview, JSON Formatter, and Regex Tester. +Contact dev@forge.local or security@forge.local for internal tool feedback. +Issue IDs: FORGE-1024, FORGE-2048, and FORGE-4096.`; + export function RegexTesterPage(): JSX.Element { - const [pattern, setPattern] = useState("\\bForge\\b"); + const [pattern, setPattern] = useState("([A-Z]+)-(\\d+)"); const [flags, setFlags] = useState("gi"); - const [sample, setSample] = useState( - "Forge includes Markdown Preview, HTML Preview, and more Forge tools.", - ); + const [replacement, setReplacement] = useState("$1#$2"); + const [sample, setSample] = useState(sampleText); const result = useMemo( - () => testRegex(pattern, flags, sample), - [flags, pattern, sample], + () => testRegex(pattern, flags, sample, replacement), + [flags, pattern, replacement, sample], ); function toggleFlag(flag: string): void { @@ -28,33 +31,46 @@ export function RegexTesterPage(): JSX.Element { ); } + async function copy(value: string): Promise { + await navigator.clipboard.writeText(value); + } + return ( - -
+
+                    {result.highlighted.map((segment, index) => (
+                      
+                        {segment.text}
+                      
+                    ))}
+                  
+ + +
+
+

Replacement

+ +
+

+ {result.replaced || "-"}

- {match.groups.length > 0 ? ( -

- Groups: {match.groups.join(", ")} +

+ +
+ {result.matches.length === 0 ? ( +

+ No matches.

) : null} + {result.matches.map((match, index) => ( +
+
+

+ Match {index + 1} +

+ + {match.index}-{match.end} + +
+

+ {match.match || "(empty match)"} +

+ {match.groups.length > 0 ? ( +
+ {match.groups.map((group, groupIndex) => ( +
+

+ Group {groupIndex + 1} +

+

+ {group || "(empty)"} +

+
+ ))} +
+ ) : null} +
+ ))}
- ))} -
- +
+ )} +
diff --git a/src/features/regex-tester/regex-tester.service.test.ts b/src/features/regex-tester/regex-tester.service.test.ts index 5cb7c07..c9c8c65 100644 --- a/src/features/regex-tester/regex-tester.service.test.ts +++ b/src/features/regex-tester/regex-tester.service.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { hasRegexTesterInput, normalizeRegexTesterInput } from "./regex-tester.service"; +import { + hasRegexTesterInput, + normalizeRegexTesterInput, + testRegex, +} from "./regex-tester.service"; describe("regex-tester service", () => { it("normalizes user input", () => { @@ -9,4 +13,11 @@ describe("regex-tester service", () => { it("detects empty input", () => { expect(hasRegexTesterInput(" ")).toBe(false); }); + + it("returns matches, groups, and replacement output", () => { + const result = testRegex("([A-Z]+)-(\\d+)", "g", "FORGE-1024", "$1#$2"); + + expect(result.matches[0]?.groups).toEqual(["FORGE", "1024"]); + expect(result.replaced).toBe("FORGE#1024"); + }); }); diff --git a/src/features/regex-tester/regex-tester.service.ts b/src/features/regex-tester/regex-tester.service.ts index d3e10d7..a6901a3 100644 --- a/src/features/regex-tester/regex-tester.service.ts +++ b/src/features/regex-tester/regex-tester.service.ts @@ -3,6 +3,7 @@ export interface RegexTesterInput { } export interface RegexMatch { + end: number; groups: string[]; index: number; match: string; @@ -10,7 +11,9 @@ export interface RegexMatch { export interface RegexTestResult { error?: string; + highlighted: Array<{ match: boolean; text: string }>; matches: RegexMatch[]; + replaced: string; } export function normalizeRegexTesterInput(input: string): string { @@ -25,9 +28,14 @@ export function testRegex( pattern: string, flags: string, sample: string, + replacement = "", ): RegexTestResult { if (!pattern) { - return { matches: [] }; + return { + highlighted: [{ match: false, text: sample }], + matches: [], + replaced: sample, + }; } try { @@ -38,9 +46,11 @@ export function testRegex( const matches: RegexMatch[] = []; for (const match of sample.matchAll(regex)) { + const index = match.index ?? 0; matches.push({ + end: index + match[0].length, groups: match.slice(1), - index: match.index ?? 0, + index, match: match[0], }); @@ -49,11 +59,44 @@ export function testRegex( } } - return { matches }; + return { + highlighted: createHighlights(sample, matches), + matches, + replaced: replacement ? sample.replace(regex, replacement) : sample, + }; } catch (error) { return { error: error instanceof Error ? error.message : "Invalid regular expression.", + highlighted: [{ match: false, text: sample }], matches: [], + replaced: sample, }; } } + +function createHighlights( + sample: string, + matches: RegexMatch[], +): Array<{ match: boolean; text: string }> { + if (matches.length === 0) { + return [{ match: false, text: sample }]; + } + + const segments: Array<{ match: boolean; text: string }> = []; + let cursor = 0; + + for (const match of matches) { + if (match.index > cursor) { + segments.push({ match: false, text: sample.slice(cursor, match.index) }); + } + + segments.push({ match: true, text: sample.slice(match.index, match.end) }); + cursor = Math.max(cursor, match.end); + } + + if (cursor < sample.length) { + segments.push({ match: false, text: sample.slice(cursor) }); + } + + return segments; +} diff --git a/src/features/slugify/SlugifyPage.tsx b/src/features/slugify/SlugifyPage.tsx index d48e9b9..772a14b 100644 --- a/src/features/slugify/SlugifyPage.tsx +++ b/src/features/slugify/SlugifyPage.tsx @@ -1,60 +1,213 @@ import type { JSX } from "react"; import { useMemo, useState } from "react"; -import { Copy, RotateCcw } from "lucide-react"; +import { Copy, Download, Link2, RotateCcw } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { - ToolOutput, + PaneHeader, ToolSurface, ToolTextarea, ToolToolbar, - ToolTitle, } from "@/shared/components/ToolSurface"; -import { createSlug } from "./slugify.service"; +import { createSlug, type SlugifyOptions } from "./slugify.service"; export function SlugifyPage(): JSX.Element { const [input, setInput] = useState("Forge Markdown Preview Design Language"); - const slug = useMemo(() => createSlug(input), [input]); + const [options, setOptions] = useState({ + lowercase: true, + maxLength: 80, + removeNumbers: false, + removeStopWords: false, + separator: "-", + }); + const slug = useMemo(() => createSlug(input, options), [input, options]); + + async function copy(value: string): Promise { + await navigator.clipboard.writeText(value); + } + + function updateOptions(next: Partial): void { + setOptions((current) => ({ ...current, ...next })); + } + + function download(): void { + const blob = new Blob([slug], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + + anchor.href = url; + anchor.download = "forge-slug.txt"; + anchor.click(); + URL.revokeObjectURL(url); + } return ( - -

- Create lowercase URL-safe slugs. -

- +
+
} right={ <> - + } /> -
- -
- +
+
+ + {input.length.toLocaleString()} chars + + } + title="Input" + tone="blue" + /> + +
+
+ + {slug.length.toLocaleString()} chars + + } + title="Slug" + tone="emerald" + /> +
+
+
+

+ {slug || "-"} +

+ +
+
+ +
+

+ Options +

+
+ {[ + { label: "Dash", value: "-" }, + { label: "Underscore", value: "_" }, + ].map((option) => ( + + ))} +
+ +
+ updateOptions({ lowercase })} + /> + updateOptions({ removeStopWords })} + /> + updateOptions({ removeNumbers })} + /> +
+
+
); } + +function CheckOption({ + checked, + label, + onChange, +}: { + checked: boolean; + label: string; + onChange: (checked: boolean) => void; +}): JSX.Element { + return ( + + ); +} diff --git a/src/features/slugify/slugify.service.test.ts b/src/features/slugify/slugify.service.test.ts index 4ff2c5c..f67b92b 100644 --- a/src/features/slugify/slugify.service.test.ts +++ b/src/features/slugify/slugify.service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { hasSlugifyInput, normalizeSlugifyInput } from "./slugify.service"; +import { createSlug, hasSlugifyInput, normalizeSlugifyInput } from "./slugify.service"; describe("slugify service", () => { it("normalizes user input", () => { @@ -9,4 +9,16 @@ describe("slugify service", () => { it("detects empty input", () => { expect(hasSlugifyInput(" ")).toBe(false); }); + + it("supports separators and stop word removal", () => { + expect( + createSlug("The Forge Preview and Tool Suite 2026", { + lowercase: true, + maxLength: 80, + removeNumbers: true, + removeStopWords: true, + separator: "_", + }), + ).toBe("forge_preview_tool_suite"); + }); }); diff --git a/src/features/slugify/slugify.service.ts b/src/features/slugify/slugify.service.ts index 9ef49ef..f1d0a69 100644 --- a/src/features/slugify/slugify.service.ts +++ b/src/features/slugify/slugify.service.ts @@ -2,14 +2,57 @@ export interface SlugifyInput { value: string; } -export function createSlug(input: string): string { - return normalizeSlugifyInput(input) +export interface SlugifyOptions { + lowercase: boolean; + maxLength: number; + removeNumbers: boolean; + removeStopWords: boolean; + separator: "-" | "_"; +} + +const stopWords = new Set([ + "a", + "an", + "and", + "are", + "as", + "at", + "be", + "by", + "for", + "from", + "in", + "is", + "of", + "on", + "or", + "the", + "to", + "with", +]); + +export function createSlug(input: string, options: Partial = {}): string { + const settings: SlugifyOptions = { + lowercase: options.lowercase ?? true, + maxLength: options.maxLength ?? 80, + removeNumbers: options.removeNumbers ?? false, + removeStopWords: options.removeStopWords ?? false, + separator: options.separator ?? "-", + }; + const normalized = normalizeSlugifyInput(input) .normalize("NFKD") .replace(/[\u0300-\u036f]/g, "") - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .replace(/-{2,}/g, "-"); + .replace(/['']/g, ""); + const cased = settings.lowercase ? normalized.toLowerCase() : normalized; + const words = cased + .replace(settings.removeNumbers ? /[^A-Za-z]+/g : /[^A-Za-z0-9]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean) + .filter((word) => !settings.removeStopWords || !stopWords.has(word.toLowerCase())); + const slug = words.join(settings.separator); + + return trimToSeparator(slug.slice(0, settings.maxLength), settings.separator); } export function normalizeSlugifyInput(input: string): string { @@ -19,3 +62,11 @@ export function normalizeSlugifyInput(input: string): string { export function hasSlugifyInput(input: string): boolean { return normalizeSlugifyInput(input).length > 0; } + +function trimToSeparator(input: string, separator: string): string { + const escaped = separator === "-" ? "\\-" : separator; + + return input + .replace(new RegExp(`${escaped}{2,}`, "g"), separator) + .replace(new RegExp(`^${escaped}+|${escaped}+$`, "g"), ""); +} diff --git a/src/features/timestamp/TimestampPage.tsx b/src/features/timestamp/TimestampPage.tsx index 3338ee4..bebf157 100644 --- a/src/features/timestamp/TimestampPage.tsx +++ b/src/features/timestamp/TimestampPage.tsx @@ -1,80 +1,180 @@ import type { JSX } from "react"; -import { useMemo, useState } from "react"; -import { Clock3, RefreshCw } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { CheckCircle2, Clock3, Copy, RefreshCw } from "lucide-react"; import { Button } from "@/shared/ui/button"; -import { ResultRow } from "@/shared/components/ToolSurface"; +import { PaneHeader, ToolSurface, ToolToolbar } from "@/shared/components/ToolSurface"; import { convertTimestampInput } from "./timestamp.service"; export function TimestampPage(): JSX.Element { const [input, setInput] = useState(""); - const conversion = useMemo(() => convertTimestampInput(input), [input]); + const [tick, setTick] = useState(Date.now()); + const conversion = useMemo(() => { + void tick; + + return convertTimestampInput(input); + }, [input, tick]); + + useEffect(() => { + const timer = window.setInterval(() => setTick(Date.now()), 1000); + + return () => window.clearInterval(timer); + }, []); + + async function copy(value: string): Promise { + await navigator.clipboard.writeText(value); + } return ( -
-
-

Timestamp Converter

-

- Utilities -

-
-
- - - -
- {conversion ? ( -
- void navigator.clipboard.writeText(String(conversion.seconds))} - value={String(conversion.seconds)} - /> - - void navigator.clipboard.writeText(String(conversion.milliseconds)) + + + +
+ } + right={ + <> + + + + + + } + /> + +
+
+ +
+ {conversion ? ( +
+ + +
+

+ Local datetime input +

+ setInput(event.target.value)} + type="datetime-local" + value={conversion.dateInput} + /> +
+
+ ) : ( + + )} +
+
+ +
+ + {conversion.relative} + + ) : null } - value={String(conversion.milliseconds)} - /> - void navigator.clipboard.writeText(conversion.iso)} - value={conversion.iso} - /> - void navigator.clipboard.writeText(conversion.utc)} - value={conversion.utc} - /> - void navigator.clipboard.writeText(conversion.local)} - value={conversion.local} + title="Converted values" + tone={conversion ? "emerald" : "rose"} /> +
+ {conversion ? ( +
+ {[ + ["ISO 8601", conversion.iso], + ["UTC", conversion.utc], + ["Local", conversion.local], + ["Unix seconds", String(conversion.seconds)], + ["Unix milliseconds", String(conversion.milliseconds)], + ["Unix microseconds", String(conversion.microseconds)], + ["Unix nanoseconds", String(conversion.nanoseconds)], + ].map(([label, value]) => ( + void copy(value)} + value={value} + /> + ))} +
+ ) : ( + + )} +
- ) : ( -
- Unable to parse that timestamp or date. -
- )} +
+ + ); +} + +function PrimaryTime({ label, value }: { label: string; value: string }): JSX.Element { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +function ResultCard({ + label, + onCopy, + value, +}: { + label: string; + onCopy: () => void; + value: string; +}): JSX.Element { + return ( +
+
+

{label}

+ +
+

+ {value} +

); } + +function ErrorPanel(): JSX.Element { + return ( +
+ Unable to parse that timestamp or date. +
+ ); +} diff --git a/src/features/timestamp/timestamp.service.test.ts b/src/features/timestamp/timestamp.service.test.ts index efdf869..72a2bec 100644 --- a/src/features/timestamp/timestamp.service.test.ts +++ b/src/features/timestamp/timestamp.service.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { hasTimestampInput, normalizeTimestampInput } from "./timestamp.service"; +import { + convertTimestampInput, + hasTimestampInput, + normalizeTimestampInput, +} from "./timestamp.service"; describe("timestamp service", () => { it("normalizes user input", () => { @@ -9,4 +13,11 @@ describe("timestamp service", () => { it("detects empty input", () => { expect(hasTimestampInput(" ")).toBe(false); }); + + it("parses millisecond timestamps", () => { + const result = convertTimestampInput("1700000000000"); + + expect(result?.seconds).toBe(1700000000); + expect(result?.microseconds).toBe(1700000000000000); + }); }); diff --git a/src/features/timestamp/timestamp.service.ts b/src/features/timestamp/timestamp.service.ts index 177006d..e9ab43a 100644 --- a/src/features/timestamp/timestamp.service.ts +++ b/src/features/timestamp/timestamp.service.ts @@ -3,9 +3,13 @@ export interface TimestampInput { } export interface TimestampConversion { + dateInput: string; iso: string; local: string; milliseconds: number; + microseconds: number; + nanoseconds: number; + relative: string; seconds: number; utc: string; } @@ -27,18 +31,69 @@ export function convertTimestampInput(input: string): TimestampConversion | null const numeric = Number(normalized); const date = Number.isFinite(numeric) - ? new Date(Math.abs(numeric) < 10_000_000_000 ? numeric * 1000 : numeric) + ? parseNumericTimestamp(numeric) : new Date(normalized); return Number.isNaN(date.getTime()) ? null : createTimestampConversion(date); } export function createTimestampConversion(date: Date): TimestampConversion { + const milliseconds = date.getTime(); + return { + dateInput: toDateTimeLocalValue(date), iso: date.toISOString(), local: date.toLocaleString(), - milliseconds: date.getTime(), - seconds: Math.floor(date.getTime() / 1000), + microseconds: milliseconds * 1000, + milliseconds, + nanoseconds: milliseconds * 1_000_000, + relative: formatRelative(milliseconds - Date.now()), + seconds: Math.floor(milliseconds / 1000), utc: date.toUTCString(), }; } + +function parseNumericTimestamp(value: number): Date { + const absolute = Math.abs(value); + + if (absolute >= 1_000_000_000_000_000_000) { + return new Date(Math.trunc(value / 1_000_000)); + } + + if (absolute >= 1_000_000_000_000_000) { + return new Date(Math.trunc(value / 1000)); + } + + if (absolute >= 10_000_000_000) { + return new Date(value); + } + + return new Date(value * 1000); +} + +function toDateTimeLocalValue(date: Date): string { + const offset = date.getTimezoneOffset() * 60_000; + + return new Date(date.getTime() - offset).toISOString().slice(0, 19); +} + +function formatRelative(deltaMs: number): string { + const absoluteSeconds = Math.round(Math.abs(deltaMs) / 1000); + const suffix = deltaMs >= 0 ? "from now" : "ago"; + + if (absoluteSeconds < 60) { + return `${absoluteSeconds} seconds ${suffix}`; + } + + const absoluteMinutes = Math.round(absoluteSeconds / 60); + if (absoluteMinutes < 60) { + return `${absoluteMinutes} minutes ${suffix}`; + } + + const absoluteHours = Math.round(absoluteMinutes / 60); + if (absoluteHours < 24) { + return `${absoluteHours} hours ${suffix}`; + } + + return `${Math.round(absoluteHours / 24)} days ${suffix}`; +} diff --git a/src/features/uuid/UuidPage.tsx b/src/features/uuid/UuidPage.tsx index 06f176e..9b5092b 100644 --- a/src/features/uuid/UuidPage.tsx +++ b/src/features/uuid/UuidPage.tsx @@ -1,65 +1,303 @@ import type { JSX } from "react"; -import { useState } from "react"; -import { Copy, RefreshCw } from "lucide-react"; +import { useMemo, useState } from "react"; +import { CheckCircle2, Copy, Download, Fingerprint, RefreshCw } from "lucide-react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -import { ResultRow } from "@/shared/components/ToolSurface"; -import { createUuidBatch } from "./uuid.service"; +import { Tooltip } from "@/shared/ui/tooltip"; +import { PaneHeader, ToolSurface, ToolToolbar } from "@/shared/components/ToolSurface"; +import { + createUuidBatch, + validateUuid, + type UuidFormat, + type UuidVersion, +} from "./uuid.service"; export function UuidPage(): JSX.Element { - const [count, setCount] = useState(5); - const [values, setValues] = useState(() => createUuidBatch(5)); + const [version, setVersion] = useState("v4"); + const [format, setFormat] = useState("standard"); + const [count, setCount] = useState(10); + const [seed, setSeed] = useState(0); + const [validationInput, setValidationInput] = useState(""); + const values = useMemo(() => { + void seed; - function generate(): void { - setValues(createUuidBatch(count)); + return createUuidBatch({ count, format, version }); + }, [count, format, seed, version]); + const validation = useMemo(() => validateUuid(validationInput), [validationInput]); + + async function copy(value: string): Promise { + await navigator.clipboard.writeText(value); } async function copyAll(): Promise { - await navigator.clipboard.writeText(values.join("\n")); + await copy(values.join("\n")); + } + + function download(): void { + const blob = new Blob([values.join("\n")], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + + anchor.href = url; + anchor.download = "forge-uuids.txt"; + anchor.click(); + URL.revokeObjectURL(url); } return ( -
-
-
-

- UUID Generator -

-

- Utilities -

+ + + setVersion("v4")} + /> + setVersion("v7")} + /> + setVersion("nil")} + /> +
+ +
+ } + right={ + <> + + + + + + + + } + /> + +
+
+ +
+
+

+ Batch size +

+
+ {[1, 10, 25, 100].map((value) => ( + setCount(value)} + value={value === 1 ? "Single" : "Bulk"} + /> + ))} +
+ +
+ +
+

+ Validate UUID +

+