33 - Add terminal UI primitives and base layout
This commit is contained in:
parent
d72b672b23
commit
28757d1e6b
6 changed files with 137 additions and 6 deletions
|
|
@ -1,11 +1,20 @@
|
||||||
|
import AppLayout from './components/layout/AppLayout'
|
||||||
|
import Button from './components/ui/Button'
|
||||||
|
import DigitalProgressBar from './components/ui/DigitalProgressBar'
|
||||||
|
import Panel from './components/ui/Panel'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 font-mono">
|
<AppLayout>
|
||||||
<h1 className="text-primary text-4xl font-bold uppercase tracking-widest">
|
<Panel className="mx-auto max-w-md space-y-4">
|
||||||
BUCKETS
|
<h2 className="text-primary font-bold uppercase tracking-wider">
|
||||||
</h1>
|
Emergency Fund
|
||||||
<p className="font-digital text-primary text-2xl">1234567890</p>
|
</h2>
|
||||||
</div>
|
<DigitalProgressBar current={750} capacity={1000} />
|
||||||
|
<p className="font-digital text-primary text-xl">$750 / $1000</p>
|
||||||
|
<Button glow>+ Add Bucket</Button>
|
||||||
|
</Panel>
|
||||||
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
18
frontend/src/components/layout/AppLayout.tsx
Normal file
18
frontend/src/components/layout/AppLayout.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background text-foreground min-h-screen font-mono">
|
||||||
|
<header className="glow-red border-b-4 border-red-500 px-6 py-4">
|
||||||
|
<h1 className="text-primary text-2xl font-bold uppercase tracking-widest">
|
||||||
|
BUCKETS
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
<main className="p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
frontend/src/components/ui/Button.tsx
Normal file
19
frontend/src/components/ui/Button.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ButtonHTMLAttributes } from 'react'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
glow?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({ glow, className, ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'border-2 border-red-500 bg-black px-4 py-2 font-mono font-bold uppercase tracking-wider text-red-500 transition-colors hover:bg-red-500 hover:text-black',
|
||||||
|
glow && 'glow-red',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
frontend/src/components/ui/DigitalProgressBar.tsx
Normal file
59
frontend/src/components/ui/DigitalProgressBar.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
interface DigitalProgressBarProps {
|
||||||
|
current: number
|
||||||
|
capacity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAR_COUNT = 20
|
||||||
|
|
||||||
|
// Zone colors are driven by the theme's chart tokens (chart-1 red → chart-2
|
||||||
|
// amber → chart-3 green) so they stay in sync with the palette; the glow is
|
||||||
|
// derived from the same token via color-mix rather than a duplicated literal.
|
||||||
|
function getZoneToken(index: number): string {
|
||||||
|
if (index < 10) return 'var(--chart-1)'
|
||||||
|
if (index < 18) return 'var(--chart-2)'
|
||||||
|
return 'var(--chart-3)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBarColor(index: number): string {
|
||||||
|
if (index < 10) return 'bg-chart-1'
|
||||||
|
if (index < 18) return 'bg-chart-2'
|
||||||
|
return 'bg-chart-3'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DigitalProgressBar({
|
||||||
|
current,
|
||||||
|
capacity,
|
||||||
|
}: DigitalProgressBarProps) {
|
||||||
|
const filledBars =
|
||||||
|
capacity > 0
|
||||||
|
? Math.min(Math.round((current / capacity) * BAR_COUNT), BAR_COUNT)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${BAR_COUNT}, 1fr)`,
|
||||||
|
gap: '10px',
|
||||||
|
height: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: BAR_COUNT }, (_, i) => {
|
||||||
|
const filled = i < filledBars
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
borderRadius: '3px',
|
||||||
|
transition: 'background-color 300ms, box-shadow 300ms',
|
||||||
|
boxShadow: filled
|
||||||
|
? `0 0 8px color-mix(in oklch, ${getZoneToken(i)} 60%, transparent)`
|
||||||
|
: 'none',
|
||||||
|
}}
|
||||||
|
className={filled ? getBarColor(i) : 'bg-chart-1/10'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/components/ui/Panel.tsx
Normal file
13
frontend/src/components/ui/Panel.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import type { HTMLAttributes } from 'react'
|
||||||
|
import { cn } from '../../lib/cn'
|
||||||
|
|
||||||
|
type PanelProps = HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
|
export default function Panel({ className, ...props }: PanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('glow-red border-4 border-red-500 bg-black p-6', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/lib/cn.ts
Normal file
13
frontend/src/lib/cn.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* Minimal className joiner — filters out falsy values and joins with spaces.
|
||||||
|
* Avoids pulling clsx / tailwind-merge for the small primitive set here.
|
||||||
|
*
|
||||||
|
* Note: this does NOT resolve conflicting Tailwind utilities. If a consumer
|
||||||
|
* passes a class that conflicts with a default (e.g. bg-blue-500 over bg-black),
|
||||||
|
* both land in the DOM and CSS source order decides the winner — not "last wins".
|
||||||
|
*/
|
||||||
|
export function cn(
|
||||||
|
...classes: Array<string | false | null | undefined>
|
||||||
|
): string {
|
||||||
|
return classes.filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue