From b7ef0060d6b802f2b994b75473d5a2705d3424ec Mon Sep 17 00:00:00 2001
From: Vuong <3168632+vuon9@users.noreply.github.com>
Date: Mon, 25 May 2026 08:38:11 +0700
Subject: [PATCH] Add URL inspector tool
---
frontend/src/ToolRouter.jsx | 2 +
frontend/src/components/Sidebar.jsx | 4 +
frontend/src/pages/UrlInspector/index.jsx | 357 ++++++++++++++++++
frontend/src/pages/UrlInspector/urlUtils.js | 79 ++++
.../src/pages/UrlInspector/urlUtils.test.js | 65 ++++
5 files changed, 507 insertions(+)
create mode 100644 frontend/src/pages/UrlInspector/index.jsx
create mode 100644 frontend/src/pages/UrlInspector/urlUtils.js
create mode 100644 frontend/src/pages/UrlInspector/urlUtils.test.js
diff --git a/frontend/src/ToolRouter.jsx b/frontend/src/ToolRouter.jsx
index c0c7b84..ebbb93f 100644
--- a/frontend/src/ToolRouter.jsx
+++ b/frontend/src/ToolRouter.jsx
@@ -17,6 +17,7 @@ import BarcodeGenerator from './pages/BarcodeGenerator';
import DataGenerator from './pages/DataGenerator';
import CodeFormatter from './pages/CodeFormatter';
import ColorConverter from './pages/ColorConverter';
+import UrlInspector from './pages/UrlInspector';
const toolComponents = {
'code-encrypter': CodeEncrypter,
@@ -30,6 +31,7 @@ const toolComponents = {
'data-generator': DataGenerator,
'code-formatter': CodeFormatter,
'color-converter': ColorConverter,
+ 'url-inspector': UrlInspector,
regexp: RegExpTester,
cron: CronJobParser,
diff: TextDiffChecker,
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index c0c0eba..568c210 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -26,6 +26,7 @@ import {
Code2,
Timer,
FileCode,
+ Link,
} from 'lucide-react';
import { ScrollArea } from './ui/scroll-area';
@@ -50,6 +51,7 @@ const TOOL_ICONS = {
'data-generator': LayoutGrid,
'code-formatter': Code2,
'color-converter': Palette,
+ 'url-inspector': Link,
cron: Timer,
regexp: Regex,
diff: FileDiff,
@@ -150,6 +152,7 @@ export function Sidebar({ isVisible, onOpenSettings }) {
{ id: 'data-generator', name: 'Data Generator', category: 'Generator' },
{ id: 'code-formatter', name: 'Code Formatter', category: 'Developer' },
{ id: 'color-converter', name: 'Color Converter', category: 'Developer' },
+ { id: 'url-inspector', name: 'URL Inspector', category: 'Developer' },
{ id: 'cron', name: 'Cron Job Parser', category: 'Developer' },
{ id: 'regexp', name: 'RegExp Tester', category: 'Developer' },
{ id: 'diff', name: 'Text Diff', category: 'Text' },
@@ -366,6 +369,7 @@ export function Sidebar({ isVisible, onOpenSettings }) {
'cron',
'code-formatter',
'color-converter',
+ 'url-inspector',
'number-converter',
'datetime-converter',
].includes(tool.id)
diff --git a/frontend/src/pages/UrlInspector/index.jsx b/frontend/src/pages/UrlInspector/index.jsx
new file mode 100644
index 0000000..fa0a41f
--- /dev/null
+++ b/frontend/src/pages/UrlInspector/index.jsx
@@ -0,0 +1,357 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { Check, Copy, Plus, RefreshCw, SortAsc, Trash2 } from 'lucide-react';
+import { Button } from '../../components/ui/Button';
+import { buildUrl, parseUrlInput, sortQueryRows } from './urlUtils';
+
+const SAMPLE_URL = 'https://example.com:8443/api/search?q=hello%20world&debug=true#results';
+
+function Field({ label, value, onChange, placeholder }) {
+ return (
+
+ );
+}
+
+function createRow() {
+ return { id: `param-${crypto.randomUUID()}`, key: '', value: '' };
+}
+
+export default function UrlInspector() {
+ const [inputUrl, setInputUrl] = useState(SAMPLE_URL);
+ const [parts, setParts] = useState({
+ scheme: 'https',
+ host: 'example.com:8443',
+ path: '/api/search',
+ hash: 'results',
+ });
+ const [queryRows, setQueryRows] = useState([
+ { id: 'q-0', key: 'q', value: 'hello world' },
+ { id: 'debug-1', key: 'debug', value: 'true' },
+ ]);
+ const [parseError, setParseError] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ const builtResult = useMemo(() => buildUrl({ ...parts, queryRows }), [parts, queryRows]);
+
+ useEffect(() => {
+ const result = parseUrlInput(inputUrl);
+ setParseError(result.error);
+ if (!result.error && result.parts) {
+ setParts(result.parts);
+ setQueryRows(result.queryRows.length > 0 ? result.queryRows : [createRow()]);
+ }
+ }, [inputUrl]);
+
+ const updatePart = (name, value) => {
+ setParts((current) => ({ ...current, [name]: value }));
+ };
+
+ const updateRow = (id, field, value) => {
+ setQueryRows((rows) => rows.map((row) => (row.id === id ? { ...row, [field]: value } : row)));
+ };
+
+ const removeRow = (id) => {
+ setQueryRows((rows) => rows.filter((row) => row.id !== id));
+ };
+
+ const copyBuiltUrl = async () => {
+ if (!builtResult.url) return;
+ await navigator.clipboard.writeText(builtResult.url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ };
+
+ const transformValue = (id, transform) => {
+ setQueryRows((rows) =>
+ rows.map((row) => {
+ if (row.id !== id) return row;
+ try {
+ return { ...row, value: transform(row.value) };
+ } catch {
+ return row;
+ }
+ })
+ );
+ };
+
+ return (
+
+
+
+ URL Inspector
+
+
+ Parse, edit, sort, encode, and rebuild URLs.
+
+
+
+
+
+ {parseError && (
+
+ {parseError}
+
+ )}
+
+
+
+ updatePart('scheme', value)}
+ />
+ updatePart('host', value)} />
+ updatePart('path', value)} />
+ updatePart('hash', value)} />
+
+
+
+
+
Query Params
+
+
+
+
+
+
+
+ {queryRows.map((row) => (
+
+
updateRow(row.id, 'key', event.target.value)}
+ placeholder="key"
+ style={{
+ height: '34px',
+ border: '1px solid var(--border)',
+ borderRadius: '6px',
+ backgroundColor: 'var(--background)',
+ color: 'var(--foreground)',
+ padding: '0 10px',
+ minWidth: 0,
+ }}
+ />
+
updateRow(row.id, 'value', event.target.value)}
+ placeholder="value"
+ style={{
+ height: '34px',
+ border: '1px solid var(--border)',
+ borderRadius: '6px',
+ backgroundColor: 'var(--background)',
+ color: 'var(--foreground)',
+ padding: '0 10px',
+ minWidth: 0,
+ }}
+ />
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
Built URL
+
+
+
+ {builtResult.error || builtResult.url}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/UrlInspector/urlUtils.js b/frontend/src/pages/UrlInspector/urlUtils.js
new file mode 100644
index 0000000..8311702
--- /dev/null
+++ b/frontend/src/pages/UrlInspector/urlUtils.js
@@ -0,0 +1,79 @@
+const ABSOLUTE_URL_PATTERN = /^[a-z][a-z\d+.-]*:\/\//i;
+
+function rowId(key, index) {
+ const safeKey = key.trim().replace(/[^a-z0-9_-]+/gi, '-') || 'param';
+ return `${safeKey}-${index}`;
+}
+
+export function parseUrlInput(input) {
+ const rawInput = input.trim();
+
+ if (!rawInput) {
+ return { parts: null, queryRows: [], error: null };
+ }
+
+ if (!ABSOLUTE_URL_PATTERN.test(rawInput)) {
+ return {
+ parts: null,
+ queryRows: [],
+ error: 'Enter an absolute URL with a scheme, for example https://example.com.',
+ };
+ }
+
+ try {
+ const parsedUrl = new URL(rawInput);
+ const queryRows = Array.from(parsedUrl.searchParams.entries()).map(([key, value], index) => ({
+ id: rowId(key, index),
+ key,
+ value,
+ }));
+
+ return {
+ parts: {
+ scheme: parsedUrl.protocol.replace(':', ''),
+ host: parsedUrl.host,
+ path: parsedUrl.pathname,
+ hash: parsedUrl.hash ? decodeURIComponent(parsedUrl.hash.slice(1)) : '',
+ },
+ queryRows,
+ error: null,
+ };
+ } catch {
+ return {
+ parts: null,
+ queryRows: [],
+ error: 'Enter a valid absolute URL.',
+ };
+ }
+}
+
+export function buildUrl({ scheme, host, path, hash, queryRows }) {
+ const cleanScheme = scheme.trim().replace(/:$/, '');
+ const cleanHost = host.trim();
+
+ if (!cleanScheme || !cleanHost) {
+ return { url: '', error: 'Scheme and host are required.' };
+ }
+
+ try {
+ const builtUrl = new URL(`${cleanScheme}://${cleanHost}`);
+ builtUrl.pathname = path?.startsWith('/') ? path : `/${path || ''}`;
+
+ const params = new URLSearchParams();
+ queryRows.filter((row) => row.key.trim()).forEach((row) => params.append(row.key, row.value));
+ builtUrl.search = params.toString();
+ builtUrl.hash = hash || '';
+
+ return { url: builtUrl.toString(), error: null };
+ } catch {
+ return { url: '', error: 'URL parts could not be combined into a valid URL.' };
+ }
+}
+
+export function sortQueryRows(rows) {
+ return [...rows].sort((left, right) => {
+ const keyCompare = left.key.localeCompare(right.key);
+ if (keyCompare !== 0) return keyCompare;
+ return left.value.localeCompare(right.value);
+ });
+}
diff --git a/frontend/src/pages/UrlInspector/urlUtils.test.js b/frontend/src/pages/UrlInspector/urlUtils.test.js
new file mode 100644
index 0000000..2130250
--- /dev/null
+++ b/frontend/src/pages/UrlInspector/urlUtils.test.js
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest';
+import { buildUrl, parseUrlInput, sortQueryRows } from './urlUtils';
+
+describe('urlUtils', () => {
+ it('parses URL parts and repeated query params', () => {
+ const result = parseUrlInput(
+ 'https://example.com:8443/api/search?q=hello%20world&q=again&empty=&flag#section'
+ );
+
+ expect(result.error).toBeNull();
+ expect(result.parts).toMatchObject({
+ scheme: 'https',
+ host: 'example.com:8443',
+ path: '/api/search',
+ hash: 'section',
+ });
+ expect(result.queryRows).toEqual([
+ { id: 'q-0', key: 'q', value: 'hello world' },
+ { id: 'q-1', key: 'q', value: 'again' },
+ { id: 'empty-2', key: 'empty', value: '' },
+ { id: 'flag-3', key: 'flag', value: '' },
+ ]);
+ });
+
+ it('builds a URL with encoded query params and hash', () => {
+ const result = buildUrl({
+ scheme: 'https',
+ host: 'example.com',
+ path: '/docs',
+ hash: 'top section',
+ queryRows: [
+ { id: 'a', key: 'q', value: 'hello world' },
+ { id: 'b', key: 'redirect', value: 'https://app.test/a?b=1' },
+ ],
+ });
+
+ expect(result.error).toBeNull();
+ expect(result.url).toBe(
+ 'https://example.com/docs?q=hello+world&redirect=https%3A%2F%2Fapp.test%2Fa%3Fb%3D1#top%20section'
+ );
+ });
+
+ it('sorts query rows by key then value without mutating the input', () => {
+ const rows = [
+ { id: '3', key: 'z', value: 'last' },
+ { id: '1', key: 'a', value: 'two' },
+ { id: '2', key: 'a', value: 'one' },
+ ];
+
+ expect(sortQueryRows(rows)).toEqual([
+ { id: '2', key: 'a', value: 'one' },
+ { id: '1', key: 'a', value: 'two' },
+ { id: '3', key: 'z', value: 'last' },
+ ]);
+ expect(rows[0].id).toBe('3');
+ });
+
+ it('returns a validation error for invalid URLs', () => {
+ const result = parseUrlInput('not a url');
+
+ expect(result.parts).toBeNull();
+ expect(result.queryRows).toEqual([]);
+ expect(result.error).toContain('Enter an absolute URL');
+ });
+});