|
@@ -1,6 +1,6 @@
|
|
|
"use client";
|
|
"use client";
|
|
|
|
|
|
|
|
-import { useState } from "react";
|
|
|
|
|
|
|
+import { Children, useEffect, useState } from "react";
|
|
|
import {
|
|
import {
|
|
|
defineRegistry,
|
|
defineRegistry,
|
|
|
useBoundProp,
|
|
useBoundProp,
|
|
@@ -96,6 +96,7 @@ import {
|
|
|
Tabs as TabsPrimitive,
|
|
Tabs as TabsPrimitive,
|
|
|
TabsList,
|
|
TabsList,
|
|
|
TabsTrigger,
|
|
TabsTrigger,
|
|
|
|
|
+ TabsContent,
|
|
|
} from "@/components/ui/tabs";
|
|
} from "@/components/ui/tabs";
|
|
|
import { Toggle } from "@/components/ui/toggle";
|
|
import { Toggle } from "@/components/ui/toggle";
|
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
|
@@ -199,9 +200,16 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
|
|
? "justify-around"
|
|
? "justify-around"
|
|
|
: "";
|
|
: "";
|
|
|
|
|
|
|
|
|
|
+ // Horizontal stacks wrap by default; wrap:false keeps a single scrolling
|
|
|
|
|
+ // row (Kanban boards / column layouts that must sit side by side).
|
|
|
|
|
+ const horizontalFlow =
|
|
|
|
|
+ props.wrap === false
|
|
|
|
|
+ ? "flex-row flex-nowrap overflow-x-auto w-full [&>*]:shrink-0"
|
|
|
|
|
+ : "flex-row flex-wrap";
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<div
|
|
<div
|
|
|
- className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col w-full"} ${gapClass} ${alignClass} ${justifyClass}`}
|
|
|
|
|
|
|
+ className={`flex ${isHorizontal ? horizontalFlow : "flex-col w-full"} ${gapClass} ${alignClass} ${justifyClass}`}
|
|
|
>
|
|
>
|
|
|
{children}
|
|
{children}
|
|
|
</div>
|
|
</div>
|
|
@@ -216,17 +224,19 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
|
|
: 0;
|
|
: 0;
|
|
|
const n = Math.min(props.columns ?? 1, childCount || 1);
|
|
const n = Math.min(props.columns ?? 1, childCount || 1);
|
|
|
const cols =
|
|
const cols =
|
|
|
- n >= 6
|
|
|
|
|
- ? "grid-cols-6"
|
|
|
|
|
- : n >= 5
|
|
|
|
|
- ? "grid-cols-5"
|
|
|
|
|
- : n >= 4
|
|
|
|
|
- ? "grid-cols-4"
|
|
|
|
|
- : n >= 3
|
|
|
|
|
- ? "grid-cols-3"
|
|
|
|
|
- : n >= 2
|
|
|
|
|
- ? "grid-cols-2"
|
|
|
|
|
- : "grid-cols-1";
|
|
|
|
|
|
|
+ n >= 7
|
|
|
|
|
+ ? "grid-cols-7"
|
|
|
|
|
+ : n >= 6
|
|
|
|
|
+ ? "grid-cols-6"
|
|
|
|
|
+ : n >= 5
|
|
|
|
|
+ ? "grid-cols-5"
|
|
|
|
|
+ : n >= 4
|
|
|
|
|
+ ? "grid-cols-4"
|
|
|
|
|
+ : n >= 3
|
|
|
|
|
+ ? "grid-cols-3"
|
|
|
|
|
+ : n >= 2
|
|
|
|
|
+ ? "grid-cols-2"
|
|
|
|
|
+ : "grid-cols-1";
|
|
|
const gridGap =
|
|
const gridGap =
|
|
|
props.gap === "lg" ? "gap-4" : props.gap === "sm" ? "gap-2" : "gap-3";
|
|
props.gap === "lg" ? "gap-4" : props.gap === "sm" ? "gap-2" : "gap-3";
|
|
|
|
|
|
|
@@ -242,7 +252,7 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
|
|
/>
|
|
/>
|
|
|
),
|
|
),
|
|
|
|
|
|
|
|
- Tabs: ({ props, bindings, emit }) => {
|
|
|
|
|
|
|
+ Tabs: ({ props, bindings, emit, children }) => {
|
|
|
const tabs = props.tabs ?? [];
|
|
const tabs = props.tabs ?? [];
|
|
|
const [boundValue, setBoundValue] = useBoundProp<string>(
|
|
const [boundValue, setBoundValue] = useBoundProp<string>(
|
|
|
props.value as string | undefined,
|
|
props.value as string | undefined,
|
|
@@ -255,6 +265,9 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
|
|
const value = isBound ? (boundValue ?? tabs[0]?.value ?? "") : localValue;
|
|
const value = isBound ? (boundValue ?? tabs[0]?.value ?? "") : localValue;
|
|
|
const setValue = isBound ? setBoundValue : setLocalValue;
|
|
const setValue = isBound ? setBoundValue : setLocalValue;
|
|
|
|
|
|
|
|
|
|
+ // Children map positionally to tabs: children[i] is the panel for tabs[i].
|
|
|
|
|
+ const panels = Children.toArray(children);
|
|
|
|
|
+
|
|
|
return (
|
|
return (
|
|
|
<TabsPrimitive
|
|
<TabsPrimitive
|
|
|
value={value}
|
|
value={value}
|
|
@@ -270,6 +283,11 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
|
|
</TabsTrigger>
|
|
</TabsTrigger>
|
|
|
))}
|
|
))}
|
|
|
</TabsList>
|
|
</TabsList>
|
|
|
|
|
+ {tabs.map((tab, i) => (
|
|
|
|
|
+ <TabsContent key={tab.value} value={tab.value} className="pt-4">
|
|
|
|
|
+ {panels[i] ?? null}
|
|
|
|
|
+ </TabsContent>
|
|
|
|
|
+ ))}
|
|
|
</TabsPrimitive>
|
|
</TabsPrimitive>
|
|
|
);
|
|
);
|
|
|
},
|
|
},
|
|
@@ -469,38 +487,293 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
|
|
return <p className={textClass}>{props.text}</p>;
|
|
return <p className={textClass}>{props.text}</p>;
|
|
|
},
|
|
},
|
|
|
|
|
|
|
|
- Image: ({ props }) => (
|
|
|
|
|
- <div
|
|
|
|
|
- className="w-full bg-muted/50 border border-dashed border-border rounded-lg flex flex-col items-center justify-center gap-2 text-muted-foreground/60 px-4"
|
|
|
|
|
- style={{
|
|
|
|
|
- maxWidth: props.width ?? undefined,
|
|
|
|
|
- height: props.height ?? 120,
|
|
|
|
|
- minHeight: 80,
|
|
|
|
|
- }}
|
|
|
|
|
- >
|
|
|
|
|
- <svg
|
|
|
|
|
- width="32"
|
|
|
|
|
- height="32"
|
|
|
|
|
- viewBox="0 0 24 24"
|
|
|
|
|
- fill="none"
|
|
|
|
|
- stroke="currentColor"
|
|
|
|
|
- strokeWidth="1.5"
|
|
|
|
|
- strokeLinecap="round"
|
|
|
|
|
- strokeLinejoin="round"
|
|
|
|
|
- className="opacity-50"
|
|
|
|
|
|
|
+ Image: ({ props }) => {
|
|
|
|
|
+ const src = typeof props.src === "string" ? props.src.trim() : "";
|
|
|
|
|
+ if (src) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ // eslint-disable-next-line @next/next/no-img-element
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={src}
|
|
|
|
|
+ alt={props.alt ?? ""}
|
|
|
|
|
+ loading="lazy"
|
|
|
|
|
+ className="w-full rounded-lg object-cover bg-muted"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ maxWidth: props.width ?? undefined,
|
|
|
|
|
+ height: props.height ?? undefined,
|
|
|
|
|
+ aspectRatio:
|
|
|
|
|
+ props.height == null && props.width == null
|
|
|
|
|
+ ? "16 / 9"
|
|
|
|
|
+ : undefined,
|
|
|
|
|
+ }}
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="w-full bg-muted/50 border border-dashed border-border rounded-lg flex flex-col items-center justify-center gap-2 text-muted-foreground/60 px-4"
|
|
|
|
|
+ style={{
|
|
|
|
|
+ maxWidth: props.width ?? undefined,
|
|
|
|
|
+ height: props.height ?? 120,
|
|
|
|
|
+ minHeight: 80,
|
|
|
|
|
+ }}
|
|
|
>
|
|
>
|
|
|
- <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
|
|
|
- <circle cx="9" cy="9" r="2" />
|
|
|
|
|
- <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- {props.alt && <span className="text-xs">{props.alt}</span>}
|
|
|
|
|
- </div>
|
|
|
|
|
|
|
+ <svg
|
|
|
|
|
+ width="32"
|
|
|
|
|
+ height="32"
|
|
|
|
|
+ viewBox="0 0 24 24"
|
|
|
|
|
+ fill="none"
|
|
|
|
|
+ stroke="currentColor"
|
|
|
|
|
+ strokeWidth="1.5"
|
|
|
|
|
+ strokeLinecap="round"
|
|
|
|
|
+ strokeLinejoin="round"
|
|
|
|
|
+ className="opacity-50"
|
|
|
|
|
+ >
|
|
|
|
|
+ <rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
|
|
|
|
+ <circle cx="9" cy="9" r="2" />
|
|
|
|
|
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ {props.alt && <span className="text-xs">{props.alt}</span>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ Map: ({ props }) => {
|
|
|
|
|
+ const query = String(props.query ?? "");
|
|
|
|
|
+ const zoom = typeof props.zoom === "number" ? props.zoom : 14;
|
|
|
|
|
+ const height = typeof props.height === "number" ? props.height : 320;
|
|
|
|
|
+ const src = `https://maps.google.com/maps?q=${encodeURIComponent(
|
|
|
|
|
+ query,
|
|
|
|
|
+ )}&z=${zoom}&output=embed`;
|
|
|
|
|
+ return (
|
|
|
|
|
+ <iframe
|
|
|
|
|
+ title={query ? `Map: ${query}` : "Map"}
|
|
|
|
|
+ src={src}
|
|
|
|
|
+ className="w-full rounded-lg border border-border"
|
|
|
|
|
+ style={{ height, minHeight: 200 }}
|
|
|
|
|
+ loading="lazy"
|
|
|
|
|
+ referrerPolicy="no-referrer-when-downgrade"
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ Pressable: ({ children, emit }) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => emit("press")}
|
|
|
|
|
+ className="block w-full text-left cursor-pointer rounded-lg transition-transform hover:scale-[1.02] focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
+ >
|
|
|
|
|
+ {children}
|
|
|
|
|
+ </button>
|
|
|
),
|
|
),
|
|
|
|
|
|
|
|
|
|
+ // Self-contained photo gallery + lightbox. Manages its own open/index state
|
|
|
|
|
+ // with React useState — does NOT depend on the json-render setState action.
|
|
|
|
|
+ Lightbox: ({ props }) => {
|
|
|
|
|
+ const images = (
|
|
|
|
|
+ Array.isArray(props.images) ? props.images : []
|
|
|
|
|
+ ) as Array<{ src: string; caption?: string | null }>;
|
|
|
|
|
+ const cols =
|
|
|
|
|
+ props.columns === 2
|
|
|
|
|
+ ? "grid-cols-2"
|
|
|
|
|
+ : props.columns === 4
|
|
|
|
|
+ ? "grid-cols-4"
|
|
|
|
|
+ : props.columns === 5
|
|
|
|
|
+ ? "grid-cols-5"
|
|
|
|
|
+ : props.columns === 6
|
|
|
|
|
+ ? "grid-cols-6"
|
|
|
|
|
+ : "grid-cols-3";
|
|
|
|
|
+ const [openIndex, setOpenIndex] = useState<number | null>(null);
|
|
|
|
|
+ const current = openIndex !== null ? images[openIndex] : undefined;
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (openIndex === null) return;
|
|
|
|
|
+ const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
+ if (e.key === "Escape") setOpenIndex(null);
|
|
|
|
|
+ if (e.key === "ArrowLeft")
|
|
|
|
|
+ setOpenIndex((i) =>
|
|
|
|
|
+ i === null ? i : (i - 1 + images.length) % images.length,
|
|
|
|
|
+ );
|
|
|
|
|
+ if (e.key === "ArrowRight")
|
|
|
|
|
+ setOpenIndex((i) => (i === null ? i : (i + 1) % images.length));
|
|
|
|
|
+ };
|
|
|
|
|
+ window.addEventListener("keydown", onKey);
|
|
|
|
|
+ return () => window.removeEventListener("keydown", onKey);
|
|
|
|
|
+ }, [openIndex, images.length]);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className={`grid w-full gap-3 ${cols}`}>
|
|
|
|
|
+ {images.map((img, i) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={i}
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ onClick={() => setOpenIndex(i)}
|
|
|
|
|
+ className="group relative aspect-square overflow-hidden rounded-lg bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* eslint-disable-next-line @next/next/no-img-element */}
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={img.src}
|
|
|
|
|
+ alt={img.caption ?? ""}
|
|
|
|
|
+ loading="lazy"
|
|
|
|
|
+ className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
|
|
|
|
|
+ />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {current && (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 p-4 backdrop-blur-sm"
|
|
|
|
|
+ onClick={() => setOpenIndex(null)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ aria-label="Close"
|
|
|
|
|
+ onClick={() => setOpenIndex(null)}
|
|
|
|
|
+ className="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white hover:bg-white/20"
|
|
|
|
|
+ >
|
|
|
|
|
+ ✕
|
|
|
|
|
+ </button>
|
|
|
|
|
+ {images.length > 1 && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ aria-label="Previous"
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ setOpenIndex((i) =>
|
|
|
|
|
+ i === null ? i : (i - 1 + images.length) % images.length,
|
|
|
|
|
+ );
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="absolute left-4 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-2xl text-white hover:bg-white/20"
|
|
|
|
|
+ >
|
|
|
|
|
+ ‹
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <figure
|
|
|
|
|
+ className="flex max-h-[90vh] max-w-[92vw] flex-col items-center gap-3"
|
|
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
|
|
+ >
|
|
|
|
|
+ {/* eslint-disable-next-line @next/next/no-img-element */}
|
|
|
|
|
+ <img
|
|
|
|
|
+ src={current.src}
|
|
|
|
|
+ alt={current.caption ?? ""}
|
|
|
|
|
+ className="max-h-[82vh] max-w-full rounded-lg object-contain shadow-2xl"
|
|
|
|
|
+ />
|
|
|
|
|
+ {current.caption && (
|
|
|
|
|
+ <figcaption className="text-sm text-white/80">
|
|
|
|
|
+ {current.caption}
|
|
|
|
|
+ <span className="ml-2 text-white/50">
|
|
|
|
|
+ {(openIndex ?? 0) + 1} / {images.length}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </figcaption>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </figure>
|
|
|
|
|
+ {images.length > 1 && (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ aria-label="Next"
|
|
|
|
|
+ onClick={(e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ setOpenIndex((i) =>
|
|
|
|
|
+ i === null ? i : (i + 1) % images.length,
|
|
|
|
|
+ );
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="absolute right-4 flex h-12 w-12 items-center justify-center rounded-full bg-white/10 text-2xl text-white hover:bg-white/20"
|
|
|
|
|
+ >
|
|
|
|
|
+ ›
|
|
|
|
|
+ </button>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
|
|
+ // Self-contained modal: renders its own trigger button and manages open
|
|
|
|
|
+ // state with React useState — no setState / openPath / Dialog needed.
|
|
|
|
|
+ Modal: ({ props, children }) => {
|
|
|
|
|
+ const [open, setOpen] = useState(false);
|
|
|
|
|
+ const variant =
|
|
|
|
|
+ props.triggerVariant === "outline"
|
|
|
|
|
+ ? "outline"
|
|
|
|
|
+ : props.triggerVariant === "secondary"
|
|
|
|
|
+ ? "secondary"
|
|
|
|
|
+ : props.triggerVariant === "danger"
|
|
|
|
|
+ ? "destructive"
|
|
|
|
|
+ : "default";
|
|
|
|
|
+ const sizeClass =
|
|
|
|
|
+ props.size === "lg"
|
|
|
|
|
+ ? "max-w-2xl"
|
|
|
|
|
+ : props.size === "sm"
|
|
|
|
|
+ ? "max-w-sm"
|
|
|
|
|
+ : "max-w-lg";
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!open) return;
|
|
|
|
|
+ const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
+ if (e.key === "Escape") setOpen(false);
|
|
|
|
|
+ };
|
|
|
|
|
+ window.addEventListener("keydown", onKey);
|
|
|
|
|
+ return () => window.removeEventListener("keydown", onKey);
|
|
|
|
|
+ }, [open]);
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <Button variant={variant} onClick={() => setOpen(true)}>
|
|
|
|
|
+ {props.triggerLabel ?? "Open"}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ {open && (
|
|
|
|
|
+ <div
|
|
|
|
|
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
|
|
|
|
+ onClick={() => setOpen(false)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div
|
|
|
|
|
+ role="dialog"
|
|
|
|
|
+ aria-modal="true"
|
|
|
|
|
+ className={`w-full ${sizeClass} rounded-xl border border-border bg-background p-6 shadow-2xl`}
|
|
|
|
|
+ onClick={(e) => e.stopPropagation()}
|
|
|
|
|
+ >
|
|
|
|
|
+ <div className="flex items-start justify-between gap-4">
|
|
|
|
|
+ <div className="min-w-0">
|
|
|
|
|
+ {props.title && (
|
|
|
|
|
+ <h2 className="text-lg font-semibold leading-tight">
|
|
|
|
|
+ {props.title}
|
|
|
|
|
+ </h2>
|
|
|
|
|
+ )}
|
|
|
|
|
+ {props.description && (
|
|
|
|
|
+ <p className="mt-1 text-sm text-muted-foreground">
|
|
|
|
|
+ {props.description}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ aria-label="Close"
|
|
|
|
|
+ onClick={() => setOpen(false)}
|
|
|
|
|
+ className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
|
|
|
+ >
|
|
|
|
|
+ ✕
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {children && <div className="mt-4">{children}</div>}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </>
|
|
|
|
|
+ );
|
|
|
|
|
+ },
|
|
|
|
|
+
|
|
|
Icon: ({ props }) => {
|
|
Icon: ({ props }) => {
|
|
|
const IconComponent = lucideIcons[props.name as keyof typeof lucideIcons];
|
|
const IconComponent = lucideIcons[props.name as keyof typeof lucideIcons];
|
|
|
if (!IconComponent) return null;
|
|
if (!IconComponent) return null;
|
|
|
- const sizeMap = { sm: 16, md: 20, lg: 24 } as const;
|
|
|
|
|
|
|
+ const sizeMap = {
|
|
|
|
|
+ sm: 16,
|
|
|
|
|
+ md: 20,
|
|
|
|
|
+ lg: 24,
|
|
|
|
|
+ xl: 32,
|
|
|
|
|
+ "2xl": 48,
|
|
|
|
|
+ "3xl": 64,
|
|
|
|
|
+ } as const;
|
|
|
const px = sizeMap[props.size ?? "md"] ?? 20;
|
|
const px = sizeMap[props.size ?? "md"] ?? 20;
|
|
|
const colorClass =
|
|
const colorClass =
|
|
|
props.color === "muted"
|
|
props.color === "muted"
|
|
@@ -783,6 +1056,44 @@ export const { registry, executeAction } = defineRegistry(playgroundCatalog, {
|
|
|
);
|
|
);
|
|
|
},
|
|
},
|
|
|
|
|
|
|
|
|
|
+ Timeline: ({ props }) => (
|
|
|
|
|
+ <div className="relative pl-8">
|
|
|
|
|
+ <div className="absolute left-[5.5px] top-3 bottom-3 w-px bg-border" />
|
|
|
|
|
+ <div className="flex flex-col gap-6">
|
|
|
|
|
+ {(props.items ?? []).map((item, i) => {
|
|
|
|
|
+ const dotColor =
|
|
|
|
|
+ item.status === "completed"
|
|
|
|
|
+ ? "bg-emerald-500"
|
|
|
|
|
+ : item.status === "current"
|
|
|
|
|
+ ? "bg-blue-500"
|
|
|
|
|
+ : "bg-muted-foreground/30";
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div key={i} className="relative">
|
|
|
|
|
+ <div
|
|
|
|
|
+ className={`absolute -left-8 top-0.5 h-3 w-3 rounded-full ${dotColor} ring-2 ring-background`}
|
|
|
|
|
+ />
|
|
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
|
|
+ <div className="flex items-center gap-2 flex-wrap">
|
|
|
|
|
+ <p className="font-medium text-sm">{item.title}</p>
|
|
|
|
|
+ {item.date && (
|
|
|
|
|
+ <span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
|
|
|
|
+ {item.date}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {item.description && (
|
|
|
|
|
+ <p className="text-sm text-muted-foreground mt-1">
|
|
|
|
|
+ {item.description}
|
|
|
|
|
+ </p>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+ })}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ),
|
|
|
|
|
+
|
|
|
Metric: ({ props }) => {
|
|
Metric: ({ props }) => {
|
|
|
const changeColor =
|
|
const changeColor =
|
|
|
props.changeType === "positive"
|
|
props.changeType === "positive"
|