Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.1",
"shadcn": "^4.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0"
Expand Down
38 changes: 21 additions & 17 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BrowserRouter, Route, Routes } from "react-router-dom"
import { Toaster } from "sonner"

import { AppSidebar } from "@/components/app-sidebar"
import {
Expand All @@ -12,23 +13,26 @@ import { TimestampPage } from "@/pages/timestamp"

export function App() {
return (
<BrowserRouter basename="/dev-tools-web">
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
</header>
<main className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/base64" element={<Base64Page />} />
<Route path="/timestamp" element={<TimestampPage />} />
</Routes>
</main>
</SidebarInset>
</SidebarProvider>
</BrowserRouter>
<>
<BrowserRouter basename="/dev-tools-web">
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<header className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
</header>
<main className="flex-1 overflow-auto">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/base64" element={<Base64Page />} />
<Route path="/timestamp" element={<TimestampPage />} />
</Routes>
</main>
</SidebarInset>
</SidebarProvider>
</BrowserRouter>
<Toaster richColors />
</>
)
}

Expand Down
18 changes: 18 additions & 0 deletions src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from "react"

import { cn } from "@/lib/utils"

function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"w-full min-w-0 rounded-none border border-input bg-transparent px-2.5 py-1 text-xs transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-1 aria-invalid:ring-destructive/20 md:text-xs dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}

export { Textarea }
23 changes: 12 additions & 11 deletions src/pages/base64.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import { useState } from "react"
import { toast } from "sonner"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"

export function Base64Page() {
const [input, setInput] = useState("")
const [output, setOutput] = useState("")
const [error, setError] = useState("")

function encode() {
try {
const bytes = new TextEncoder().encode(input)
const binary = String.fromCharCode(...bytes)
setOutput(btoa(binary))
setError("")
} catch {
setError("Encoding failed. Make sure the input is valid text.")
toast.error("Encoding failed. Make sure the input is valid text.")
}
}

Expand All @@ -24,9 +23,8 @@ export function Base64Page() {
const binary = atob(input)
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0))
setOutput(new TextDecoder().decode(bytes))
setError("")
} catch {
setError("Decoding failed. Make sure the input is valid Base64.")
toast.error("Decoding failed. Make sure the input is valid Base64.")
}
}

