feat(frontend): add react spa with wishlist flows and public profile
This commit is contained in:
15
apps/frontend/src/components/Layout/AppShell.tsx
Normal file
15
apps/frontend/src/components/Layout/AppShell.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Header } from './Header';
|
||||
import { Footer } from './Footer';
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="container-page flex-1 py-6 sm:py-10">
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/frontend/src/components/Layout/Footer.tsx
Normal file
32
apps/frontend/src/components/Layout/Footer.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Gift } from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { FRONTEND_VERSION } from '@/lib/version';
|
||||
|
||||
interface VersionInfo {
|
||||
backend: string;
|
||||
}
|
||||
|
||||
export function Footer() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['version'],
|
||||
queryFn: () => api.get<VersionInfo>('/api/version'),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<footer className="container-page mt-10 py-6 text-xs text-muted">
|
||||
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gift className="h-4 w-4" aria-hidden />
|
||||
<span className="font-display text-sm">Family Wishlist</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span>frontend v{FRONTEND_VERSION}</span>
|
||||
<span className="opacity-50">·</span>
|
||||
<span>backend v{data?.backend ?? '...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
79
apps/frontend/src/components/Layout/Header.tsx
Normal file
79
apps/frontend/src/components/Layout/Header.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { Archive, Gift, LogOut, Sparkles, Trash2, UserCog, CheckCircle2 } from 'lucide-react';
|
||||
import { Button } from '../ui/Button';
|
||||
import { useAuthStore } from '@/features/auth/authStore';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const links = [
|
||||
{ to: '/', label: 'Active', icon: Sparkles, end: true },
|
||||
{ to: '/completed', label: 'Fulfilled', icon: CheckCircle2 },
|
||||
{ to: '/archive', label: 'Archive', icon: Archive },
|
||||
{ to: '/trash', label: 'Trash', icon: Trash2 },
|
||||
];
|
||||
|
||||
export function Header() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<header className="container-page pt-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-surface/80 px-4 py-3 backdrop-blur">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-md bg-primary text-primary-foreground shadow-card">
|
||||
<Gift className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-display text-lg leading-tight">Family Wishlist</div>
|
||||
<div className="text-xs text-muted">
|
||||
signed in as <span className="font-medium text-ink">{user.displayName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<nav className="flex flex-wrap items-center gap-1">
|
||||
{links.map((l) => (
|
||||
<NavLink
|
||||
key={l.to}
|
||||
to={l.to}
|
||||
end={l.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
isActive ? 'bg-primary text-primary-foreground shadow-card' : 'text-ink hover:bg-ink/5',
|
||||
)
|
||||
}
|
||||
>
|
||||
<l.icon className="h-4 w-4" />
|
||||
{l.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/settings')}
|
||||
title="Profile settings"
|
||||
>
|
||||
<UserCog className="h-4 w-4" />
|
||||
Profile
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void logout().then(() => navigate('/login'));
|
||||
}}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Log out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
17
apps/frontend/src/components/Layout/ProtectedRoute.tsx
Normal file
17
apps/frontend/src/components/Layout/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useAuthStore } from '@/features/auth/authStore';
|
||||
|
||||
export function ProtectedRoute({ children }: { children: ReactNode }) {
|
||||
const { user, status } = useAuthStore();
|
||||
const location = useLocation();
|
||||
if (status !== 'ready') {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center text-muted">Loading...</div>
|
||||
);
|
||||
}
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
51
apps/frontend/src/components/WishBadges/WishBadges.tsx
Normal file
51
apps/frontend/src/components/WishBadges/WishBadges.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Check, Sparkles, Archive, Trash2 } from 'lucide-react';
|
||||
import type { Wish } from '@family-wishlist/shared';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
interface Props {
|
||||
wish: Wish & { isNewForOwner?: boolean };
|
||||
view: 'owner' | 'guest';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WishBadges({ wish, view, className }: Props) {
|
||||
const badges: JSX.Element[] = [];
|
||||
|
||||
const isNew =
|
||||
view === 'owner' ? wish.isNewForOwner === true : wish.isNewForGuest === true;
|
||||
if (isNew && wish.status === 'ACTIVE') {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--new" key="new">
|
||||
<Sparkles className="h-3 w-3" aria-hidden />
|
||||
new
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (wish.status === 'COMPLETED') {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--completed" key="done">
|
||||
<Check className="h-3 w-3" aria-hidden />
|
||||
fulfilled
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (wish.status === 'ARCHIVED' && view === 'owner') {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--archived" key="arch">
|
||||
<Archive className="h-3 w-3" aria-hidden />
|
||||
archived
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
if (wish.status === 'DELETED' && view === 'owner') {
|
||||
badges.push(
|
||||
<span className="wish-badge wish-badge--deleted" key="del">
|
||||
<Trash2 className="h-3 w-3" aria-hidden />
|
||||
trash
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
|
||||
if (badges.length === 0) return null;
|
||||
return <div className={cn('flex flex-wrap gap-1.5', className)}>{badges}</div>;
|
||||
}
|
||||
191
apps/frontend/src/components/WishCard/WishCard.tsx
Normal file
191
apps/frontend/src/components/WishCard/WishCard.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { memo, useState } from 'react';
|
||||
import type { Wish } from '@family-wishlist/shared';
|
||||
import {
|
||||
Archive,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { WishBadges } from '../WishBadges/WishBadges';
|
||||
import { Button } from '../ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { formatPrice } from '@/lib/format';
|
||||
|
||||
export type WishCardView = 'owner' | 'guest';
|
||||
|
||||
export interface WishCardAction {
|
||||
key: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface WishCardProps {
|
||||
wish: Wish & { isNewForOwner?: boolean };
|
||||
view: WishCardView;
|
||||
onEdit?: () => void;
|
||||
onArchive?: () => void;
|
||||
onComplete?: () => void;
|
||||
onRestore?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
onDelete?: () => void;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
function WishCardInner({
|
||||
wish,
|
||||
view,
|
||||
onEdit,
|
||||
onArchive,
|
||||
onComplete,
|
||||
onRestore,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
footer,
|
||||
}: WishCardProps) {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const completed = wish.status === 'COMPLETED';
|
||||
const priceLabel = formatPrice(wish.price, wish.currency);
|
||||
const imageSrc = wish.imageUrl ?? '/default-gift.svg';
|
||||
|
||||
const actions: WishCardAction[] = [];
|
||||
if (view === 'owner') {
|
||||
if (wish.status === 'ACTIVE') {
|
||||
if (onEdit) actions.push({ key: 'edit', icon: Pencil, label: 'Edit', onClick: onEdit });
|
||||
if (onComplete)
|
||||
actions.push({
|
||||
key: 'complete',
|
||||
icon: CheckCircle2,
|
||||
label: 'Mark fulfilled',
|
||||
onClick: onComplete,
|
||||
});
|
||||
if (onArchive)
|
||||
actions.push({ key: 'archive', icon: Archive, label: 'Archive', onClick: onArchive });
|
||||
if (onDelete)
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
onClick: onDelete,
|
||||
danger: true,
|
||||
});
|
||||
} else if (wish.status === 'ARCHIVED') {
|
||||
if (onRestore)
|
||||
actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore });
|
||||
if (onDelete)
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
onClick: onDelete,
|
||||
danger: true,
|
||||
});
|
||||
} else if (wish.status === 'COMPLETED') {
|
||||
if (onDuplicate)
|
||||
actions.push({
|
||||
key: 'duplicate',
|
||||
icon: Copy,
|
||||
label: 'Create copy as new',
|
||||
onClick: onDuplicate,
|
||||
});
|
||||
if (onDelete)
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
icon: Trash2,
|
||||
label: 'Delete',
|
||||
onClick: onDelete,
|
||||
danger: true,
|
||||
});
|
||||
} else if (wish.status === 'DELETED') {
|
||||
if (onRestore)
|
||||
actions.push({ key: 'restore', icon: RotateCcw, label: 'Restore', onClick: onRestore });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className={cn('wish-card', completed && 'wish-card--completed')}>
|
||||
<div className="wish-card__image-wrap">
|
||||
<img
|
||||
className="wish-card__image"
|
||||
src={imageSrc}
|
||||
alt={wish.title}
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).src = '/default-gift.svg';
|
||||
}}
|
||||
/>
|
||||
<div className="absolute left-3 top-3">
|
||||
<WishBadges wish={wish} view={view} />
|
||||
</div>
|
||||
{actions.length > 0 && (
|
||||
<div className="absolute right-3 top-3">
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
aria-label="Actions"
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
{menuOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute right-0 z-20 mt-2 w-48 overflow-hidden rounded-md border border-border bg-surface shadow-pop">
|
||||
{actions.map((a) => (
|
||||
<button
|
||||
key={a.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
a.onClick();
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-surface-muted',
|
||||
a.danger && 'text-danger',
|
||||
)}
|
||||
>
|
||||
<a.icon className="h-4 w-4" />
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="wish-card__body">
|
||||
<h3 className="wish-card__title">{wish.title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{priceLabel && <span className="wish-card__price">{priceLabel}</span>}
|
||||
{wish.url && (
|
||||
<a
|
||||
href={wish.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:text-primary-600"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
open link
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{wish.comment && <p className="wish-card__comment">{wish.comment}</p>}
|
||||
{footer}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export const WishCard = memo(WishCardInner);
|
||||
197
apps/frontend/src/components/WishForm/WishForm.tsx
Normal file
197
apps/frontend/src/components/WishForm/WishForm.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { createWishSchema, type CreateWishInput, type Wish } from '@family-wishlist/shared';
|
||||
import { Button } from '../ui/Button';
|
||||
import { Input } from '../ui/Input';
|
||||
import { Textarea } from '../ui/Textarea';
|
||||
import { Label } from '../ui/Label';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { ImageIcon, Loader2, RefreshCcw, Trash2, Upload } from 'lucide-react';
|
||||
import {
|
||||
useCreateWish,
|
||||
useRefreshOg,
|
||||
useResetWishImage,
|
||||
useUpdateWish,
|
||||
useUploadWishImage,
|
||||
} from '@/features/wishes/wishes.hooks';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
mode: 'create' | 'edit';
|
||||
initial?: Wish;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WishForm({ open, mode, initial, onClose }: Props) {
|
||||
const create = useCreateWish();
|
||||
const update = useUpdateWish();
|
||||
const upload = useUploadWishImage();
|
||||
const refreshOg = useRefreshOg();
|
||||
const resetImage = useResetWishImage();
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingId, setPendingId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CreateWishInput>({
|
||||
resolver: zodResolver(createWishSchema),
|
||||
defaultValues: {
|
||||
title: initial?.title ?? '',
|
||||
price: initial?.price ?? '',
|
||||
currency: initial?.currency ?? 'RUB',
|
||||
url: initial?.url ?? '',
|
||||
comment: initial?.comment ?? '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
reset({
|
||||
title: initial?.title ?? '',
|
||||
price: initial?.price ?? '',
|
||||
currency: initial?.currency ?? 'RUB',
|
||||
url: initial?.url ?? '',
|
||||
comment: initial?.comment ?? '',
|
||||
});
|
||||
setPendingId(initial?.id ?? null);
|
||||
}, [open, initial, reset]);
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
if (mode === 'create') {
|
||||
const created = await create.mutateAsync(values);
|
||||
setPendingId(created.id);
|
||||
} else if (initial) {
|
||||
await update.mutateAsync({ id: initial.id, input: values });
|
||||
}
|
||||
onClose();
|
||||
});
|
||||
|
||||
const activeId = pendingId ?? initial?.id ?? null;
|
||||
const canEditImage = activeId != null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={mode === 'create' ? 'Add a wish' : 'Edit wish'}
|
||||
description={
|
||||
mode === 'create'
|
||||
? 'Tell us what you want. A link helps us grab a preview image automatically.'
|
||||
: 'Update the details of your wish.'
|
||||
}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="wish-form" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{mode === 'create' ? 'Add wish' : 'Save'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="wish-form" className="grid gap-4" onSubmit={submit}>
|
||||
<div className="field">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input id="title" placeholder="Moka pot, size 3" {...register('title')} />
|
||||
{errors.title && <span className="field__error">{errors.title.message}</span>}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-[1fr_auto]">
|
||||
<div className="field">
|
||||
<Label htmlFor="price">Price (optional)</Label>
|
||||
<Input id="price" placeholder="e.g. 2490" inputMode="decimal" {...register('price')} />
|
||||
{errors.price && <span className="field__error">{errors.price.message as string}</span>}
|
||||
</div>
|
||||
<div className="field">
|
||||
<Label htmlFor="currency">Currency</Label>
|
||||
<Input id="currency" maxLength={3} className="uppercase w-24" {...register('currency')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<Label htmlFor="url">Link (optional)</Label>
|
||||
<Input id="url" type="url" placeholder="https://..." {...register('url')} />
|
||||
{errors.url && <span className="field__error">{errors.url.message as string}</span>}
|
||||
<p className="text-xs text-muted">
|
||||
We will try to pull a preview image from the link after saving.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<Label htmlFor="comment">Comment (optional)</Label>
|
||||
<Textarea
|
||||
id="comment"
|
||||
rows={3}
|
||||
placeholder="Size / color / notes..."
|
||||
{...register('comment')}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{canEditImage && activeId && (
|
||||
<section className="mt-6 rounded-md border border-border bg-surface-muted p-4">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-medium text-ink">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Image
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void upload.mutateAsync({ id: activeId, file });
|
||||
e.currentTarget.value = '';
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={upload.isPending}
|
||||
>
|
||||
{upload.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4" />
|
||||
)}
|
||||
Upload custom
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshOg.mutateAsync(activeId)}
|
||||
disabled={refreshOg.isPending}
|
||||
>
|
||||
{refreshOg.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCcw className="h-4 w-4" />
|
||||
)}
|
||||
Refresh from link
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void resetImage.mutateAsync(activeId)}
|
||||
disabled={resetImage.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Reset to default
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
42
apps/frontend/src/components/ui/Button.tsx
Normal file
42
apps/frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline';
|
||||
type Size = 'sm' | 'md' | 'lg' | 'icon';
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-all duration-150 focus:outline-none focus-visible:shadow-focus disabled:cursor-not-allowed disabled:opacity-50';
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary: 'bg-primary text-primary-foreground hover:bg-primary-600 shadow-card',
|
||||
secondary: 'bg-surface text-ink shadow-card hover:bg-surface-muted',
|
||||
ghost: 'text-ink hover:bg-ink/5',
|
||||
outline: 'border border-border bg-surface text-ink hover:bg-surface-muted',
|
||||
danger: 'bg-danger text-white hover:brightness-95 shadow-card',
|
||||
};
|
||||
|
||||
const sizes: Record<Size, string> = {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-sm',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
icon: 'h-9 w-9',
|
||||
};
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ className, variant = 'primary', size = 'md', type = 'button', ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(base, variants[variant], sizes[size], className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
8
apps/frontend/src/components/ui/Input.tsx
Normal file
8
apps/frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
function Input({ className, ...rest }, ref) {
|
||||
return <input ref={ref} className={cn('field__input', className)} {...rest} />;
|
||||
},
|
||||
);
|
||||
6
apps/frontend/src/components/ui/Label.tsx
Normal file
6
apps/frontend/src/components/ui/Label.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { LabelHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export function Label({ className, ...rest }: LabelHTMLAttributes<HTMLLabelElement>) {
|
||||
return <label className={cn('field__label', className)} {...rest} />;
|
||||
}
|
||||
78
apps/frontend/src/components/ui/Modal.tsx
Normal file
78
apps/frontend/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/cn';
|
||||
import { Button } from './Button';
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
size?: 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
size = 'md',
|
||||
}: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-6">
|
||||
<div
|
||||
className="absolute inset-0 bg-ink/40 backdrop-blur-sm animate-fade-in-up"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
'relative w-full bg-surface shadow-pop animate-fade-in-up',
|
||||
'rounded-t-xl sm:rounded-xl',
|
||||
size === 'md' ? 'sm:max-w-lg' : 'sm:max-w-2xl',
|
||||
'max-h-[90vh] overflow-hidden flex flex-col',
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
||||
{description && <p className="mt-1 text-sm text-muted">{description}</p>}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} aria-label="Close">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="overflow-y-auto px-5 py-5">{children}</div>
|
||||
{footer && (
|
||||
<footer className="flex items-center justify-end gap-2 border-t border-border px-5 py-4">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
16
apps/frontend/src/components/ui/Textarea.tsx
Normal file
16
apps/frontend/src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { forwardRef, type TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export const Textarea = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(function Textarea({ className, rows = 3, ...rest }, ref) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
rows={rows}
|
||||
className={cn('field__textarea', className)}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user