|
| 1 | +"use client"; |
| 2 | +import React, { useState, useEffect } from "react"; |
| 3 | +import DevelopmentToolsStyles from "../../developmentToolsStyles.module.scss"; |
| 4 | + |
| 5 | +interface QueryParam { |
| 6 | + id: string; |
| 7 | + key: string; |
| 8 | + value: string; |
| 9 | + active: boolean; |
| 10 | + encoded: boolean; |
| 11 | +} |
| 12 | + |
| 13 | +const UrlParser = () => { |
| 14 | + const [urlInput, setUrlInput] = useState<string>(""); |
| 15 | + const [parsedUrl, setParsedUrl] = useState<URL | null>(null); |
| 16 | + const [queryParams, setQueryParams] = useState<QueryParam[]>([]); |
| 17 | + const [error, setError] = useState<string>(""); |
| 18 | + const isInternalUpdate = React.useRef(false); |
| 19 | + |
| 20 | + |
| 21 | + // Function to update the input and state from a URL object |
| 22 | + const updateStateFromUrl = (url: URL) => { |
| 23 | + setParsedUrl(url); |
| 24 | + const params: QueryParam[] = []; |
| 25 | + let index = 0; |
| 26 | + url.searchParams.forEach((value, key) => { |
| 27 | + // Use a stable ID based on index and key to avoid unnecessary re-renders |
| 28 | + // when only values change, or random regeneration on every parse. |
| 29 | + const id = `param-${index}-${key}`; |
| 30 | + params.push({ |
| 31 | + id, |
| 32 | + key, |
| 33 | + value, |
| 34 | + active: true, |
| 35 | + encoded: true |
| 36 | + }); |
| 37 | + index++; |
| 38 | + }); |
| 39 | + setQueryParams(params); |
| 40 | + } |
| 41 | + |
| 42 | + // Initial parse on input change |
| 43 | + useEffect(() => { |
| 44 | + if (isInternalUpdate.current) { |
| 45 | + isInternalUpdate.current = false; |
| 46 | + return; |
| 47 | + } |
| 48 | + |
| 49 | + if (!urlInput.trim()) { |
| 50 | + setParsedUrl(null); |
| 51 | + setQueryParams([]); |
| 52 | + setError(""); |
| 53 | + return; |
| 54 | + } |
| 55 | + |
| 56 | + try { |
| 57 | + // Basic heuristic to add protocol if missing for better UX |
| 58 | + let urlToParse = urlInput; |
| 59 | + if (!urlInput.match(/^[a-zA-Z][a-zA-Z\d+\-.]*:/)) { |
| 60 | + // If doesn't start with scheme, assume http:// if it looks like a domain |
| 61 | + if (urlInput.includes('.') && !urlInput.startsWith('/')) { |
| 62 | + urlToParse = "http://" + urlInput; |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + const url = new URL(urlToParse); |
| 67 | + updateStateFromUrl(url); |
| 68 | + setError(""); |
| 69 | + } catch (err) { |
| 70 | + setParsedUrl(null); |
| 71 | + setError("Invalid URL"); |
| 72 | + } |
| 73 | + }, [urlInput]); |
| 74 | + |
| 75 | + // Re-construct URL when params change |
| 76 | + const updateUrlFromParams = (newParams: QueryParam[]) => { |
| 77 | + if (!parsedUrl) return; |
| 78 | + |
| 79 | + try { |
| 80 | + const newUrl = new URL(parsedUrl.toString()); |
| 81 | + const queryParts: string[] = []; |
| 82 | + |
| 83 | + newParams.forEach(p => { |
| 84 | + if (p.active && p.key) { |
| 85 | + const key = p.encoded ? encodeURIComponent(p.key) : p.key; |
| 86 | + const value = p.encoded ? encodeURIComponent(p.value) : p.value; |
| 87 | + queryParts.push(`${key}=${value}`); |
| 88 | + } |
| 89 | + }); |
| 90 | + |
| 91 | + newUrl.search = queryParts.join('&'); |
| 92 | + |
| 93 | + if (newUrl.toString() !== urlInput) { |
| 94 | + isInternalUpdate.current = true; |
| 95 | + setUrlInput(newUrl.toString()); |
| 96 | + } |
| 97 | + } catch (e) { |
| 98 | + console.error("Error updating URL from params", e); |
| 99 | + } |
| 100 | + }; |
| 101 | + |
| 102 | + const handleParamChange = (id: string, field: 'key' | 'value', newValue: string) => { |
| 103 | + const updatedParams = queryParams.map(p => |
| 104 | + p.id === id ? { ...p, [field]: newValue } : p |
| 105 | + ); |
| 106 | + // Optimistically update params state to feel responsive |
| 107 | + setQueryParams(updatedParams); |
| 108 | + // Then update the full URL |
| 109 | + updateUrlFromParams(updatedParams); |
| 110 | + }; |
| 111 | + |
| 112 | + const handleToggleParamEncoding = (id: string) => { |
| 113 | + const updatedParams = queryParams.map(p => |
| 114 | + p.id === id ? { ...p, encoded: !p.encoded } : p |
| 115 | + ); |
| 116 | + setQueryParams(updatedParams); |
| 117 | + updateUrlFromParams(updatedParams); |
| 118 | + }; |
| 119 | + |
| 120 | + const handleDeleteParam = (id: string) => { |
| 121 | + const updatedParams = queryParams.filter(p => p.id !== id); |
| 122 | + setQueryParams(updatedParams); |
| 123 | + updateUrlFromParams(updatedParams); |
| 124 | + }; |
| 125 | + |
| 126 | + const handleAddParam = () => { |
| 127 | + const newParam: QueryParam = { |
| 128 | + id: Math.random().toString(36).substr(2, 9), |
| 129 | + key: "new_key", |
| 130 | + value: "value", |
| 131 | + active: true, |
| 132 | + encoded: true |
| 133 | + }; |
| 134 | + const updatedParams = [...queryParams, newParam]; |
| 135 | + setQueryParams(updatedParams); |
| 136 | + updateUrlFromParams(updatedParams); |
| 137 | + }; |
| 138 | + |
| 139 | + const updateUrlPart = (part: 'protocol' | 'hostname' | 'pathname' | 'hash', value: string) => { |
| 140 | + if (!parsedUrl) return; |
| 141 | + try { |
| 142 | + const newUrl = new URL(parsedUrl.toString()); |
| 143 | + if (part === 'protocol') { |
| 144 | + // Protocol needs the colon usually |
| 145 | + const protocol = value.endsWith(':') ? value : value + ':'; |
| 146 | + // prevent invalid protocol assignment crash if possible, but URL object throws |
| 147 | + newUrl.protocol = protocol; |
| 148 | + } else if (part === 'hostname') { |
| 149 | + newUrl.hostname = value; |
| 150 | + } else if (part === 'pathname') { |
| 151 | + newUrl.pathname = value.startsWith('/') ? value : '/' + value; |
| 152 | + } else if (part === 'hash') { |
| 153 | + newUrl.hash = value; |
| 154 | + } |
| 155 | + if (newUrl.toString() !== urlInput) { |
| 156 | + isInternalUpdate.current = true; |
| 157 | + setUrlInput(newUrl.toString()); |
| 158 | + } |
| 159 | + } catch (e) { |
| 160 | + // invalid part update, ignore or show hint |
| 161 | + } |
| 162 | + }; |
| 163 | + |
| 164 | + return ( |
| 165 | + <section> |
| 166 | + <div className="md:mt-8 mt-4"> |
| 167 | + <div className="flex-1 flex items-center justify-center"> |
| 168 | + <div className="w-full bg-[#FFFFFF1A] rounded-2xl shadow-lg p-8"> |
| 169 | + <div className="md:w-[900px] mx-auto"> |
| 170 | + <div className="flex flex-col gap-6 md:my-5 mt-2"> |
| 171 | + |
| 172 | + {/* Title */} |
| 173 | + <div className="flex items-center gap-4"> |
| 174 | + <h3 className="text-xl font-medium text-white">URL Parser & Query Editor</h3> |
| 175 | + </div> |
| 176 | + |
| 177 | + {/* Main URL Input */} |
| 178 | + <div className="w-full"> |
| 179 | + <label className="block text-sm font-medium mb-2 text-white">URL</label> |
| 180 | + <div className="relative"> |
| 181 | + <textarea |
| 182 | + value={urlInput} |
| 183 | + onChange={(e) => setUrlInput(e.target.value)} |
| 184 | + placeholder="Enter URL to parse... (e.g. https://example.com/search?q=test)" |
| 185 | + className={`${DevelopmentToolsStyles.scrollbar} w-full min-h-[80px] bg-black border border-[#222222] p-4 rounded-xl text-white focus:outline-none focus:border-blue-500 transition-colors`} |
| 186 | + /> |
| 187 | + {error && <p className="text-red-400 text-sm mt-2">{error}</p>} |
| 188 | + </div> |
| 189 | + </div> |
| 190 | + |
| 191 | + {parsedUrl && ( |
| 192 | + <div className="animate-fade-in space-y-6"> |
| 193 | + {/* URL Components Grid */} |
| 194 | + <div className="grid grid-cols-1 md:grid-cols-2 gap-6 bg-black/20 p-6 rounded-xl border border-[#222222]"> |
| 195 | + <h4 className="col-span-full text-lg font-medium text-white border-b border-white/10 pb-2">Components</h4> |
| 196 | + |
| 197 | + <div> |
| 198 | + <label className="block text-xs font-medium mb-1 text-white/60 uppercase tracking-wider">Scheme</label> |
| 199 | + <input |
| 200 | + type="text" |
| 201 | + value={parsedUrl.protocol.replace(':', '')} |
| 202 | + onChange={(e) => updateUrlPart('protocol', e.target.value)} |
| 203 | + className="w-full bg-black/40 border border-[#222222] p-2.5 rounded-lg text-white focus:outline-none focus:border-blue-500 transition-colors" |
| 204 | + /> |
| 205 | + </div> |
| 206 | + <div> |
| 207 | + <label className="block text-xs font-medium mb-1 text-white/60 uppercase tracking-wider">Host</label> |
| 208 | + <input |
| 209 | + type="text" |
| 210 | + value={parsedUrl.hostname} |
| 211 | + onChange={(e) => updateUrlPart('hostname', e.target.value)} |
| 212 | + className="w-full bg-black/40 border border-[#222222] p-2.5 rounded-lg text-white focus:outline-none focus:border-blue-500 transition-colors" |
| 213 | + /> |
| 214 | + </div> |
| 215 | + <div className="md:col-span-2"> |
| 216 | + <label className="block text-xs font-medium mb-1 text-white/60 uppercase tracking-wider">Path</label> |
| 217 | + <input |
| 218 | + type="text" |
| 219 | + value={parsedUrl.pathname} |
| 220 | + onChange={(e) => updateUrlPart('pathname', e.target.value)} |
| 221 | + className="w-full bg-black/40 border border-[#222222] p-2.5 rounded-lg text-white focus:outline-none focus:border-blue-500 transition-colors" |
| 222 | + /> |
| 223 | + </div> |
| 224 | + <div> |
| 225 | + <label className="block text-xs font-medium mb-1 text-white/60 uppercase tracking-wider">Hash</label> |
| 226 | + <input |
| 227 | + type="text" |
| 228 | + value={parsedUrl.hash} |
| 229 | + onChange={(e) => updateUrlPart('hash', e.target.value)} |
| 230 | + className="w-full bg-black/40 border border-[#222222] p-2.5 rounded-lg text-white focus:outline-none focus:border-blue-500 transition-colors" |
| 231 | + /> |
| 232 | + </div> |
| 233 | + <div> |
| 234 | + <label className="block text-xs font-medium mb-1 text-white/60 uppercase tracking-wider">Port</label> |
| 235 | + <input |
| 236 | + type="text" |
| 237 | + value={parsedUrl.port} |
| 238 | + readOnly |
| 239 | + placeholder="80/443" |
| 240 | + className="w-full bg-black/40 border border-[#222222] p-2.5 rounded-lg text-white/50 cursor-not-allowed" |
| 241 | + /> |
| 242 | + </div> |
| 243 | + </div> |
| 244 | + |
| 245 | + {/* Query Parameters Table */} |
| 246 | + <div className="w-full"> |
| 247 | + <div className="flex justify-between items-center mb-4"> |
| 248 | + <h4 className="text-lg font-medium text-white">Query Parameters</h4> |
| 249 | + <button |
| 250 | + onClick={handleAddParam} |
| 251 | + className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition flex items-center gap-2" |
| 252 | + > |
| 253 | + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5"> |
| 254 | + <path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" /> |
| 255 | + </svg> |
| 256 | + Add New |
| 257 | + </button> |
| 258 | + </div> |
| 259 | + |
| 260 | + <div className="bg-black/20 rounded-xl overflow-hidden border border-[#222222]"> |
| 261 | + {queryParams.length === 0 ? ( |
| 262 | + <div className="p-8 text-center text-white/40 text-sm flex flex-col items-center"> |
| 263 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-10 h-10 mb-2 opacity-30"> |
| 264 | + <path strokeLinecap="round" strokeLinejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> |
| 265 | + </svg> |
| 266 | + No query parameters found. Add one or paste a URL with params. |
| 267 | + </div> |
| 268 | + ) : ( |
| 269 | + <div className="w-full overflow-x-auto"> |
| 270 | + <table className="w-full text-left text-sm text-white"> |
| 271 | + <thead className="bg-white/5 uppercase text-xs"> |
| 272 | + <tr> |
| 273 | + <th className="px-4 py-3 font-semibold text-white/70 w-1/3">Key</th> |
| 274 | + <th className="px-4 py-3 font-semibold text-white/70 w-1/3">Value</th> |
| 275 | + <th className="px-4 py-3 font-semibold text-white/70 w-24 text-center">Encoded</th> |
| 276 | + <th className="px-4 py-3 font-semibold text-white/70 text-right w-24">Actions</th> |
| 277 | + </tr> |
| 278 | + </thead> |
| 279 | + <tbody className="divide-y divide-white/5"> |
| 280 | + {queryParams.map((param) => ( |
| 281 | + <tr key={param.id} className="hover:bg-white/5 transition group"> |
| 282 | + <td className="px-4 py-2"> |
| 283 | + <input |
| 284 | + type="text" |
| 285 | + value={param.key} |
| 286 | + onChange={(e) => handleParamChange(param.id, 'key', e.target.value)} |
| 287 | + className="bg-transparent w-full p-2 rounded border border-transparent focus:border-blue-500/50 focus:bg-black/20 focus:outline-none transition-colors" |
| 288 | + placeholder="Key" |
| 289 | + /> |
| 290 | + </td> |
| 291 | + <td className="px-4 py-2"> |
| 292 | + <input |
| 293 | + type="text" |
| 294 | + value={param.value} |
| 295 | + onChange={(e) => handleParamChange(param.id, 'value', e.target.value)} |
| 296 | + className="bg-transparent w-full p-2 rounded border border-transparent focus:border-blue-500/50 focus:bg-black/20 focus:outline-none transition-colors" |
| 297 | + placeholder="Value" |
| 298 | + /> |
| 299 | + </td> |
| 300 | + <td className="px-4 py-2 text-center"> |
| 301 | + <input |
| 302 | + type="checkbox" |
| 303 | + checked={param.encoded} |
| 304 | + onChange={() => handleToggleParamEncoding(param.id)} |
| 305 | + className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 bg-gray-700 border-gray-600 accent-blue-600 cursor-pointer" |
| 306 | + title="Toggle URL Encoding for this parameter" |
| 307 | + /> |
| 308 | + </td> |
| 309 | + <td className="px-4 py-2 text-right"> |
| 310 | + <button |
| 311 | + onClick={() => handleDeleteParam(param.id)} |
| 312 | + className="text-white/40 hover:text-red-400 p-2 rounded-lg hover:bg-white/5 transition" |
| 313 | + title="Delete Parameter" |
| 314 | + > |
| 315 | + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5"> |
| 316 | + <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /> |
| 317 | + </svg> |
| 318 | + </button> |
| 319 | + </td> |
| 320 | + </tr> |
| 321 | + ))} |
| 322 | + </tbody> |
| 323 | + </table> |
| 324 | + </div> |
| 325 | + )} |
| 326 | + </div> |
| 327 | + </div> |
| 328 | + </div> |
| 329 | + )} |
| 330 | + </div> |
| 331 | + </div> |
| 332 | + </div> |
| 333 | + </div> |
| 334 | + </div> |
| 335 | + </section> |
| 336 | + ); |
| 337 | +}; |
| 338 | + |
| 339 | +export default UrlParser; |
0 commit comments