wip
This commit is contained in:
124
src/app.css
124
src/app.css
@@ -1,3 +1,121 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
@import "tailwindcss";
|
||||
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.984 0.003 247.858);
|
||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.208 0.042 265.755);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.929 0.013 255.508);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.208 0.042 265.755);
|
||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
50
src/lib/components/ui/badge/badge.svelte
Normal file
50
src/lib/components/ui/badge/badge.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>["variant"];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = "default",
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? "a" : "span"}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
||||
2
src/lib/components/ui/badge/index.ts
Normal file
2
src/lib/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Badge } from "./badge.svelte";
|
||||
export { badgeVariants, type BadgeVariant } from "./badge.svelte";
|
||||
82
src/lib/components/ui/button/button.svelte
Normal file
82
src/lib/components/ui/button/button.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
|
||||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white",
|
||||
outline:
|
||||
"bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = "button",
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? "link" : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
20
src/lib/components/ui/button/index.ts
Normal file
20
src/lib/components/ui/button/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants,
|
||||
} from './button.svelte'
|
||||
|
||||
import ToggleButton from './toggleButton.svelte'
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
ToggleButton,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
}
|
||||
29
src/lib/components/ui/button/toggleButton.svelte
Normal file
29
src/lib/components/ui/button/toggleButton.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte'
|
||||
|
||||
const {
|
||||
onclick,
|
||||
children: content,
|
||||
selected = $bindable(),
|
||||
}: {
|
||||
onclick?: () => void
|
||||
children?: Snippet
|
||||
selected?: boolean
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={() => onclick?.()}
|
||||
class:selected
|
||||
class="cursor-pointer px-6 py-3 border-2 rounded-lg transition-all border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{@render content?.()}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
@reference "../../../../app.css";
|
||||
|
||||
.selected {
|
||||
@apply border-blue-600 bg-blue-50 dark:border-blue-500 dark:text-white dark:bg-blue-950;
|
||||
}
|
||||
</style>
|
||||
20
src/lib/components/ui/card/card-action.svelte
Normal file
20
src/lib/components/ui/card/card-action.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
15
src/lib/components/ui/card/card-content.svelte
Normal file
15
src/lib/components/ui/card/card-content.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
src/lib/components/ui/card/card-description.svelte
Normal file
20
src/lib/components/ui/card/card-description.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn("text-muted-foreground text-sm", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
||||
20
src/lib/components/ui/card/card-footer.svelte
Normal file
20
src/lib/components/ui/card/card-footer.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn("[.border-t]:pt-6 flex items-center px-6", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
src/lib/components/ui/card/card-header.svelte
Normal file
23
src/lib/components/ui/card/card-header.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-header"
|
||||
class={cn(
|
||||
"@container/card-header has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6 grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
20
src/lib/components/ui/card/card-title.svelte
Normal file
20
src/lib/components/ui/card/card-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-title"
|
||||
class={cn("font-semibold leading-none", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
src/lib/components/ui/card/card.svelte
Normal file
23
src/lib/components/ui/card/card.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
25
src/lib/components/ui/card/index.ts
Normal file
25
src/lib/components/ui/card/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Root from "./card.svelte";
|
||||
import Content from "./card-content.svelte";
|
||||
import Description from "./card-description.svelte";
|
||||
import Footer from "./card-footer.svelte";
|
||||
import Header from "./card-header.svelte";
|
||||
import Title from "./card-title.svelte";
|
||||
import Action from "./card-action.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Description,
|
||||
Footer,
|
||||
Header,
|
||||
Title,
|
||||
Action,
|
||||
//
|
||||
Root as Card,
|
||||
Content as CardContent,
|
||||
Description as CardDescription,
|
||||
Footer as CardFooter,
|
||||
Header as CardHeader,
|
||||
Title as CardTitle,
|
||||
Action as CardAction,
|
||||
};
|
||||
43
src/lib/components/ui/carousel/carousel-content.svelte
Normal file
43
src/lib/components/ui/carousel/carousel-content.svelte
Normal file
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import emblaCarouselSvelte from "embla-carousel-svelte";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Content/>");
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-slot="carousel-content"
|
||||
class="overflow-hidden"
|
||||
use:emblaCarouselSvelte={{
|
||||
options: {
|
||||
container: "[data-embla-container]",
|
||||
slides: "[data-embla-slide]",
|
||||
...emblaCtx.options,
|
||||
axis: emblaCtx.orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins: emblaCtx.plugins,
|
||||
}}
|
||||
onemblaInit={emblaCtx.onInit}
|
||||
>
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn(
|
||||
"flex",
|
||||
emblaCtx.orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
data-embla-container=""
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
30
src/lib/components/ui/carousel/carousel-item.svelte
Normal file
30
src/lib/components/ui/carousel/carousel-item.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Item/>");
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="carousel-item"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
class={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
emblaCtx.orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
data-embla-slide=""
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
38
src/lib/components/ui/carousel/carousel-next.svelte
Normal file
38
src/lib/components/ui/carousel/carousel-next.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right'
|
||||
import type { WithoutChildren } from 'bits-ui'
|
||||
import { getEmblaContext } from './context.js'
|
||||
import { cn } from '$lib/utils.js'
|
||||
import { Button, type Props } from '$lib/components/ui/button/index.js'
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = 'outline',
|
||||
size = 'icon',
|
||||
...restProps
|
||||
}: WithoutChildren<Props> = $props()
|
||||
|
||||
const emblaCtx = getEmblaContext('<Carousel.Next/>')
|
||||
</script>
|
||||
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
{variant}
|
||||
{size}
|
||||
aria-disabled={!emblaCtx.canScrollNext}
|
||||
class={cn(
|
||||
'absolute size-8 rounded-full',
|
||||
emblaCtx.orientation === 'horizontal'
|
||||
? '-right-12 top-1/2 -translate-y-1/2'
|
||||
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
|
||||
className,
|
||||
)}
|
||||
onclick={emblaCtx.scrollNext}
|
||||
onkeydown={emblaCtx.handleKeyDown}
|
||||
bind:ref
|
||||
{...restProps}
|
||||
>
|
||||
<ArrowRightIcon class="size-4" />
|
||||
<span class="sr-only">Next slide</span>
|
||||
</Button>
|
||||
38
src/lib/components/ui/carousel/carousel-previous.svelte
Normal file
38
src/lib/components/ui/carousel/carousel-previous.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeftIcon from "@lucide/svelte/icons/arrow-left";
|
||||
import type { WithoutChildren } from "bits-ui";
|
||||
import { getEmblaContext } from "./context.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { Button, type Props } from "$lib/components/ui/button/index.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...restProps
|
||||
}: WithoutChildren<Props> = $props();
|
||||
|
||||
const emblaCtx = getEmblaContext("<Carousel.Previous/>");
|
||||
</script>
|
||||
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
{variant}
|
||||
{size}
|
||||
aria-disabled={!emblaCtx.canScrollPrev}
|
||||
class={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
emblaCtx.orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
onclick={emblaCtx.scrollPrev}
|
||||
onkeydown={emblaCtx.handleKeyDown}
|
||||
{...restProps}
|
||||
bind:ref
|
||||
>
|
||||
<ArrowLeftIcon class="size-4" />
|
||||
<span class="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
93
src/lib/components/ui/carousel/carousel.svelte
Normal file
93
src/lib/components/ui/carousel/carousel.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type CarouselAPI,
|
||||
type CarouselProps,
|
||||
type EmblaContext,
|
||||
setEmblaContext,
|
||||
} from "./context.js";
|
||||
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
opts = {},
|
||||
plugins = [],
|
||||
setApi = () => {},
|
||||
orientation = "horizontal",
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<CarouselProps> = $props();
|
||||
|
||||
let carouselState = $state<EmblaContext>({
|
||||
api: undefined,
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
orientation,
|
||||
canScrollNext: false,
|
||||
canScrollPrev: false,
|
||||
handleKeyDown,
|
||||
options: opts,
|
||||
plugins,
|
||||
onInit,
|
||||
scrollSnaps: [],
|
||||
selectedIndex: 0,
|
||||
scrollTo,
|
||||
});
|
||||
|
||||
setEmblaContext(carouselState);
|
||||
|
||||
function scrollPrev() {
|
||||
carouselState.api?.scrollPrev();
|
||||
}
|
||||
|
||||
function scrollNext() {
|
||||
carouselState.api?.scrollNext();
|
||||
}
|
||||
|
||||
function scrollTo(index: number, jump?: boolean) {
|
||||
carouselState.api?.scrollTo(index, jump);
|
||||
}
|
||||
|
||||
function onSelect() {
|
||||
if (!carouselState.api) return;
|
||||
carouselState.selectedIndex = carouselState.api.selectedScrollSnap();
|
||||
carouselState.canScrollNext = carouselState.api.canScrollNext();
|
||||
carouselState.canScrollPrev = carouselState.api.canScrollPrev();
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (e.key === "ArrowRight") {
|
||||
e.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
}
|
||||
|
||||
function onInit(event: CustomEvent<CarouselAPI>) {
|
||||
carouselState.api = event.detail;
|
||||
setApi(carouselState.api);
|
||||
|
||||
carouselState.scrollSnaps = carouselState.api.scrollSnapList();
|
||||
carouselState.api.on("select", onSelect);
|
||||
onSelect();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
carouselState.api?.off("select", onSelect);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="carousel"
|
||||
class={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
58
src/lib/components/ui/carousel/context.ts
Normal file
58
src/lib/components/ui/carousel/context.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { WithElementRef } from "$lib/utils.js";
|
||||
import type {
|
||||
EmblaCarouselSvelteType,
|
||||
default as emblaCarouselSvelte,
|
||||
} from "embla-carousel-svelte";
|
||||
import { getContext, hasContext, setContext } from "svelte";
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
|
||||
export type CarouselAPI =
|
||||
NonNullable<NonNullable<EmblaCarouselSvelteType["$$_attributes"]>["on:emblaInit"]> extends (
|
||||
evt: CustomEvent<infer CarouselAPI>
|
||||
) => void
|
||||
? CarouselAPI
|
||||
: never;
|
||||
|
||||
type EmblaCarouselConfig = NonNullable<Parameters<typeof emblaCarouselSvelte>[1]>;
|
||||
|
||||
export type CarouselOptions = EmblaCarouselConfig["options"];
|
||||
export type CarouselPlugins = EmblaCarouselConfig["plugins"];
|
||||
|
||||
////
|
||||
|
||||
export type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugins;
|
||||
setApi?: (api: CarouselAPI | undefined) => void;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
} & WithElementRef<HTMLAttributes<HTMLDivElement>>;
|
||||
|
||||
const EMBLA_CAROUSEL_CONTEXT = Symbol("EMBLA_CAROUSEL_CONTEXT");
|
||||
|
||||
export type EmblaContext = {
|
||||
api: CarouselAPI | undefined;
|
||||
orientation: "horizontal" | "vertical";
|
||||
scrollNext: () => void;
|
||||
scrollPrev: () => void;
|
||||
canScrollNext: boolean;
|
||||
canScrollPrev: boolean;
|
||||
handleKeyDown: (e: KeyboardEvent) => void;
|
||||
options: CarouselOptions;
|
||||
plugins: CarouselPlugins;
|
||||
onInit: (e: CustomEvent<CarouselAPI>) => void;
|
||||
scrollTo: (index: number, jump?: boolean) => void;
|
||||
scrollSnaps: number[];
|
||||
selectedIndex: number;
|
||||
};
|
||||
|
||||
export function setEmblaContext(config: EmblaContext): EmblaContext {
|
||||
setContext(EMBLA_CAROUSEL_CONTEXT, config);
|
||||
return config;
|
||||
}
|
||||
|
||||
export function getEmblaContext(name = "This component") {
|
||||
if (!hasContext(EMBLA_CAROUSEL_CONTEXT)) {
|
||||
throw new Error(`${name} must be used within a <Carousel.Root> component`);
|
||||
}
|
||||
return getContext<ReturnType<typeof setEmblaContext>>(EMBLA_CAROUSEL_CONTEXT);
|
||||
}
|
||||
19
src/lib/components/ui/carousel/index.ts
Normal file
19
src/lib/components/ui/carousel/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import Root from "./carousel.svelte";
|
||||
import Content from "./carousel-content.svelte";
|
||||
import Item from "./carousel-item.svelte";
|
||||
import Previous from "./carousel-previous.svelte";
|
||||
import Next from "./carousel-next.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Previous,
|
||||
Next,
|
||||
//
|
||||
Root as Carousel,
|
||||
Content as CarouselContent,
|
||||
Item as CarouselItem,
|
||||
Previous as CarouselPrevious,
|
||||
Next as CarouselNext,
|
||||
};
|
||||
7
src/lib/components/ui/label/index.ts
Normal file
7
src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
20
src/lib/components/ui/label/label.svelte
Normal file
20
src/lib/components/ui/label/label.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Label as LabelPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: LabelPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<LabelPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="label"
|
||||
class={cn(
|
||||
"flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
10
src/lib/components/ui/radio-group/index.ts
Normal file
10
src/lib/components/ui/radio-group/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import Root from "./radio-group.svelte";
|
||||
import Item from "./radio-group-item.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Item,
|
||||
//
|
||||
Root as RadioGroup,
|
||||
Item as RadioGroupItem,
|
||||
};
|
||||
31
src/lib/components/ui/radio-group/radio-group-item.svelte
Normal file
31
src/lib/components/ui/radio-group/radio-group-item.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
|
||||
import CircleIcon from "@lucide/svelte/icons/circle";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
|
||||
</script>
|
||||
|
||||
<RadioGroupPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="radio-group-item"
|
||||
class={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 shadow-xs aspect-square size-4 shrink-0 rounded-full border outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked })}
|
||||
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
|
||||
{#if checked}
|
||||
<CircleIcon
|
||||
class="fill-primary absolute start-1/2 top-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</RadioGroupPrimitive.Item>
|
||||
19
src/lib/components/ui/radio-group/radio-group.svelte
Normal file
19
src/lib/components/ui/radio-group/radio-group.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value = $bindable(""),
|
||||
...restProps
|
||||
}: RadioGroupPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<RadioGroupPrimitive.Root
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="radio-group"
|
||||
class={cn("grid gap-3", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
7
src/lib/components/ui/separator/index.ts
Normal file
7
src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
21
src/lib/components/ui/separator/separator.svelte
Normal file
21
src/lib/components/ui/separator/separator.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Separator as SeparatorPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
"data-slot": dataSlot = "separator",
|
||||
...restProps
|
||||
}: SeparatorPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<SeparatorPrimitive.Root
|
||||
bind:ref
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=vertical]:h-full data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
31
src/lib/components/ui/specCard/SpecCard.svelte
Normal file
31
src/lib/components/ui/specCard/SpecCard.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { Specification } from '$lib'
|
||||
import * as Card from '../card'
|
||||
import { m } from '$lib/paraglide/messages'
|
||||
import * as Tabs from '../tabs'
|
||||
import SpecTable from './SpecTable.svelte'
|
||||
|
||||
const { specifications }: { specifications: Specification[] } = $props()
|
||||
|
||||
let selectedSpec = $state(specifications.at(0)?.title ?? '')
|
||||
</script>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
{m.specification_title()}
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Tabs.Root bind:value={selectedSpec}>
|
||||
<Tabs.List class="w-full">
|
||||
{#each specifications as spec (spec.title)}
|
||||
<Tabs.Trigger value={spec.title}>{spec.title}</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
{#each specifications as spec (spec.title)}
|
||||
<Tabs.TabsContent value={spec.title}>
|
||||
<SpecTable attributes={spec.attributes} />
|
||||
</Tabs.TabsContent>
|
||||
{/each}
|
||||
</Tabs.Root>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
12
src/lib/components/ui/specCard/SpecRow.svelte
Normal file
12
src/lib/components/ui/specCard/SpecRow.svelte
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { SpecificationAttribute } from '$lib'
|
||||
|
||||
const { attribute }: { attribute: SpecificationAttribute } = $props()
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<dt class="text-gray-600 dark:text-gray-400">{attribute.key}</dt>
|
||||
<dd class="dark:text-gray-200">{attribute.value}</dd>
|
||||
</div>
|
||||
13
src/lib/components/ui/specCard/SpecTable.svelte
Normal file
13
src/lib/components/ui/specCard/SpecTable.svelte
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { SpecificationAttribute } from '$lib'
|
||||
import type { Snippet } from 'svelte'
|
||||
import SpecRow from './SpecRow.svelte'
|
||||
|
||||
const { attributes }: { attributes: SpecificationAttribute[] } = $props()
|
||||
</script>
|
||||
|
||||
<dl class="space-y-3">
|
||||
{#each attributes as attribute (attribute.key)}
|
||||
<SpecRow {attribute} />
|
||||
{/each}
|
||||
</dl>
|
||||
3
src/lib/components/ui/specCard/index.ts
Normal file
3
src/lib/components/ui/specCard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import SpecCard from './SpecCard.svelte'
|
||||
|
||||
export { SpecCard }
|
||||
16
src/lib/components/ui/tabs/index.ts
Normal file
16
src/lib/components/ui/tabs/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import Root from "./tabs.svelte";
|
||||
import Content from "./tabs-content.svelte";
|
||||
import List from "./tabs-list.svelte";
|
||||
import Trigger from "./tabs-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
List,
|
||||
Trigger,
|
||||
//
|
||||
Root as Tabs,
|
||||
Content as TabsContent,
|
||||
List as TabsList,
|
||||
Trigger as TabsTrigger,
|
||||
};
|
||||
17
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
17
src/lib/components/ui/tabs/tabs-content.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="tabs-content"
|
||||
class={cn("flex-1 outline-none", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
20
src/lib/components/ui/tabs/tabs-list.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.ListProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.List
|
||||
bind:ref
|
||||
data-slot="tabs-list"
|
||||
class={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
20
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
20
src/lib/components/ui/tabs/tabs-trigger.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Trigger
|
||||
bind:ref
|
||||
data-slot="tabs-trigger"
|
||||
class={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
19
src/lib/components/ui/tabs/tabs.svelte
Normal file
19
src/lib/components/ui/tabs/tabs.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Tabs as TabsPrimitive } from "bits-ui";
|
||||
import { cn } from "$lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
class: className,
|
||||
...restProps
|
||||
}: TabsPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<TabsPrimitive.Root
|
||||
bind:ref
|
||||
bind:value
|
||||
data-slot="tabs"
|
||||
class={cn("flex flex-col gap-2", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
24
src/lib/components/ui/variations/CapacityVarations.svelte
Normal file
24
src/lib/components/ui/variations/CapacityVarations.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { CapacityProductVariation } from '$lib'
|
||||
import * as m from '$lib/paraglide/messages'
|
||||
import { ToggleButton } from '../button'
|
||||
import { Label } from '../label'
|
||||
|
||||
const { variations }: { variations: CapacityProductVariation[] } = $props()
|
||||
|
||||
let selectedVariation = $state('')
|
||||
</script>
|
||||
|
||||
<div class="mb-6">
|
||||
<Label class="mb-3">{m['capacity_variation.title']()}</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
{#each variations as variation (variation.numericValue)}
|
||||
<ToggleButton
|
||||
onclick={() => (selectedVariation = variation.name)}
|
||||
selected={selectedVariation === variation.name}
|
||||
>
|
||||
{variation.name}
|
||||
</ToggleButton>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
24
src/lib/components/ui/variations/ColorVariations.svelte
Normal file
24
src/lib/components/ui/variations/ColorVariations.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { ColorProductVariation } from '$lib'
|
||||
import * as m from '$lib/paraglide/messages'
|
||||
import { Label } from '../label'
|
||||
import ColorButton from './color/ColorButton.svelte'
|
||||
|
||||
const { variations }: { variations: ColorProductVariation[] } = $props()
|
||||
|
||||
let selectedColor = $state('')
|
||||
</script>
|
||||
|
||||
<div class="mb-6">
|
||||
<Label class="mb-3">{m['color_variation.title']()}</Label>
|
||||
<div class="flex flex-row gap-2">
|
||||
{#each variations as variation (variation.hex)}
|
||||
<ColorButton
|
||||
onclick={(input) => (selectedColor = input.name)}
|
||||
selected={selectedColor === variation.name}
|
||||
hex={variation.hex}
|
||||
name={variation.name}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
58
src/lib/components/ui/variations/ConditionVariations.svelte
Normal file
58
src/lib/components/ui/variations/ConditionVariations.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import type { ConditionEnum, ConditionProductVariation } from '$lib'
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group'
|
||||
import { Label } from '$lib/components/ui/label'
|
||||
import * as Card from '../card'
|
||||
import { cn } from '$lib/utils'
|
||||
import Badge from '../badge/badge.svelte'
|
||||
import { m } from '$lib/paraglide/messages'
|
||||
|
||||
const { variations }: { variations: ConditionProductVariation[] } = $props()
|
||||
|
||||
const titleMap: Record<ConditionEnum, () => string> = {
|
||||
excellent: m.condition_variation_excellent,
|
||||
very_good: m.condition_variation_very_good,
|
||||
good: m.condition_variation_good,
|
||||
fair: m.condition_variation_fair,
|
||||
}
|
||||
|
||||
const descriptionMap: Record<ConditionEnum, () => string> = {
|
||||
excellent: m.condition_variation_description_excellent,
|
||||
very_good: m.condition_variation_description_very_good,
|
||||
good: m.condition_variation_description_good,
|
||||
fair: m.condition_variation_description_fair,
|
||||
}
|
||||
|
||||
let selectedCondition = $state('')
|
||||
</script>
|
||||
|
||||
<div class="mb-6">
|
||||
<Label class="mb-3">{m.condition_title()}</Label>
|
||||
|
||||
<RadioGroup.Root bind:value={selectedCondition}>
|
||||
{#each variations as variation, i (variation.name)}
|
||||
<Card.Root
|
||||
class={cn(
|
||||
'flex flex-row p-4 items-center cursor-pointer transition-all border-2 shadow-none',
|
||||
selectedCondition === variation.name
|
||||
? 'border-blue-600 dark:border-blue-500 bg-blue-50 dark:bg-blue-950'
|
||||
: '',
|
||||
)}
|
||||
onclick={() => (selectedCondition = variation.name)}
|
||||
>
|
||||
<RadioGroup.Item value={variation.name} id={`r${i}`} />
|
||||
<div>
|
||||
<div class="flex flex-row gap-4">
|
||||
<Label class="mb-1" for={`r${i}`}
|
||||
>{titleMap[variation.name]()}</Label
|
||||
>
|
||||
<Badge variant="secondary">{variation.numericValue}%</Badge>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{descriptionMap[variation.name]()}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
31
src/lib/components/ui/variations/color/ColorButton.svelte
Normal file
31
src/lib/components/ui/variations/color/ColorButton.svelte
Normal file
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
export type ColorButtonInput = {
|
||||
hex: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const {
|
||||
onclick,
|
||||
hex,
|
||||
name,
|
||||
selected = $bindable(),
|
||||
}: {
|
||||
onclick: (input: ColorButtonInput) => void
|
||||
hex: string
|
||||
name: string
|
||||
selected: boolean
|
||||
} = $props()
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={() => onclick({ hex, name })}
|
||||
class:border-blue-600={selected}
|
||||
class:dark:border-blue-500={selected}
|
||||
class:ring-2={selected}
|
||||
class:ring-blue-200={selected}
|
||||
class:dark:ring-blue-900={selected}
|
||||
class="cursor-pointer relative w-12 h-12 rounded-full border-2 transition-all border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500"
|
||||
title={name}
|
||||
>
|
||||
<div class="w-full h-full rounded-full" style:background-color={hex}></div>
|
||||
</button>
|
||||
@@ -1 +1,43 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
|
||||
export type ColorProductVariation = {
|
||||
type: 'color'
|
||||
name: string
|
||||
hex: string
|
||||
numericValue: null
|
||||
}
|
||||
export type CapacityProductVariation = {
|
||||
type: 'capacity'
|
||||
name: string
|
||||
hex: null
|
||||
numericValue: number
|
||||
}
|
||||
|
||||
export enum ConditionEnum {
|
||||
EXCELLENT = 'excellent',
|
||||
VERY_GOOD = 'very_good',
|
||||
GOOD = 'good',
|
||||
FAIR = 'fair',
|
||||
}
|
||||
|
||||
export type ConditionProductVariation = {
|
||||
type: 'condition'
|
||||
name: ConditionEnum
|
||||
hex: null
|
||||
numericValue: number
|
||||
}
|
||||
|
||||
export type ProductVariation =
|
||||
| ColorProductVariation
|
||||
| CapacityProductVariation
|
||||
| ConditionProductVariation
|
||||
|
||||
export type SpecificationAttribute = {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export type Specification = {
|
||||
title: string
|
||||
attributes: SpecificationAttribute[]
|
||||
}
|
||||
|
||||
13
src/lib/utils.ts
Normal file
13
src/lib/utils.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
|
||||
@@ -1,12 +1,19 @@
|
||||
<script lang="ts">
|
||||
import '../app.css'
|
||||
import favicon from '$lib/assets/favicon.svg'
|
||||
import '../app.css'
|
||||
import favicon from '$lib/assets/favicon.svg'
|
||||
import * as r from '$lib/paraglide/runtime'
|
||||
|
||||
let { children } = $props()
|
||||
r.setLocale('de-de')
|
||||
|
||||
let { children } = $props()
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
{@render children?.()}
|
||||
<div class="flex justify-center items-center">
|
||||
<div class="max-w-full lg:max-w-5xl pt-4">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,199 @@
|
||||
<script lang="ts">
|
||||
import * as Carousel from '$lib/components/ui/carousel'
|
||||
import { Badge, type BadgeVariant } from '$lib/components/ui/badge'
|
||||
import { Separator } from '$lib/components/ui/separator'
|
||||
import ColorVariations from '$lib/components/ui/variations/ColorVariations.svelte'
|
||||
import {
|
||||
ConditionEnum,
|
||||
type CapacityProductVariation,
|
||||
type ColorProductVariation,
|
||||
type ConditionProductVariation,
|
||||
type Specification,
|
||||
} from '$lib'
|
||||
import CapacityVarations from '$lib/components/ui/variations/CapacityVarations.svelte'
|
||||
import ConditionVariations from '$lib/components/ui/variations/ConditionVariations.svelte'
|
||||
import { SpecCard } from '$lib/components/ui/specCard'
|
||||
|
||||
async function crawlClevertronik() {
|
||||
const response = await fetch('/crawl', { method: 'POST' })
|
||||
const productName = await response.json()
|
||||
console.log(productName)
|
||||
}
|
||||
|
||||
type ProductBadge = {
|
||||
text: string
|
||||
variant: BadgeVariant
|
||||
}
|
||||
|
||||
const product = {
|
||||
title: 'Apple iPhone 15',
|
||||
description:
|
||||
'Das iPhone 15 bietet modernste Technologie zu einem nachhaltigen Preis. Professionell aufbereitet und vollständig getestet.',
|
||||
images: [
|
||||
'https://images.unsplash.com/photo-1702184117235-56002cb13663?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjAxNSUyMGJsYWNrfGVufDF8fHx8MTc2MzI5NDI5OHww&ixlib=rb-4.1.0&q=80&w=1080',
|
||||
'https://images.unsplash.com/photo-1710023038502-ba80a70a9f53?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjAxNSUyMHByb3xlbnwxfHx8fDE3NjMzMjEyMTF8MA&ixlib=rb-4.1.0&q=80&w=1080',
|
||||
'https://images.unsplash.com/photo-1761907174062-c8baf8b7edb3?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxzbWFydHBob25lJTIwbW9kZXJufGVufDF8fHx8MTc2MzM2OTEyOXww&ixlib=rb-4.1.0&q=80&w=1080',
|
||||
'https://images.unsplash.com/photo-1567985944845-cb4d1e598822?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w3Nzg4Nzd8MHwxfHNlYXJjaHwxfHxpUGhvbmUlMjBjYW1lcmF8ZW58MXx8fHwxNzYzMzkxOTUyfDA&ixlib=rb-4.1.0&q=80&w=1080',
|
||||
],
|
||||
badges: [
|
||||
{
|
||||
text: 'Refurbished',
|
||||
variant: 'default',
|
||||
},
|
||||
{
|
||||
text: 'Nachhaltig',
|
||||
variant: 'outline',
|
||||
},
|
||||
] satisfies ProductBadge[],
|
||||
specifications: [
|
||||
{
|
||||
title: 'display',
|
||||
attributes: [
|
||||
{
|
||||
key: 'display_size',
|
||||
value: '6,1 Zoll',
|
||||
},
|
||||
{
|
||||
key: 'display_technology',
|
||||
value: 'Super Retina XDR OLED',
|
||||
},
|
||||
{
|
||||
key: 'display_resolution',
|
||||
value: '2556 x 1179 Pixel (460 ppi)',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'battery',
|
||||
attributes: [
|
||||
{
|
||||
key: 'battery_video',
|
||||
value: '20',
|
||||
},
|
||||
{
|
||||
key: 'battery_streaming',
|
||||
value: '16',
|
||||
},
|
||||
{
|
||||
key: 'battery_audio',
|
||||
value: '80',
|
||||
},
|
||||
{
|
||||
key: 'battery_quickcharge',
|
||||
value: 'ja (50% in 30 Min mit 20W+)',
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies Specification[],
|
||||
variations: {
|
||||
color: [
|
||||
{
|
||||
name: 'Blau',
|
||||
type: 'color',
|
||||
hex: '#87CEFA',
|
||||
numericValue: null,
|
||||
},
|
||||
{
|
||||
name: 'Grün',
|
||||
type: 'color',
|
||||
hex: '#ADFF2F',
|
||||
numericValue: null,
|
||||
},
|
||||
{
|
||||
name: 'Gelb',
|
||||
type: 'color',
|
||||
hex: '#FFFF00',
|
||||
numericValue: null,
|
||||
},
|
||||
{
|
||||
name: 'Rose',
|
||||
type: 'color',
|
||||
hex: '#FF69B4',
|
||||
numericValue: null,
|
||||
},
|
||||
{
|
||||
name: 'Schwarz',
|
||||
type: 'color',
|
||||
hex: '#000000',
|
||||
numericValue: null,
|
||||
},
|
||||
] satisfies ColorProductVariation[],
|
||||
capacity: [
|
||||
{
|
||||
name: '128GB',
|
||||
numericValue: 128,
|
||||
hex: null,
|
||||
type: 'capacity',
|
||||
},
|
||||
{
|
||||
name: '256GB',
|
||||
numericValue: 256,
|
||||
hex: null,
|
||||
type: 'capacity',
|
||||
},
|
||||
{
|
||||
name: '512GB',
|
||||
numericValue: 512,
|
||||
hex: null,
|
||||
type: 'capacity',
|
||||
},
|
||||
] satisfies CapacityProductVariation[],
|
||||
condition: [
|
||||
{
|
||||
name: ConditionEnum.EXCELLENT,
|
||||
numericValue: 15,
|
||||
hex: null,
|
||||
type: 'condition',
|
||||
},
|
||||
{
|
||||
name: ConditionEnum.VERY_GOOD,
|
||||
numericValue: 25,
|
||||
hex: null,
|
||||
type: 'condition',
|
||||
},
|
||||
{
|
||||
name: ConditionEnum.GOOD,
|
||||
numericValue: 35,
|
||||
hex: null,
|
||||
type: 'condition',
|
||||
},
|
||||
] satisfies ConditionProductVariation[],
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<h1>Welcome to SvelteKit</h1>
|
||||
<p>
|
||||
Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the
|
||||
documentation
|
||||
</p>
|
||||
<div>
|
||||
<div class="flex lg:flex-row flex-col gap-6">
|
||||
<Carousel.Root class="max-w-xs mx-12">
|
||||
<Carousel.Previous />
|
||||
<Carousel.Content>
|
||||
{#each product.images as image}
|
||||
<Carousel.Item>
|
||||
<img class="p-1 rounded-lg" src={image} alt="" />
|
||||
</Carousel.Item>
|
||||
{/each}
|
||||
</Carousel.Content>
|
||||
<Carousel.Next />
|
||||
</Carousel.Root>
|
||||
|
||||
<button onclick={crawlClevertronik}>lets do it</button>
|
||||
<div>
|
||||
<div class="flex flex-col gap-y-1">
|
||||
<div class="flex flex-row gap-2">
|
||||
{#each product.badges as badgeInput (badgeInput.text)}
|
||||
<Badge variant={badgeInput.variant}>{badgeInput.text}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
<h1 class="text-xl">{product.title}</h1>
|
||||
<h2 class="text-md">{product.description}</h2>
|
||||
</div>
|
||||
<Separator class="mt-4 mb-6" />
|
||||
<div class="flex flex-col gap-4">
|
||||
<ColorVariations variations={product.variations.color} />
|
||||
<CapacityVarations variations={product.variations.capacity} />
|
||||
<ConditionVariations variations={product.variations.condition} />
|
||||
</div>
|
||||
<SpecCard specifications={product.specifications} />
|
||||
</div>
|
||||
</div>
|
||||
<button onclick={crawlClevertronik}>lets do it</button>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { setLocale } from '$lib/paraglide/runtime'
|
||||
import { page } from '$app/state'
|
||||
import { goto } from '$app/navigation'
|
||||
import { m } from '$lib/paraglide/messages.js'
|
||||
import { setLocale } from '$lib/paraglide/runtime'
|
||||
import { m } from '$lib/paraglide/messages.js'
|
||||
</script>
|
||||
|
||||
<h1>{m.hello_world({ name: 'SvelteKit User' })}</h1>
|
||||
<div>
|
||||
<button onclick={() => setLocale('en')}>en</button>
|
||||
<button onclick={() => setLocale('es')}>es</button>
|
||||
<button onclick={() => setLocale('de-de')}>de-de</button>
|
||||
<button onclick={() => setLocale('en')}>en</button>
|
||||
<button onclick={() => setLocale('es')}>es</button>
|
||||
<button onclick={() => setLocale('de-de')}>de-de</button>
|
||||
</div>
|
||||
<p>
|
||||
If you use VSCode, install the <a
|
||||
href="https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension"
|
||||
target="_blank">Sherlock i18n extension</a
|
||||
> for a better i18n experience.
|
||||
If you use VSCode, install the <a
|
||||
href="https://marketplace.visualstudio.com/items?itemName=inlang.vs-code-extension"
|
||||
target="_blank">Sherlock i18n extension</a
|
||||
> for a better i18n experience.
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user