@@ -10,6 +10,8 @@ import IPInfoMap from "./components/IPInfoMap.jsx";
1010import NsLookup from "./components/NsLookup.jsx" ;
1111import EpochConverter from "./components/EpochConverter.jsx" ;
1212import DnsPropagation from "./components/DnsPropagation.jsx" ;
13+ import UrlEncoder from "./components/UrlEncoder.jsx" ;
14+ import RegexTester from "./components/RegexTester.jsx" ;
1315import Disclaimer from "./components/Disclaimer.jsx" ;
1416
1517const TOOLS = [
@@ -23,66 +25,131 @@ const TOOLS = [
2325 { id : "nslookup" , label : "nslookup" , glyph : "DNS" , Component : NsLookup , badge : "DNS" } ,
2426 { id : "epoch" , label : "epoch" , glyph : "ts" , Component : EpochConverter , badge : "time" } ,
2527 { id : "dnsprop" , label : "DNS prop" , glyph : "⇢" , Component : DnsPropagation , badge : "checker" } ,
28+ { id : "urlencode" , label : "URL encode" , glyph : "%20" , Component : UrlEncoder , badge : "encoding" } ,
29+ { id : "regex" , label : "regex" , glyph : ".*" , Component : RegexTester , badge : "pattern" } ,
2630 { id : "disclaimer" , label : "Disclaimer" , glyph : "§" , Component : Disclaimer , badge : "legal" } ,
2731] ;
2832
29- const BASE = import . meta. env . BASE_URL ; // "/syskit/" in prod, "/" in dev
30-
3133function getToolFromPath ( ) {
32- const path = window . location . pathname ;
33- const relative = path . startsWith ( BASE ) ? path . slice ( BASE . length ) : path . replace ( / ^ \/ / , "" ) ;
34- const id = relative . split ( "/" ) [ 0 ] ;
34+ const segments = window . location . pathname . split ( "/" ) . filter ( Boolean ) ;
35+ const id = segments [ segments . length - 1 ] ?? "" ;
3536 return TOOLS . find ( ( t ) => t . id === id ) ?. id ?? "chmod" ;
3637}
3738
38- function ThemeToggle ( { theme, onToggle } ) {
39- const isDark = theme === "dark" ;
39+ const THEME_OPTIONS = [
40+ {
41+ value : "light" , label : "Light" ,
42+ icon : < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" > < circle cx = "12" cy = "12" r = "5" /> < line x1 = "12" y1 = "1" x2 = "12" y2 = "3" /> < line x1 = "12" y1 = "21" x2 = "12" y2 = "23" /> < line x1 = "4.22" y1 = "4.22" x2 = "5.64" y2 = "5.64" /> < line x1 = "18.36" y1 = "18.36" x2 = "19.78" y2 = "19.78" /> < line x1 = "1" y1 = "12" x2 = "3" y2 = "12" /> < line x1 = "21" y1 = "12" x2 = "23" y2 = "12" /> < line x1 = "4.22" y1 = "19.78" x2 = "5.64" y2 = "18.36" /> < line x1 = "18.36" y1 = "5.64" x2 = "19.78" y2 = "4.22" /> </ svg > ,
43+ } ,
44+ {
45+ value : "dark" , label : "Dark" ,
46+ icon : < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" > < path d = "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> </ svg > ,
47+ } ,
48+ {
49+ value : "system" , label : "System" ,
50+ icon : < svg width = "13" height = "13" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2" strokeLinecap = "round" strokeLinejoin = "round" > < rect x = "2" y = "3" width = "20" height = "14" rx = "2" /> < line x1 = "8" y1 = "21" x2 = "16" y2 = "21" /> < line x1 = "12" y1 = "17" x2 = "12" y2 = "21" /> </ svg > ,
51+ } ,
52+ ] ;
53+
54+ function ThemeToggle ( { theme, onChange } ) {
55+ const [ open , setOpen ] = useState ( false ) ;
56+ const [ hovered , setHovered ] = useState ( null ) ;
57+ const ref = useRef ( null ) ;
58+
59+ useEffect ( ( ) => {
60+ const handler = ( e ) => { if ( ref . current && ! ref . current . contains ( e . target ) ) setOpen ( false ) ; } ;
61+ document . addEventListener ( "mousedown" , handler ) ;
62+ return ( ) => document . removeEventListener ( "mousedown" , handler ) ;
63+ } , [ ] ) ;
64+
65+ const current = THEME_OPTIONS . find ( ( o ) => o . value === theme ) ;
66+
4067 return (
41- < button
42- onClick = { onToggle }
43- title = { `Switch to ${ isDark ? "light" : "dark" } theme` }
44- style = { {
45- display : "flex" , alignItems : "center" , gap : 7 ,
46- background : "none" , border : "none" , cursor : "pointer" ,
47- padding : "2px 0" ,
48- } }
49- >
50- < span style = { { fontSize : 16 , lineHeight : 1 , color : isDark ? "var(--text-faint)" : "var(--amber)" , transition : "color 0.2s" } } > ☀</ span >
51- < span style = { {
52- display : "inline-flex" , alignItems : "center" ,
53- width : 40 , height : 22 , borderRadius : 11 ,
54- background : isDark ? "var(--green-dim)" : "var(--surface-3)" ,
55- border : "1px solid var(--border-2)" ,
56- position : "relative" , transition : "background 0.2s" , flexShrink : 0 ,
57- } } >
58- < span style = { {
59- position : "absolute" , left : isDark ? 20 : 2 ,
60- width : 16 , height : 16 , borderRadius : "50%" ,
61- background : isDark ? "var(--green)" : "var(--text-muted)" ,
62- transition : "left 0.2s, background 0.2s" , flexShrink : 0 ,
63- } } />
64- </ span >
65- < span style = { { fontSize : 16 , lineHeight : 1 , color : isDark ? "var(--blue)" : "var(--text-faint)" , transition : "color 0.2s" } } > ☾</ span >
66- </ button >
68+ < div ref = { ref } style = { { position : "relative" } } >
69+ < button
70+ onClick = { ( ) => setOpen ( ( v ) => ! v ) }
71+ style = { {
72+ display : "flex" , alignItems : "center" , gap : 6 ,
73+ padding : "5px 10px" , height : 30 ,
74+ background : open ? "var(--surface-3)" : "var(--surface-2)" ,
75+ border : "1px solid var(--border)" , borderRadius : 8 ,
76+ color : "var(--text-muted)" , cursor : "pointer" , transition : "all 0.15s" ,
77+ } }
78+ >
79+ { current ?. icon }
80+ < span style = { { fontFamily : "var(--font-mono)" , fontSize : "var(--xs)" } } > { current ?. label } </ span >
81+ < svg width = "10" height = "10" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" strokeLinecap = "round" strokeLinejoin = "round"
82+ style = { { opacity : 0.5 , transform : open ? "rotate(180deg)" : "none" , transition : "transform 0.15s" } } >
83+ < polyline points = "6 9 12 15 18 9" />
84+ </ svg >
85+ </ button >
86+
87+ { open && (
88+ < div style = { {
89+ position : "absolute" , top : "calc(100% + 4px)" , right : 0 , zIndex : 300 ,
90+ background : "var(--surface)" , border : "1px solid var(--border-2)" ,
91+ borderRadius : 10 , overflow : "hidden" , minWidth : 130 ,
92+ boxShadow : "0 8px 24px rgba(0,0,0,0.45)" ,
93+ } } >
94+ { THEME_OPTIONS . map ( ( opt , i ) => (
95+ < button
96+ key = { opt . value }
97+ onMouseDown = { ( ) => { onChange ( opt . value ) ; setOpen ( false ) ; } }
98+ onMouseEnter = { ( ) => setHovered ( opt . value ) }
99+ onMouseLeave = { ( ) => setHovered ( null ) }
100+ style = { {
101+ width : "100%" , padding : "9px 14px" , border : "none" ,
102+ borderBottom : i < THEME_OPTIONS . length - 1 ? "1px solid var(--border)" : "none" ,
103+ background : theme === opt . value ? "var(--green-bg)" : hovered === opt . value ? "var(--surface-3)" : "transparent" ,
104+ color : theme === opt . value ? "var(--green)" : hovered === opt . value ? "var(--text)" : "var(--text-muted)" ,
105+ fontFamily : "var(--font-mono)" , fontSize : "var(--sm)" ,
106+ display : "flex" , alignItems : "center" , gap : 9 ,
107+ textAlign : "left" , cursor : "pointer" , transition : "background 0.1s, color 0.1s" ,
108+ } }
109+ >
110+ { opt . icon }
111+ { opt . label }
112+ </ button >
113+ ) ) }
114+ </ div >
115+ ) }
116+ </ div >
67117 ) ;
68118}
69119
70120
71121export default function App ( ) {
72122 const [ activeTool , setActiveTool ] = useState ( getToolFromPath ) ;
73- const [ theme , setTheme ] = useState ( ( ) => localStorage . getItem ( "syskit-theme" ) || "dark" ) ;
123+ const [ theme , setTheme ] = useState ( ( ) => localStorage . getItem ( "syskit-theme" ) || "system" ) ;
124+
125+ const resolveTheme = ( t ) =>
126+ t === "system"
127+ ? ( window . matchMedia ( "(prefers-color-scheme: dark)" ) . matches ? "dark" : "light" )
128+ : t ;
74129
75130 useEffect ( ( ) => {
76- document . documentElement . setAttribute ( "data-theme" , theme ) ;
77131 localStorage . setItem ( "syskit-theme" , theme ) ;
132+ document . documentElement . setAttribute ( "data-theme" , resolveTheme ( theme ) ) ;
133+ } , [ theme ] ) ;
134+
135+ // Re-apply when system preference changes (only relevant when theme === "system")
136+ useEffect ( ( ) => {
137+ const mq = window . matchMedia ( "(prefers-color-scheme: dark)" ) ;
138+ const handler = ( ) => {
139+ if ( theme === "system" ) {
140+ document . documentElement . setAttribute ( "data-theme" , resolveTheme ( "system" ) ) ;
141+ }
142+ } ;
143+ mq . addEventListener ( "change" , handler ) ;
144+ return ( ) => mq . removeEventListener ( "change" , handler ) ;
78145 } , [ theme ] ) ;
79146
80147 useEffect ( ( ) => {
81- const path = window . location . pathname ;
82- const relative = path . startsWith ( BASE ) ? path . slice ( BASE . length ) : path . replace ( / ^ \/ / , "" ) ;
83- const current = relative . split ( "/" ) [ 0 ] ;
148+ const segments = window . location . pathname . split ( "/" ) . filter ( Boolean ) ;
149+ const current = segments [ segments . length - 1 ] ?? "" ;
84150 if ( current !== activeTool ) {
85- history . pushState ( { } , "" , BASE + activeTool ) ;
151+ const base = segments . slice ( 0 , - 1 ) . join ( "/" ) ;
152+ history . pushState ( { } , "" , ( base ? "/" + base : "" ) + "/" + activeTool ) ;
86153 }
87154 } , [ activeTool ] ) ;
88155
@@ -93,7 +160,6 @@ export default function App() {
93160 } , [ ] ) ;
94161
95162
96- const toggleTheme = ( ) => setTheme ( ( t ) => ( t === "dark" ? "light" : "dark" ) ) ;
97163
98164 const scrollRef = useRef ( null ) ;
99165 useEffect ( ( ) => { if ( scrollRef . current ) scrollRef . current . scrollTop = 0 ; } , [ activeTool ] ) ;
@@ -163,7 +229,7 @@ export default function App() {
163229 { activeMeta ?. badge }
164230 </ span >
165231 < div style = { { flex : 1 } } />
166- < ThemeToggle theme = { theme } onToggle = { toggleTheme } />
232+ < ThemeToggle theme = { theme } onChange = { setTheme } />
167233 </ header >
168234
169235 { /* Scrollable content */ }
0 commit comments