diff --git a/src/components/DevToolkitApp.tsx b/src/components/DevToolkitApp.tsx new file mode 100644 index 0000000..6231c18 --- /dev/null +++ b/src/components/DevToolkitApp.tsx @@ -0,0 +1,296 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { GuidTool } from "./tools/GuidTool"; +import { Base64Tool } from "./tools/Base64Tool"; + +/** + * Definition for a tool displayed in the sidebar. + */ +interface ToolDefinition { + id: string; + name: string; + description: string; + keywords: string[]; + render: (props: ToolRenderProps) => React.ReactNode; +} + +/** + * Props provided to tool renderers. + */ +interface ToolRenderProps { + onCopied: (message: string) => void; +} + +/** + * Props for the ToolButton component. + */ +interface ToolButtonProps { + tool: ToolDefinition; + isActive: boolean; + onSelect: (id: string) => void; +} + +/** + * Renders the icon made of two square blocks. + */ +const ToolIcon: React.FC = () => { + return ( +
+ + +
+ ); +}; + +/** + * Renders a sidebar tool button with ripple feedback. + */ +const ToolButton: React.FC = ({ + tool, + isActive, + onSelect, +}) => { + const [ripple, setRipple] = useState<{ + x: number; + y: number; + key: number; + } | null>(null); + + /** + * Triggers the ripple animation and selects the tool. + */ + const handleClick = (event: React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + setRipple({ + x: event.clientX - rect.left, + y: event.clientY - rect.top, + key: Date.now(), + }); + onSelect(tool.id); + }; + + return ( + + ); +}; + +/** + * Skeleton placeholder for tool content transitions. + */ +const ToolSkeleton: React.FC = () => { + return ( +
+
+
+
+
+
+ ); +}; + +/** + * Main application shell for the Dev Toolkit. + */ +const DevToolkitApp: React.FC = () => { + const [search, setSearch] = useState(""); + const [activeId, setActiveId] = useState("guid"); + const [renderId, setRenderId] = useState("guid"); + const [loading, setLoading] = useState(false); + const [typing, setTyping] = useState(false); + const [toast, setToast] = useState(null); + const [progress, setProgress] = useState(0); + const [loaded, setLoaded] = useState(false); + const contentRef = useRef(null); + const transitionRef = useRef(null); + const typingRef = useRef(null); + + const tools = useMemo( + () => [ + { + id: "guid", + name: "GUID Generator", + description: + "Fast, compliant GUID creation for workflows and API testing.", + keywords: ["guid", "uuid", "id"], + render: ({ onCopied }) => , + }, + { + id: "base64", + name: "Base64 Studio", + description: "Convert text and files into clean Base64 payloads.", + keywords: ["base64", "encode", "file"], + render: ({ onCopied }) => , + }, + ], + [], + ); + + const filteredTools = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) { + return tools; + } + return tools.filter((tool) => { + return ( + tool.name.toLowerCase().includes(query) || + tool.keywords.some((keyword) => keyword.includes(query)) + ); + }); + }, [search, tools]); + + const activeTool = tools.find((tool) => tool.id === renderId) ?? tools[0]; + const activeIndex = Math.max( + 0, + filteredTools.findIndex((tool) => tool.id === activeId), + ); + + /** + * Handles tool selection with skeleton transitions. + */ + const handleSelectTool = (id: string) => { + if (id === activeId) { + return; + } + setActiveId(id); + setLoading(true); + if (transitionRef.current) { + window.clearTimeout(transitionRef.current); + } + transitionRef.current = window.setTimeout(() => { + setRenderId(id); + setLoading(false); + }, 320); + }; + + /** + * Shows a short toast notification. + */ + const notify = (message: string) => { + setToast(message); + window.setTimeout(() => setToast(null), 2100); + }; + + /** + * Tracks typing for the search animation. + */ + const handleSearchChange = (value: string) => { + setSearch(value); + setTyping(true); + if (typingRef.current) { + window.clearTimeout(typingRef.current); + } + typingRef.current = window.setTimeout(() => setTyping(false), 500); + }; + + useEffect(() => { + const id = window.requestAnimationFrame(() => setLoaded(true)); + return () => window.cancelAnimationFrame(id); + }, []); + + useEffect(() => { + if (filteredTools.length === 0) { + return; + } + if (!filteredTools.some((tool) => tool.id === activeId)) { + handleSelectTool(filteredTools[0].id); + } + }, [activeId, filteredTools]); + + useEffect(() => { + const node = contentRef.current; + if (!node) { + return; + } + + /** + * Updates the scroll progress indicator based on the content scroll. + */ + const handleScroll = () => { + const max = node.scrollHeight - node.clientHeight; + const ratio = max > 0 ? node.scrollTop / max : 0; + setProgress(Math.min(1, Math.max(0, ratio))); + }; + + handleScroll(); + node.addEventListener("scroll", handleScroll); + return () => node.removeEventListener("scroll", handleScroll); + }, [renderId]); + + return ( +
+ +
+
+
+
+ + + + + handleSearchChange(event.target.value)} + /> +
+
+
{activeTool.name}
+
{activeTool.description}
+
+
+ {loading ? ( + + ) : ( + activeTool.render({ onCopied: notify }) + )} +
+
+
+ {toast ?
{toast}
: null} +
+ ); +}; + +export default DevToolkitApp; diff --git a/src/components/hooks/useCopyToClipboard.ts b/src/components/hooks/useCopyToClipboard.ts new file mode 100644 index 0000000..906c4cb --- /dev/null +++ b/src/components/hooks/useCopyToClipboard.ts @@ -0,0 +1,59 @@ +import { useCallback, useState } from "react"; + +/** + * Result of a clipboard copy attempt. + */ +export interface CopyState { + ok: boolean; +} + +/** + * Provides a safe clipboard copy helper with UI-friendly state. + */ +export const useCopyToClipboard = () => { + const [isCopied, setIsCopied] = useState(false); + + /** + * Resets the copied state after a short delay. + */ + const resetCopied = useCallback(() => { + const timeout = window.setTimeout(() => setIsCopied(false), 1800); + return () => window.clearTimeout(timeout); + }, []); + + /** + * Copies a string into the clipboard using the best available API. + */ + const copy = useCallback( + async (value: string): Promise => { + if (!value) { + return { ok: false }; + } + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value); + } else { + const textarea = document.createElement("textarea"); + textarea.value = value; + textarea.setAttribute("readonly", "true"); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + + setIsCopied(true); + resetCopied(); + return { ok: true }; + } catch { + return { ok: false }; + } + }, + [resetCopied], + ); + + return { isCopied, copy }; +}; diff --git a/src/components/tools/Base64Tool.tsx b/src/components/tools/Base64Tool.tsx new file mode 100644 index 0000000..6379250 --- /dev/null +++ b/src/components/tools/Base64Tool.tsx @@ -0,0 +1,109 @@ +import React, { useMemo, useState } from "react"; +import { ToolSection } from "../ui/ToolSection"; +import { ToolOutput } from "../ui/ToolOutput"; + +/** + * Props for the Base64Tool component. + */ +export interface Base64ToolProps { + onCopied?: (message: string) => void; +} + +/** + * Encodes text into Base64 with UTF-8 support. + */ +const encodeTextToBase64 = (value: string): string => { + if (!value) { + return ""; + } + + const encoded = new TextEncoder().encode(value); + let binary = ""; + encoded.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +}; + +/** + * Reads a file as Base64 and returns its data payload. + */ +const readFileAsBase64 = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = typeof reader.result === "string" ? reader.result : ""; + const [, payload] = result.split(","); + resolve(payload ?? ""); + }; + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +}; + +/** + * Renders the Base64 conversion tool for text and files. + */ +export const Base64Tool: React.FC = ({ onCopied }) => { + const [textInput, setTextInput] = useState(""); + const [fileOutput, setFileOutput] = useState(""); + const [fileMeta, setFileMeta] = useState(""); + + const textOutput = useMemo(() => encodeTextToBase64(textInput), [textInput]); + + /** + * Handles file selection and Base64 extraction. + */ + const handleFileChange = async ( + event: React.ChangeEvent, + ) => { + const [file] = Array.from(event.target.files ?? []); + if (!file) { + setFileOutput(""); + setFileMeta(""); + return; + } + + try { + const payload = await readFileAsBase64(file); + setFileOutput(payload); + setFileMeta(`${file.name} - ${(file.size / 1024).toFixed(1)} KB`); + } catch { + setFileOutput(""); + setFileMeta("Failed to read file"); + } + }; + + return ( +
+ +
Plain text
+