Skip to content
Draft
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
47 changes: 46 additions & 1 deletion web/package-lock.json

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

6 changes: 5 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@
"lint": "next lint"
},
"dependencies": {
"clsx": "^2.1.1",
"js-md5": "^0.8.3",
"next": "15.1.7",
"oidc-client-ts": "^3.5.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
Expand Down
23 changes: 23 additions & 0 deletions web/src/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { VT323, Silkscreen, Press_Start_2P, Geist, Geist_Mono } from "next/font/google"
import '@/styles/globals.css'

const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] })
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] })
const pressStart2P = Press_Start_2P({ weight: "400", variable: "--font-press-start", subsets: ["latin"] })
const vt323 = VT323({ weight: "400", variable: "--font-vt323", subsets: ["latin"] })
const silkscreen = Silkscreen({ weight: ["400", "700"], variable: "--font-silkscreen", subsets: ["latin"] })

export default function AuthLayout({ children }: { children: React.ReactNode }) {
const fontVars = `${geistSans.variable} ${geistMono.variable} ${pressStart2P.variable} ${vt323.variable} ${silkscreen.variable}`
return (
<div className={`${fontVars} antialiased`}>
<div
className="pointer-events-none fixed inset-0 z-50"
style={{
background: "repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px)"
}}
/>
{children}
</div>
)
}
100 changes: 100 additions & 0 deletions web/src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"use client"

import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import MetalPanelScrewed from "@/components/ui/metal-panel-screwed";
import QrLogo from "@/components/ui/qr-logo";
import HudInput from "@/components/ui/hud-input";
import HudButton from "@/components/ui/hud-button";
import StatusLine, { type StatusVariant } from "@/components/ui/status-line";
import { getUserManager } from "@/lib/auth";
import { hashCredentials } from "@/lib/utils";
import Splash from "@/components/ui/splash";


type LoginStatus = {
message: string
variant: StatusVariant
}

export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginContent />
</Suspense>
);
}

function LoginContent() {
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [status, setStatus] = useState<LoginStatus>({ message: "", variant: "idle" })

const router = useRouter()
const searchParams = useSearchParams()

async function handleLogin() {
if (!username || !password) {
setStatus({ message: "fill in all fields", variant: "error" })
return
}

setStatus({ message: "authenticating...", variant: "busy" })

try {
await getUserManager().signinResourceOwnerCredentials({
username,
password: hashCredentials(username, password),
})
setStatus({ message: "OK", variant: "ok" })

await new Promise(r => setTimeout(r, 600))

const raw = searchParams.get("redirect") ?? "/"
const redirect = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/"

router.push(redirect)
} catch (e: unknown) {
const message = e instanceof Error ? e.message : "connection error"
setStatus({ message, variant: "error" })
}
}

return (
<main className="min-h-screen flex items-center justify-center bg-base-500">
<MetalPanelScrewed className="w-[280px]">

<Splash />
<QrLogo />
<div className="flex flex-col gap-3">
<HudInput
label="Username"
id="username"
value={username}
onChange={e => setUsername(e.target.value)}
/>
<HudInput
label="Password"
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</div>

<HudButton
className="mt-6"
onClick={handleLogin}
disabled={status.variant === "busy" || !username || !password}
>
LOGIN
</HudButton>

<StatusLine message={status.message} variant={status.variant} />


</MetalPanelScrewed>

</main>
)
}
29 changes: 29 additions & 0 deletions web/src/components/ui/decorations/screw.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type ScrewSlot = "tl" | "tr" | "bl" | "br"

interface ScrewProps {
slot: ScrewSlot
}

const positions: Record<ScrewSlot, string> = {
tl: "top-[10px] left-[10px]",
tr: "top-[10px] right-[10px]",
bl: "bottom-[10px] left-[10px]",
br: "bottom-[10px] right-[10px]",
}

