diff --git a/src/components/ui/Field.tsx b/src/components/ui/Field.tsx index 189a631..7001feb 100644 --- a/src/components/ui/Field.tsx +++ b/src/components/ui/Field.tsx @@ -175,7 +175,7 @@ export function Dropdown({ top: 'calc(100% + 8px)', left: 0, right: 0, - zIndex: 100, + zIndex: 500, background: 'var(--surface-palette)', backdropFilter: 'blur(40px) saturate(200%)', border: '1px solid var(--surface-palette-border)', @@ -183,13 +183,15 @@ export function Dropdown({ padding: 6, boxShadow: '0 12px 40px -8px rgba(40, 0, 100, 0.3), 0 4px 12px -4px rgba(80, 40, 140, 0.1)', maxHeight: 260, - overflowY: 'auto' + overflowY: 'auto', + overflowX: 'hidden', }} > {options.map((opt) => ( ))} diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx index b2c74b8..ed2b0ae 100644 --- a/src/components/ui/Toast.tsx +++ b/src/components/ui/Toast.tsx @@ -47,13 +47,15 @@ export function ToastProvider({ children }: { children: ReactNode }) { style={{ position: 'fixed', top: 80, - right: 24, + right: 16, + left: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 10, pointerEvents: 'none', zIndex: 10000, + maxWidth: 'calc(100vw - 32px)', }} > diff --git a/src/index.css b/src/index.css index 199c30c..3d59fdb 100644 --- a/src/index.css +++ b/src/index.css @@ -141,6 +141,7 @@ body, overflow-x: hidden; } + body { font-family: var(--font-sans); color: var(--ink); diff --git a/src/tools/jwt-decode/JwtDecodeTool.tsx b/src/tools/jwt-decode/JwtDecodeTool.tsx index 17f5f5e..6e1bcb7 100644 --- a/src/tools/jwt-decode/JwtDecodeTool.tsx +++ b/src/tools/jwt-decode/JwtDecodeTool.tsx @@ -6,31 +6,29 @@ import { Output } from '../../components/ui/Output'; export default function JwtDecodeTool() { const [input, setInput] = useState(''); - const decoded = useMemo(() => { - if (!input.trim()) return null; + const { decoded, isExpired } = useMemo(() => { + if (!input.trim()) return { decoded: null, isExpired: false }; try { const parts = input.split('.'); if (parts.length !== 3) throw new Error('Invalid JWT format (must have 3 parts separated by dots)'); const decodeB64 = (str: string) => { const b64 = str.replace(/-/g, '+').replace(/_/g, '/'); - // Pad with = to make it a multiple of 4 const padded = b64.padEnd(b64.length + (4 - (b64.length % 4)) % 4, '='); return decodeURIComponent(atob(padded).split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')); }; const header = JSON.parse(decodeB64(parts[0])); const payload = JSON.parse(decodeB64(parts[1])); - return { header, payload, error: null }; + const expired = payload && typeof payload.exp === 'number' + ? (payload.exp * 1000 < Date.now()) + : false; + return { decoded: { header, payload, error: null }, isExpired: expired }; } catch (e) { - return { error: (e as Error).message }; + return { decoded: { error: (e as Error).message }, isExpired: false }; } }, [input]); - const isExpired = decoded?.payload && typeof decoded.payload.exp === 'number' - ? (decoded.payload.exp * 1000 < Date.now()) - : false; - return (
diff --git a/src/tools/regex/RegexTool.tsx b/src/tools/regex/RegexTool.tsx index 348eecc..470b979 100644 --- a/src/tools/regex/RegexTool.tsx +++ b/src/tools/regex/RegexTool.tsx @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useState, useMemo } from 'react'; +import { useState, useMemo } from 'react'; // useState kept for pattern/flags/testString +import type { ReactNode } from 'react'; import { Field, Toggle, TextInput, TextArea } from '../../components/ui/Field'; import { ToolLayout } from '../../components/ToolLayout'; @@ -7,70 +8,65 @@ export default function RegexTool() { const [pattern, setPattern] = useState('(?\\d{4})-(?\\d{2})-(?\\d{2})'); const [flags, setFlags] = useState('g'); const [testString, setTestString] = useState('Today is 2024-03-12, tomorrow is 2024-03-13.'); - const [error, setError] = useState(null); - const toggleFlag = (flag: string) => { setFlags(prev => prev.includes(flag) ? prev.replace(flag, '') : prev + flag); }; - const { matches, highlighted } = useMemo(() => { - setError(null); - if (!pattern) return { matches: [], highlighted: testString }; + const { matches, highlighted, error: regexError } = useMemo(() => { + if (!pattern) return { matches: [], highlighted: testString as ReactNode, error: null }; + let regex: RegExp; try { - const regex = new RegExp(pattern, flags); - const m = []; - let match; - - // If it's global, we can iterate - if (flags.includes('g')) { - let max = 1000; - while ((match = regex.exec(testString)) !== null && max-- > 0) { - m.push(match); - if (match[0].length === 0) { - regex.lastIndex++; // prevent infinite loop on empty matches - } - } - } else { - match = regex.exec(testString); - if (match) m.push(match); - } + regex = new RegExp(pattern, flags); + } catch (e: any) { + return { matches: [], highlighted: testString as ReactNode, error: e.message as string }; + } - // Highlight logic - const hl = []; - let lastIdx = 0; - m.forEach((match, i) => { - const start = match.index; - const end = start + match[0].length; - if (start > lastIdx) { - hl.push({testString.substring(lastIdx, start)}); - } - hl.push( - - {match[0]} - - ); - lastIdx = end; - }); - if (lastIdx < testString.length) { - hl.push({testString.substring(lastIdx)}); + const m: RegExpExecArray[] = []; + if (flags.includes('g')) { + let max = 1000; + let match: RegExpExecArray | null; + while ((match = regex.exec(testString)) !== null && max-- > 0) { + m.push(match); + if (match[0].length === 0) regex.lastIndex++; } + } else { + const match = regex.exec(testString); + if (match) m.push(match); + } - return { matches: m, highlighted: hl.length > 0 ? hl : testString }; - } catch (e: any) { - setError(e.message); - return { matches: [], highlighted: testString }; + // Build highlight spans outside try/catch (no JSX in catch blocks) + const hl: ReactNode[] = []; + let lastIdx = 0; + m.forEach((match, i) => { + const start = match.index; + const end = start + match[0].length; + if (start > lastIdx) { + hl.push({testString.substring(lastIdx, start)}); + } + hl.push( + + {match[0]} + + ); + lastIdx = end; + }); + if (lastIdx < testString.length) { + hl.push({testString.substring(lastIdx)}); } + + return { matches: m, highlighted: hl.length > 0 ? hl : testString as ReactNode, error: null }; }, [pattern, flags, testString]); + return (
@@ -84,7 +80,7 @@ export default function RegexTool() { style={{ fontFamily: 'var(--font-mono)' }} /> - {error &&
{error}
} + {regexError &&
{regexError}
}
1e12 ? ts : ts * 1000; - const d = new Date(ms); - if (Number.isNaN(d.getTime())) return ''; - return `${d.toISOString()} · ${d.toLocaleString()}`; +// ── Timezone Lists ── +const DEFAULT_TZS = ['UTC', 'America/New_York', 'Asia/Kolkata', 'Europe/London']; +const POPULAR_TZS = [ + 'UTC', 'Europe/London', 'America/New_York', 'Asia/Kolkata', + 'Europe/Paris', 'Asia/Tokyo', 'America/Los_Angeles', 'America/Chicago', + 'Australia/Sydney', 'Asia/Singapore', 'Asia/Dubai', 'America/Sao_Paulo', + 'Africa/Johannesburg' +]; + +interface HistoryItem { + id: string; + mode: 'convert' | 'batch' | 'diff' | 'cron'; + timestamp: number; + label: string; + details: string; + data: Record; +} + +// ── Formatting/Calculation Helpers ── +function getRelativeTime(d: Date): string { + const now = new Date(); + const diffMs = d.getTime() - now.getTime(); + const diffSecs = Math.round(diffMs / 1000); + const diffMins = Math.round(diffSecs / 60); + const diffHours = Math.round(diffMins / 60); + const diffDays = Math.round(diffHours / 24); + + const absSecs = Math.abs(diffSecs); + const absMins = Math.abs(diffMins); + const absHours = Math.abs(diffHours); + const absDays = Math.abs(diffDays); + + if (absSecs < 5) return 'just now'; + if (absSecs < 60) return diffSecs > 0 ? `in ${absSecs}s` : `${absSecs}s ago`; + if (absMins < 60) return diffMins > 0 ? `in ${absMins}m` : `${absMins}m ago`; + if (absHours < 24) return diffHours > 0 ? `in ${absHours}h` : `${absHours}h ago`; + if (absDays < 30) return diffDays > 0 ? `in ${absDays}d` : `${absDays}d ago`; + + const diffMonths = Math.round(absDays / 30.4); + if (diffMonths < 12) return diffDays > 0 ? `in ${diffMonths}mo` : `${diffMonths}mo ago`; + + const diffYears = Math.round(diffMonths / 12); + return diffDays > 0 ? `in ${diffYears}y` : `${diffYears}y ago`; +} + +function parseSmartDate(input: string): Date | null { + const clean = input.trim(); + if (!clean) return null; + if (/^-?\d+$/.test(clean)) { + const num = Number(clean); + return Math.abs(num) < 1e11 ? new Date(num * 1000) : new Date(num); + } + const d = new Date(clean); + return Number.isNaN(d.getTime()) ? null : d; +} + +function formatLocal(date: Date, timeZone: string, type: 'all' | 'date' | 'time' | 'offset'): string { + try { + if (type === 'all') { + return new Intl.DateTimeFormat('en-US', { + timeZone, + dateStyle: 'medium', + timeStyle: 'medium', + }).format(date); + } + if (type === 'date') { + return new Intl.DateTimeFormat('en-US', { + timeZone, + dateStyle: 'medium', + }).format(date); + } + if (type === 'time') { + return new Intl.DateTimeFormat('en-US', { + timeZone, + timeStyle: 'medium', + }).format(date); + } + if (type === 'offset') { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone, + timeZoneName: 'longOffset', + }).formatToParts(date); + const tzPart = parts.find((p) => p.type === 'timeZoneName'); + return tzPart ? tzPart.value : ''; + } + } catch { + return 'Invalid TZ'; + } + return ''; +} + +// ── Cron Next Runs ── +function getNextCronRuns(cron: string, count = 5): Date[] { + const parts = cron.trim().split(/\s+/); + if (parts.length < 5) return []; + const [minPat, hourPat, domPat, monthPat, dowPat] = parts; + + function parseField(pattern: string, minVal: number, maxVal: number): number[] { + const list: number[] = []; + const subpatterns = pattern.split(','); + for (const sub of subpatterns) { + if (sub === '*') { + for (let i = minVal; i <= maxVal; i++) list.push(i); + } else if (sub.includes('/')) { + const [range, stepStr] = sub.split('/'); + const step = Number(stepStr) || 1; + let start = minVal; + let end = maxVal; + if (range !== '*') { + if (range.includes('-')) { + const [s, e] = range.split('-'); + start = Number(s); + end = Number(e); + } else { + start = Number(range); + } + } + for (let i = start; i <= end; i += step) { + list.push(i); + } + } else if (sub.includes('-')) { + const [s, e] = sub.split('-'); + const start = Number(s); + const end = Number(e); + for (let i = start; i <= end; i++) list.push(i); + } else { + list.push(Number(sub)); + } + } + return Array.from(new Set(list)).sort((a, b) => a - b); + } + + try { + const minutes = parseField(minPat!, 0, 59); + const hours = parseField(hourPat!, 0, 23); + const doms = parseField(domPat!, 1, 31); + const months = parseField(monthPat!, 1, 12); + const dows = parseField(dowPat!, 0, 6); + + const runs: Date[] = []; + const current = new Date(); + current.setSeconds(0); + current.setMilliseconds(0); + current.setMinutes(current.getMinutes() + 1); + + let iterations = 0; + while (runs.length < count && iterations < 30000) { + iterations++; + const min = current.getMinutes(); + const hour = current.getHours(); + const dom = current.getDate(); + const month = current.getMonth() + 1; + const dow = current.getDay(); + + if ( + minutes.includes(min) && + hours.includes(hour) && + doms.includes(dom) && + months.includes(month) && + dows.includes(dow) + ) { + runs.push(new Date(current)); + } + current.setMinutes(current.getMinutes() + 1); + } + return runs; + } catch { + return []; + } +} + +function describeCron(cron: string): string { + const parts = cron.trim().split(/\s+/); + if (parts.length < 5) return 'Invalid cron expression'; + const [minPat, hourPat, domPat, monthPat, dowPat] = parts; + const describeField = (pat: string | undefined, name: string) => { + if (!pat || pat === '*') return `every ${name}`; + if (pat.startsWith('*/')) return `every ${pat.slice(2)} ${name}s`; + return `at ${name} ${pat}`; + }; + return `Runs ${describeField(minPat, 'minute')}, ${describeField(hourPat, 'hour')}, on day ${describeField(domPat, 'of month')}, in ${describeField(monthPat, 'month')}, on ${describeField(dowPat, 'day of week')}.`; +} + +// ── Diff Duration helper ── +function getDurationDescription(ms: number): string { + const isPast = ms < 0; + const absMs = Math.abs(ms); + const secs = Math.floor(absMs / 1000); + const mins = Math.floor(secs / 60); + const hours = Math.floor(mins / 60); + const days = Math.floor(hours / 24); + + const remHours = hours % 24; + const remMins = mins % 60; + const remSecs = secs % 60; + + const parts = []; + if (days > 0) parts.push(`${days}d`); + if (remHours > 0) parts.push(`${remHours}h`); + if (remMins > 0) parts.push(`${remMins}m`); + if (remSecs > 0) parts.push(`${remSecs}s`); + + if (parts.length === 0) return '0s'; + return parts.join(' ') + (isPast ? ' ago' : ''); +} + +// ── Offset Parser ── +const OFFSET_REGEX = /^([+-])?\s*(?:(\d+)\s*y)?\s*(?:(\d+)\s*mo)?\s*(?:(\d+)\s*d)?\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m)?\s*(?:(\d+)\s*s)?$/i; + +function applyOffset(base: Date, offsetStr: string): Date { + const clean = offsetStr.trim(); + const match = clean.match(OFFSET_REGEX); + if (!match) return base; + + const sign = match[1] === '-' ? -1 : 1; + const y = (Number(match[2]) || 0) * sign; + const mo = (Number(match[3]) || 0) * sign; + const d = (Number(match[4]) || 0) * sign; + const h = (Number(match[5]) || 0) * sign; + const m = (Number(match[6]) || 0) * sign; + const s = (Number(match[7]) || 0) * sign; + + const res = new Date(base); + if (y) res.setFullYear(res.getFullYear() + y); + if (mo) res.setMonth(res.getMonth() + mo); + if (d) res.setDate(res.getDate() + d); + if (h) res.setHours(res.getHours() + h); + if (m) res.setMinutes(res.getMinutes() + m); + if (s) res.setSeconds(res.getSeconds() + s); + return res; } export default function TimestampTool() { - const [ts, setTs] = useState(String(Math.floor(Date.now() / 1000))); - const [iso, setIso] = useState(''); - const [out, setOut] = useState(''); + const toast = useToast(); + const { copy } = useClipboard(); + const { slug } = useParams<{ slug: string }>(); + const { increment } = useToolStats(slug); + // ── URL & Local states ── + const [activeMode, setActiveMode] = useUrlState('mode', 'convert', (r) => r, (v) => v); + // Use lazy initializer so Date.now() is called once (not during every render) + const [convertInput, setConvertInput] = useUrlState( + 'q', + (() => String(Math.floor(Date.now() / 1000)))(), + (r) => r, + (v) => v, + ); + const [batchInput, setBatchInput] = useUrlState('batch', '', (r) => r, (v) => v); + const [cronInput, setCronInput] = useUrlState('cron', '*/5 * * * *', (r) => r, (v) => v); + + // Diff states + const [diffMode, setDiffMode] = useUrlState('diffMode', 'range', (r) => r, (v) => v); + const [diffDate1, setDiffDate1] = useUrlState('d1', 'now', (r) => r, (v) => v); + const [diffDate2, setDiffDate2] = useUrlState('d2', '2027-01-01', (r) => r, (v) => v); + const [diffBase, setDiffBase] = useUrlState('db', 'now', (r) => r, (v) => v); + const [diffOffset, setDiffOffset] = useUrlState('do', '+1y 2mo 15d', (r) => r, (v) => v); + + // Timezones State + const [selectedTz, setSelectedTz] = useState('UTC'); + const [customTzs, setCustomTzs] = useState(DEFAULT_TZS); + + // History Drawer State + const [historyOpen, setHistoryOpen] = useState(false); + const [historyList, setHistoryList] = useState(() => { + try { + return JSON.parse(localStorage.getItem('tg_ts_history') || '[]'); + } catch { + return []; + } + }); + + // Code Flyout States + const [flyoutValue, setFlyoutValue] = useState(null); + const [flyoutDate, setFlyoutDate] = useState(null); + const [copiedSnippetIndex, setCopiedSnippetIndex] = useState(null); + const flyoutRef = useRef(null); + + // Live clock: tracks current unix second so Date.now() is not called during render + const [nowTs, setNowTs] = useState(() => Math.floor(Date.now() / 1000)); useEffect(() => { - const n = Number(ts); - setOut(toHuman(n)); - }, [ts]); + const timer = setInterval(() => setNowTs(Math.floor(Date.now() / 1000)), 1000); + return () => clearInterval(timer); + }, []); - const fromIso = () => { - const d = new Date(iso); - if (!Number.isNaN(d.getTime())) setTs(String(Math.floor(d.getTime() / 1000))); + // Close flyout on click outside + useEffect(() => { + const clickOutside = (e: MouseEvent) => { + if (flyoutRef.current && !flyoutRef.current.contains(e.target as Node)) { + setFlyoutValue(null); + } + }; + document.addEventListener('mousedown', clickOutside); + return () => document.removeEventListener('mousedown', clickOutside); + }, []); + + // Add history helper + const addHistory = (label: string, mode: 'convert' | 'batch' | 'diff' | 'cron', details: string, data: Record) => { + const nextItem: HistoryItem = { + id: Math.random().toString(36).substring(2, 9), + mode, + timestamp: Date.now(), + label, + details, + data, + }; + setHistoryList((prev) => { + const filtered = prev.filter((item) => item.label !== label || item.mode !== mode); + const updated = [nextItem, ...filtered].slice(0, 10); + localStorage.setItem('tg_ts_history', JSON.stringify(updated)); + return updated; + }); }; + const handleCopy = async (val: string) => { + if (!val) return; + const ok = await copy(val); + if (ok) { + increment(); + toast.push(`Copied: ${val.substring(0, 25)}${val.length > 25 ? '...' : ''}`, 'success'); + } + }; + + const handleCopySnippet = async (code: string, index: number) => { + const ok = await copy(code); + if (ok) { + increment(); + setCopiedSnippetIndex(index); + setTimeout(() => setCopiedSnippetIndex(null), 1500); + toast.push('Copied snippet to clipboard', 'success'); + } + }; + + const openCodeFlyout = (e: React.MouseEvent, val: string, dateObj: Date) => { + e.stopPropagation(); + setFlyoutValue(val); + setFlyoutDate(dateObj); + }; + + // ── Mode Outputs ── + + // 1. Convert — always needed for the convert tab + const parsedDate = activeMode === 'convert' ? parseSmartDate(convertInput) : null; + const parseStatus = parsedDate ? 'Valid Date' : 'Waiting for valid input...'; + + // 2. Batch Mode — only parse when on batch tab + const batchMatches: Array<{ original: string; date: Date; iso: string; relative: string }> = []; + if (activeMode === 'batch' && batchInput.trim()) { + const regex = /(?:-?\d{9,13})|(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2}))/g; + const tokens = batchInput.match(regex); + if (tokens) { + for (const token of tokens) { + const date = parseSmartDate(token); + if (date) { + batchMatches.push({ + original: token, + date, + iso: date.toISOString(), + relative: getRelativeTime(date), + }); + } + } + } + } + + // 3. Diff & Duration — only parse when on diff tab + const d1 = activeMode === 'diff' ? parseSmartDate(diffDate1 === 'now' ? String(nowTs) : diffDate1) : null; + const d2 = activeMode === 'diff' ? parseSmartDate(diffDate2 === 'now' ? String(nowTs) : diffDate2) : null; + const baseD = activeMode === 'diff' ? parseSmartDate(diffBase === 'now' ? String(nowTs) : diffBase) : null; + + // Offset validation + const isValidOffset = diffOffset.trim() === '' || OFFSET_REGEX.test(diffOffset.trim()); + const offsetResult = baseD && isValidOffset ? applyOffset(baseD, diffOffset) : null; + + // 4. Cron — only parse when on cron tab + const cronRuns = activeMode === 'cron' ? getNextCronRuns(cronInput, 5) : []; + const cronDesc = activeMode === 'cron' ? describeCron(cronInput) : ''; + + // Trigger history save on successful parses. + // IMPORTANT: only use primitive string deps here — Date objects are new references + // every render tick and would cause the effect (and history) to fire every second. + const lastLoggedRef = useRef(''); + useEffect(() => { + if (activeMode === 'convert') { + const val = convertInput.trim(); + const parsed = parseSmartDate(val); + if (val && parsed && val !== lastLoggedRef.current) { + lastLoggedRef.current = val; + addHistory(val, 'convert', parsed.toISOString(), { q: val }); + } + } else if (activeMode === 'diff' && diffMode === 'range') { + const parsed1 = parseSmartDate(diffDate1 === 'now' ? String(nowTs) : diffDate1); + const parsed2 = parseSmartDate(diffDate2 === 'now' ? String(nowTs) : diffDate2); + const label = `${diffDate1} ↔ ${diffDate2}`; + if (parsed1 && parsed2 && label !== lastLoggedRef.current) { + lastLoggedRef.current = label; + addHistory(label, 'diff', 'Duration Diff', { d1: diffDate1, d2: diffDate2, diffMode: 'range' }); + } + } else if (activeMode === 'cron') { + const label = cronInput.trim(); + const runs = getNextCronRuns(label, 1); + if (label && runs.length > 0 && label !== lastLoggedRef.current) { + lastLoggedRef.current = label; + addHistory(label, 'cron', describeCron(label), { cron: label }); + } + } + }, [convertInput, activeMode, diffDate1, diffDate2, diffMode, cronInput, nowTs]); + return ( -
- - setTs(e.target.value)} /> - -
+
+ {/* Genuine Glass Backdrop Blurring Blobs */} +
+ + {/* Top Navigation: Mode Selector + Toggle Sidebar Trigger */} +
+ {/* Segmented Modes Tabs with heightened contrast on inactive ones */} +
+ {['convert', 'batch', 'diff', 'cron'].map((m) => ( + + ))} +
+ + {/* Connected Trigger Button that controls sidebar */}
- - -
- - - setIso(e.target.value)} - /> - -
- + + {/* Main layout containing the workspace and collapsible history sidebar */} +
+ {/* Scroll Constrained Workspace Panel */} +
+ {/* CONVERT MODE */} + {activeMode === 'convert' && ( +
+ +
+ setConvertInput(e.target.value)} + /> + +
+
+ + {parsedDate ? ( +
+ {/* Raw Group */} +
+
Raw Unix Timestamps
+
+ + +
+
+ + {/* Standard Group */} +
+
Standard UTC Formats
+
+ + +
+
+ + {/* Localized Group */} +
+
+
Localized Dates
+ {/* Custom-styled Dropzone Selection for timezone */} +
+ ({ label: tz, value: tz }))} + onChange={(tz) => { + setSelectedTz(tz); + if (!customTzs.includes(tz)) { + setCustomTzs([...customTzs, tz]); + } + }} + /> +
+
+ + {/* TZ Pinned Pills */} +
+ {customTzs.map((tz) => ( + + ))} +
+ +
+ + + + + +
+
+
+ ) : ( +
+ ⚠️ {parseStatus} +
+ )} +
+ )} + + {/* BATCH MODE */} + {activeMode === 'batch' && ( +
+ +
+