summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/App.tsx86
-rw-r--r--src/components/ui/alert.tsx76
-rw-r--r--src/components/ui/badge.tsx52
-rw-r--r--src/components/ui/card.tsx103
-rw-r--r--src/components/ui/separator.tsx23
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 }