Expand All @@ -40,7 +38,8 @@ export function Base64Page() {
</div>
<div className="flex flex-col gap-3">
<label className="text-sm font-medium">Input</label>
<Input
<Textarea
rows={6}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Enter text or Base64 string..."
Expand All @@ -52,13 +51,15 @@ export function Base64Page() {
</Button>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{output && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">Output</label>
<div className="rounded-md border bg-muted px-3 py-2 font-mono text-sm break-all">
{output}
</div>
<Textarea
rows={6}
readOnly
value={output}
className="font-mono"
/>
<Button
variant="outline"
size="sm"
Expand Down
135 changes: 87 additions & 48 deletions src/pages/timestamp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,48 +3,82 @@ import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

type TimestampFormats = {
unix: string
unixMilli: string
unixNano: string
iso: string
}

function detectAndConvert(raw: string): TimestampFormats | null {
const num = Number(raw.trim())
if (!raw.trim() || isNaN(num)) return null

const len = String(Math.trunc(num)).length
let ms: number
if (len <= 10) {
ms = num * 1000
} else if (len <= 13) {
ms = num
} else {
ms = Math.trunc(num / 1_000_000)
}

const date = new Date(ms)
if (isNaN(date.getTime())) return null

const unix = String(Math.trunc(ms / 1000))
const unixMilli = String(ms)
const unixNano = String(BigInt(ms) * 1_000_000n)
return { unix, unixMilli, unixNano, iso: date.toISOString() }
}

export function TimestampPage() {
const [tsInput, setTsInput] = useState("")
const [dateInput, setDateInput] = useState("")
const [tsResult, setTsResult] = useState("")
const [dateResult, setDateResult] = useState("")
const [tsResult, setTsResult] = useState<TimestampFormats | null>(null)
const [dateResult, setDateResult] = useState<TimestampFormats | null>(null)
const [tsError, setTsError] = useState("")
const [dateError, setDateError] = useState("")

function convertTimestamp() {
const num = Number(tsInput.trim())
if (!tsInput.trim() || isNaN(num)) {
setTsError("Enter a valid Unix timestamp (seconds or milliseconds).")
setTsResult("")
return
}
// Detect seconds vs milliseconds
const ms = String(num).length <= 10 ? num * 1000 : num
const date = new Date(ms)
if (isNaN(date.getTime())) {
setTsError("Invalid timestamp.")
setTsResult("")
const result = detectAndConvert(tsInput)
if (!result) {
setTsError("Enter a valid Unix timestamp (seconds, milliseconds, or nanoseconds).")
setTsResult(null)
return
}
setTsResult(date.toISOString())
setTsResult(result)
setTsError("")
}

function convertDate() {
const date = new Date(dateInput.trim())
if (isNaN(date.getTime())) {
setDateError("Enter a valid date string (e.g. 2024-01-01 or ISO 8601).")
setDateResult("")
setDateResult(null)
return
}
setDateResult(String(Math.floor(date.getTime() / 1000)))
const ms = date.getTime()
setDateResult({
unix: String(Math.trunc(ms / 1000)),
unixMilli: String(ms),
unixNano: String(BigInt(ms) * 1_000_000n),
iso: date.toISOString(),
})
setDateError("")
}

function useNow() {
const now = Math.floor(Date.now() / 1000)
setTsInput(String(now))
setTsResult(new Date(now * 1000).toISOString())
const ms = Date.now()
const unix = String(Math.trunc(ms / 1000))
setTsInput(unix)
setTsResult({
unix,
unixMilli: String(ms),
unixNano: String(BigInt(ms) * 1_000_000n),
iso: new Date(ms).toISOString(),
})
setTsError("")
}

Expand Down Expand Up @@ -72,20 +106,7 @@ export function TimestampPage() {
</Button>
</div>
{tsError && <p className="text-sm text-destructive">{tsError}</p>}
{tsResult && (
<div className="flex items-center gap-2">
<div className="rounded-md border bg-muted px-3 py-2 font-mono text-sm">
{tsResult}
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigator.clipboard.writeText(tsResult)}
>
Copy
</Button>
</div>
)}
{tsResult && <TimestampResultTable result={tsResult} />}
</div>

{/* Date → Timestamp */}
Expand All @@ -100,21 +121,39 @@ export function TimestampPage() {
<Button onClick={convertDate}>Convert</Button>
</div>
{dateError && <p className="text-sm text-destructive">{dateError}</p>}
{dateResult && (
<div className="flex items-center gap-2">
<div className="rounded-md border bg-muted px-3 py-2 font-mono text-sm">
{dateResult}
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigator.clipboard.writeText(dateResult)}
>
Copy
</Button>
</div>
)}
{dateResult && <TimestampResultTable result={dateResult} />}
</div>
</div>
)
}

function TimestampResultTable({ result }: { result: TimestampFormats }) {
const rows: { label: string; value: string }[] = [
{ label: "ISO 8601", value: result.iso },
{ label: "Unix (s)", value: result.unix },
{ label: "Unix Milli (ms)", value: result.unixMilli },
{ label: "Unix Nano (ns)", value: result.unixNano },
]

return (
<div className="flex flex-col gap-1">
{rows.map(({ label, value }) => (
<div key={label} className="flex items-center gap-2">
<span className="w-36 shrink-0 text-xs text-muted-foreground">
{label}
</span>
<div className="rounded-md border bg-muted px-3 py-2 font-mono text-sm">
{value}
</div>
<Button
variant="outline"
size="sm"
onClick={() => navigator.clipboard.writeText(value)}
>
Copy
</Button>
</div>
))}
</div>
)
}
Loading