diff options
| author | IwanIDev <iwan@iwani.dev> | 2026-03-20 15:20:27 +0000 |
|---|---|---|
| committer | IwanIDev <iwan@iwani.dev> | 2026-03-20 15:20:27 +0000 |
| commit | c491fd65470b5b952865966019e4fc28cf5014bb (patch) | |
| tree | 877e2b323bf1d667463b2d68845ed646463cfe03 | |
| parent | 3d7963641db5e5c2fa92b088770162918003a7ff (diff) | |
Using shadcn/ui for this site.
| -rw-r--r-- | src/App.tsx | 86 | ||||
| -rw-r--r-- | src/components/ui/alert.tsx | 76 | ||||
| -rw-r--r-- | src/components/ui/badge.tsx | 52 | ||||
| -rw-r--r-- | src/components/ui/card.tsx | 103 | ||||
| -rw-r--r-- | src/components/ui/separator.tsx | 23 |
5 files changed, 320 insertions, 20 deletions
diff --git a/src/App.tsx b/src/App.tsx index 3472fd0..396af37 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,10 @@ import { useEffect, useState } from 'react' import { fetchDrupalResource } from './lib/drupalClient' -import './App.css' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { Separator } from '@/components/ui/separator' type DrupalArticle = { id: string @@ -56,34 +60,76 @@ function App() { void loadArticles() }, []) - return ( - <main> - <h1 className="mb-4 text-2xl font-bold">Articles</h1> - {!isLoading && !error && resourcePath && <p>Using resource: {resourcePath}</p>} + const articleCount = articles.length + + const formatDate = (value?: string) => { + if (!value) { + return 'Unknown date' + } - {isLoading && <p>Loading articles…</p>} + return new Date(value).toLocaleString() + } + + return ( + <main className="mx-auto flex min-h-screen w-full max-w-5xl flex-col gap-6 px-4 py-10 md:px-6"> + <Card> + <CardHeader> + <CardTitle className="text-2xl">Portfolio CMS Homepage</CardTitle> + <CardDescription>This homepage now uses shadcn/ui components and reads content from Drupal JSON:API.</CardDescription> + </CardHeader> + <CardContent className="flex flex-wrap items-center gap-3"> + <Badge variant={isLoading ? 'secondary' : error ? 'destructive' : 'default'}> + {isLoading ? 'Loading' : error ? 'Error' : 'Connected'} + </Badge> + <Badge variant="outline">Articles: {articleCount}</Badge> + {resourcePath && <Badge variant="outline">Resource: {resourcePath}</Badge>} + </CardContent> + <CardFooter className="justify-between gap-3"> + <span className="text-muted-foreground">Headless Drupal + Vite + shadcn/ui</span> + <Button type="button" onClick={() => window.location.reload()}> + Refresh + </Button> + </CardFooter> + </Card> {error && ( - <p role="alert" className="text-red-600"> - Could not fetch articles: {error} - </p> + <Alert variant="destructive"> + <AlertTitle>Could not fetch articles</AlertTitle> + <AlertDescription>{error}</AlertDescription> + </Alert> )} - {!isLoading && !error && articles.length === 0 && ( - <p>Connected to Drupal, but no articles were returned.</p> + {!isLoading && !error && articleCount === 0 && ( + <Alert> + <AlertTitle>No articles found</AlertTitle> + <AlertDescription>Connected to Drupal successfully, but this resource currently has no content.</AlertDescription> + </Alert> )} - {!isLoading && !error && articles.length > 0 && ( - <ul style={{ textAlign: 'left' }}> + {!isLoading && !error && articleCount > 0 && ( + <section className="grid gap-4 md:grid-cols-2"> {articles.map((article) => ( - <li key={article.id}> - <strong>{article.attributes?.title ?? '(Untitled)'}</strong> - <div>ID: {article.id}</div> - <div>Status: {article.attributes?.status ? 'Published' : 'Unpublished'}</div> - {article.attributes?.created && <div>Created: {new Date(article.attributes.created).toLocaleString()}</div>} - </li> + <Card key={article.id} size="sm"> + <CardHeader className="gap-2"> + <CardTitle>{article.attributes?.title ?? '(Untitled)'}</CardTitle> + <CardDescription>{article.id}</CardDescription> + </CardHeader> + <Separator /> + <CardContent className="space-y-2 pt-3"> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">Status</span> + <Badge variant={article.attributes?.status ? 'default' : 'secondary'}> + {article.attributes?.status ? 'Published' : 'Unpublished'} + </Badge> + </div> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">Created</span> + <span>{formatDate(article.attributes?.created)}</span> + </div> + </CardContent> + </Card> ))} - </ul> + </section> )} </main> ) diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..2a176c7 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,76 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) { + return ( + <div + data-slot="alert" + role="alert" + className={cn(alertVariants({ variant }), className)} + {...props} + /> + ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="alert-title" + className={cn( + "font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( + <div + data-slot="alert-description" + className={cn( + "text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4", + className + )} + {...props} + /> + ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="alert-action" + className={cn("absolute top-2 right-2", className)} + {...props} + /> + ) +} + +export { Alert, AlertTitle, AlertDescription, AlertAction } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..b20959d --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,52 @@ +import { mergeProps } from "@base-ui/react/merge-props" +import { useRender } from "@base-ui/react/use-render" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + render, + ...props +}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) { + return useRender({ + defaultTagName: "span", + props: mergeProps<"span">( + { + className: cn(badgeVariants({ variant }), className), + }, + props + ), + render, + state: { + slot: "badge", + variant, + }, + }) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..40cac5f --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( + <div + data-slot="card" + data-size={size} + className={cn( + "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-header" + className={cn( + "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3", + className + )} + {...props} + /> + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-title" + className={cn( + "font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm", + className + )} + {...props} + /> + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-description" + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> + ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-action" + className={cn( + "col-start-2 row-span-2 row-start-1 self-start justify-self-end", + className + )} + {...props} + /> + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-content" + className={cn("px-4 group-data-[size=sm]/card:px-3", className)} + {...props} + /> + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-footer" + className={cn( + "flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3", + className + )} + {...props} + /> + ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx new file mode 100644 index 0000000..4f65961 --- /dev/null +++ b/src/components/ui/separator.tsx @@ -0,0 +1,23 @@ +import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" + +import { cn } from "@/lib/utils" + +function Separator({ + className, + orientation = "horizontal", + ...props +}: SeparatorPrimitive.Props) { + return ( + <SeparatorPrimitive + data-slot="separator" + orientation={orientation} + className={cn( + "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", + className + )} + {...props} + /> + ) +} + +export { Separator } |
