[FA-18] Frontend bootstrapped

This commit is contained in:
gamer147
2025-11-24 13:25:29 -05:00
parent 16ed16ff62
commit e8596b67c4
42 changed files with 9747 additions and 2 deletions

View File

@@ -0,0 +1,103 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useAuth } from '../auth/AuthContext'
import { Button } from './ui/button'
export function AuthenticationDisplay() {
const { user, isConfigured, isLoading, login, logout } = useAuth()
const [isOpen, setIsOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const email = useMemo(
() =>
user?.profile?.email ??
user?.profile?.preferred_username ??
user?.profile?.name ??
user?.profile?.sub ??
null,
[user],
)
useEffect(() => {
if (!isOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (!menuRef.current) return
if (!menuRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setIsOpen(false)
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])
if (isLoading) {
return (
<Button variant="outline" size="sm" disabled>
Loading...
</Button>
)
}
if (!isConfigured) {
return (
<Button
variant="secondary"
size="sm"
onClick={() =>
alert('OIDC is not configured. Set VITE_OIDC_* environment variables to enable login.')
}
>
Configure OIDC
</Button>
)
}
if (!user) {
return (
<Button variant="secondary" size="sm" onClick={login}>
Login
</Button>
)
}
return (
<div className="relative" ref={menuRef}>
<Button
variant="ghost"
size="sm"
className="flex items-center gap-2 text-foreground"
onClick={() => setIsOpen((open) => !open)}
aria-expanded={isOpen}
aria-haspopup="menu"
>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
{(email ?? 'User').slice(0, 1).toUpperCase()}
</span>
<span className="max-w-[12ch] truncate">{email ?? 'User'}</span>
</Button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-56 rounded-lg border bg-white p-2 shadow-lg">
<div className="px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Signed in
</p>
<p className="truncate text-sm font-medium text-foreground">{email ?? 'User'}</p>
</div>
<Button variant="ghost" size="sm" className="w-full justify-start" onClick={logout}>
Log out
</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,50 @@
import { Link, NavLink } from 'react-router-dom'
import { AuthenticationDisplay } from './AuthenticationDisplay'
import { Button } from './ui/button'
import { Input } from './ui/input'
export function Navbar() {
return (
<nav className="sticky top-0 z-10 border-b bg-white/80 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center gap-6 px-4 py-3 sm:px-6 lg:px-8">
<div className="flex items-center gap-3">
<Link to="/" className="flex items-center gap-3">
<div
className="flex h-10 w-10 select-none items-center justify-center rounded-xl bg-primary text-primary-foreground font-bold shadow-md shadow-primary/20"
translate="no"
aria-label="FictionArchive"
>
FA
</div>
<div>
<p className="text-sm font-semibold text-foreground">FictionArchive</p>
<p className="text-xs text-muted-foreground">GraphQL playground</p>
</div>
</Link>
</div>
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<Button variant="ghost" size="sm" asChild>
<NavLink
to="/"
className={({ isActive }) =>
isActive ? 'text-foreground' : 'text-muted-foreground'
}
>
Novels
</NavLink>
</Button>
</div>
<div className="ml-auto flex items-center gap-3">
<div className="flex items-center gap-2">
<Input
placeholder="Search"
className="w-48 sm:w-64"
aria-label="Search"
/>
</div>
<AuthenticationDisplay />
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,47 @@
import type { Novel } from '../__generated__/graphql'
import { Card, CardContent, CardHeader, CardTitle } from './ui/card'
type NovelCardProps = {
novel: Novel
}
function pickText(novelText?: Novel['name'] | Novel['description']) {
const texts = novelText?.texts ?? []
const english = texts.find((t) => t.language === 'EN')
return (english ?? texts[0])?.text ?? 'No description available.'
}
export function NovelCard({ novel }: NovelCardProps) {
const title = pickText(novel.name)
const description = pickText(novel.description)
const cover = novel.coverImage
const coverSrc = cover?.newPath ?? cover?.originalPath
return (
<Card className="overflow-hidden border shadow-sm hover:shadow-md transition-shadow">
{coverSrc ? (
<div className="aspect-[3/4] w-full overflow-hidden bg-muted/50">
<img
src={coverSrc}
alt={title}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
) : (
<div className="aspect-[3/4] w-full bg-muted/50" />
)}
<CardHeader className="space-y-2">
<CardTitle className="line-clamp-2 text-lg leading-tight">
{title}
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<p className="line-clamp-3 text-sm text-muted-foreground">
{description}
</p>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,34 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,54 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground shadow-sm',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button'
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -0,0 +1,76 @@
import * as React from 'react'
import { cn } from '../../lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-xl border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-2xl font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,25 @@
import * as React from 'react'
import { cn } from '../../lib/utils'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = 'Input'
export { Input }