export default function Screw({ slot }: ScrewProps) {
return (
<div className={`absolute ${positions[slot]} w-[14px] h-[14px] rounded-full
bg-[radial-gradient(circle_at_35%_35%,var(--screw-shine),var(--screw-bg)_50%,#1a1a1a)]
border border-[#111]
shadow-[0_1px_2px_rgba(0,0,0,0.8),inset_0_1px_0_rgba(255,255,255,0.08)]`}
>
{/* horizontal bar of the Phillips cross */}
<span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
w-[8px] h-[1.5px] bg-black/60 rounded-sm rotate-45 block" />
{/* vertical bar */}
<span className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
w-[8px] h-[1.5px] bg-black/60 rounded-sm -rotate-45 block" />
</div>
)
}
52 changes: 52 additions & 0 deletions web/src/components/ui/hud-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { cn } from "@/lib/utils"

interface HudButtonProps {
children: React.ReactNode
onClick?: () => void
disabled?: boolean
className?: string
}

export default function HudButton({
children,
onClick,
disabled = false,
className,
}: HudButtonProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={cn(
// base
"w-full py-3.5 rounded font-vt323 text-[26px] tracking-[6px] uppercase",
"transition-[background,box-shadow,transform,color] duration-100",
"outline-none border border-[#2a0505]",

// popped out, unlit
"bg-[#3a0a0a] text-[#7a3333]",
"shadow-[0_4px_0_#1a0303,0_6px_12px_rgba(0,0,0,0.5)]",
"translate-y-0",

// hover
"hover:bg-[#5c0f0f] hover:text-btn-login-text",
"hover:shadow-[0_4px_0_#1a0303,0_6px_12px_rgba(0,0,0,0.5),inset_0_0_18px_rgba(255,50,50,0.2),0_0_12px_rgba(255,50,50,0.15)]",

// pressed
"active:bg-[#4a0c0c] active:text-[#ff4444]",
"active:translate-y-[3px]",
"active:shadow-[0_1px_0_#1a0303,inset_0_0_18px_rgba(255,50,50,0.3),0_0_8px_rgba(255,50,50,0.2)]",

// disabled
"disabled:bg-[#1a0505] disabled:text-[#3a1a1a]",
"disabled:translate-y-[3px]",
"disabled:shadow-[0_1px_0_#0a0101]",
"disabled:cursor-not-allowed",

className
)}
>
{children}
</button>
)
}
53 changes: 53 additions & 0 deletions web/src/components/ui/hud-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { cn } from "@/lib/utils"

interface HudInputProps {
label: string
id: string
type?: "text" | "password"
placeholder?: string
autoComplete?: string
className?: string
value: string
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
}

export default function HudInput({
label,
id,
type = "text",
placeholder,
autoComplete,
className,
value,
onChange,
}: HudInputProps) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<label
htmlFor={id}
className="font-mono text-[11px] tracking-[2px] uppercase text-[var(--panel-groove)]"
>
{label}
</label>
<input
id={id}
name={id}
type={type}
placeholder={placeholder}
autoComplete={autoComplete}
value={value}
onChange={onChange}
spellCheck={false}
className={cn(
"font-vt323 text-2xl",
"w-full rounded bg-input-bg border border-input-border",
"px-3 py-2.5 text-input-text",
"shadow-input caret-[var(--input-text)]",
"placeholder:text-[var(--input-placeholder)]",
"outline-none transition-[border-color,box-shadow] duration-150",
"focus:border-input-focus focus:shadow-input-focus",
)}
/>
</div>
)
}
18 changes: 18 additions & 0 deletions web/src/components/ui/metal-panel-screwed-h.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import MetalPanel from "@/components/ui/metal-panel"
import Screw from "@/components/ui/decorations/screw"
import { cn } from "@/lib/utils"

interface MetalPanelScrewedHProps {
children: React.ReactNode
className?: string
}

export default function MetalPanelScrewedH({ children, className }: MetalPanelScrewedHProps) {
return (
<MetalPanel className={cn("px-10 py-4", className)}>
<Screw slot="tl" />
<Screw slot="tr" />
{children}
</MetalPanel>
)
}
Loading
Loading