Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Share link #55

Merged
merged 11 commits into from
Oct 17, 2024
34 changes: 32 additions & 2 deletions apps/class-solid/src/components/Experiment.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import {
type Accessor,
Show,
createEffect,
createMemo,
createSignal,
onCleanup,
} from "solid-js";

import { Button, buttonVariants } from "~/components/ui/button";
import { createArchive, toConfigBlob } from "~/lib/download";
import { encodeExperiment } from "~/lib/encode";
import {
type Experiment,
addExperiment,
Expand All @@ -17,7 +18,13 @@ import {
} from "~/lib/store";
import { ExperimentConfigForm } from "./ExperimentConfigForm";
import { PermutationsList } from "./PermutationsList";
import { MdiCog, MdiContentCopy, MdiDelete, MdiDownload } from "./icons";
import {
MdiCog,
MdiContentCopy,
MdiDelete,
MdiDownload,
MdiShareVariantOutline,
} from "./icons";
import {
Card,
CardContent,
Expand All @@ -40,6 +47,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import { showToast } from "./ui/toast";

export function AddExperimentDialog(props: {
nextIndex: number;
Expand Down Expand Up @@ -214,6 +222,27 @@ function DownloadExperiment(props: { experiment: Experiment }) {
);
}

function ShareExperiment(props: { experiment: Accessor<Experiment> }) {
return (
<Button
variant="outline"
title="Share experiment"
onClick={() => {
const encodedExperiment = encodeExperiment(props.experiment());
// TODO how should it be shared: dialog, adresss bar, clipboard, toast?
// window.location.hash = encodedExperiment;
const url = `${window.location.origin}#${encodedExperiment}`;
navigator.clipboard.writeText(url);
showToast({
title: "Share link copied to clipboard",
});
}}
>
<MdiShareVariantOutline />
</Button>
);
}

export function ExperimentCard(props: {
experiment: Experiment;
experimentIndex: number;
Expand Down Expand Up @@ -253,6 +282,7 @@ export function ExperimentCard(props: {
>
<MdiDelete />
</Button>
<ShareExperiment experiment={experiment} />
</Show>
</CardFooter>
</Card>
Expand Down
6 changes: 3 additions & 3 deletions apps/class-solid/src/components/PermutationsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ function AddPermutationButton(props: {
experimentIndex: number;
}) {
const [open, setOpen] = createSignal(false);
const permutationName = `${props.experiment.permutations.length + 1}`;
const permutationName = () => `${props.experiment.permutations.length + 1}`;
return (
<Dialog open={open()} onOpenChange={setOpen}>
<DialogTrigger
Expand All @@ -118,14 +118,14 @@ function AddPermutationButton(props: {
id="add-permutation-form"
reference={props.experiment.reference.config}
config={{}}
permutationName={permutationName}
permutationName={permutationName()}
onSubmit={(config) => {
const { title, description, ...strippedConfig } = config;
setPermutationConfigInExperiment(
props.experimentIndex,
-1,
strippedConfig,
title ?? permutationName,
title ?? permutationName(),
);
setOpen(false);
}}
Expand Down
18 changes: 18 additions & 0 deletions apps/class-solid/src/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,21 @@ export function MdiExclamationThick(props: JSX.IntrinsicElements["svg"]) {
</svg>
);
}

export function MdiShareVariantOutline(props: JSX.IntrinsicElements["svg"]) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81c1.66 0 3-1.34 3-3s-1.34-3-3-3s-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66c0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91s-1.31-2.92-2.92-2.92M18 4c.55 0 1 .45 1 1s-.45 1-1 1s-1-.45-1-1s.45-1 1-1M6 13c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1m12 7c-.55 0-1-.45-1-1s.45-1 1-1s1 .45 1 1s-.45 1-1 1"
/>
<title>Share</title>
</svg>
);
}
208 changes: 208 additions & 0 deletions apps/class-solid/src/components/ui/toast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import type { JSX, ValidComponent } from "solid-js";
import { Match, Switch, splitProps } from "solid-js";
import { Portal } from "solid-js/web";

import type { PolymorphicProps } from "@kobalte/core/polymorphic";
import * as ToastPrimitive from "@kobalte/core/toast";
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";

import { cn } from "~/lib/utils";

const toastVariants = cva(
"group data-[closed]:fade-out-80 data-[closed]:slide-out-to-right-full data-[opened]:slide-in-from-top-full data-[opened]:sm:slide-in-from-bottom-full pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--kb-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--kb-toast-swipe-move-x)] data-[closed]:animate-out data-[opened]:animate-in data-[swipe=end]:animate-out data-[swipe=move]:transition-none",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
success:
"success border-success-foreground bg-success text-success-foreground",
warning:
"warning border-warning-foreground bg-warning text-warning-foreground",
error: "error border-error-foreground bg-error text-error-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
type ToastVariant = NonNullable<VariantProps<typeof toastVariants>["variant"]>;

type ToastListProps<T extends ValidComponent = "ol"> =
ToastPrimitive.ToastListProps<T> & {
class?: string | undefined;
};

const Toaster = <T extends ValidComponent = "ol">(
props: PolymorphicProps<T, ToastListProps<T>>,
) => {
const [local, others] = splitProps(props as ToastListProps, ["class"]);
return (
<Portal>
<ToastPrimitive.Region>
<ToastPrimitive.List
class={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse gap-2 p-4 sm:top-auto sm:right-0 sm:bottom-0 sm:flex-col md:max-w-[420px]",
local.class,
)}
{...others}
/>
</ToastPrimitive.Region>
</Portal>
);
};

type ToastRootProps<T extends ValidComponent = "li"> =
ToastPrimitive.ToastRootProps<T> &
VariantProps<typeof toastVariants> & { class?: string | undefined };

const Toast = <T extends ValidComponent = "li">(
props: PolymorphicProps<T, ToastRootProps<T>>,
) => {
const [local, others] = splitProps(props as ToastRootProps, [
"class",
"variant",
]);
return (
<ToastPrimitive.Root
class={cn(toastVariants({ variant: local.variant }), local.class)}
{...others}
/>
);
};

type ToastCloseButtonProps<T extends ValidComponent = "button"> =
ToastPrimitive.ToastCloseButtonProps<T> & { class?: string | undefined };

const ToastClose = <T extends ValidComponent = "button">(
props: PolymorphicProps<T, ToastCloseButtonProps<T>>,
) => {
const [local, others] = splitProps(props as ToastCloseButtonProps, ["class"]);
return (
<ToastPrimitive.CloseButton
class={cn(
"absolute top-2 right-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-destructive-foreground group-[.error]:text-error-foreground group-[.success]:text-success-foreground group-[.warning]:text-warning-foreground",
local.class,
)}
{...others}
>
{/* biome-ignore lint/a11y/noSvgWithoutTitle: <explanation> */}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4"
>
<path d="M18 6l-12 12" />
<path d="M6 6l12 12" />
</svg>
</ToastPrimitive.CloseButton>
);
};

type ToastTitleProps<T extends ValidComponent = "div"> =
ToastPrimitive.ToastTitleProps<T> & {
class?: string | undefined;
};

const ToastTitle = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, ToastTitleProps<T>>,
) => {
const [local, others] = splitProps(props as ToastTitleProps, ["class"]);
return (
<ToastPrimitive.Title
class={cn("font-semibold text-sm", local.class)}
{...others}
/>
);
};

type ToastDescriptionProps<T extends ValidComponent = "div"> =
ToastPrimitive.ToastDescriptionProps<T> & { class?: string | undefined };

const ToastDescription = <T extends ValidComponent = "div">(
props: PolymorphicProps<T, ToastDescriptionProps<T>>,
) => {
const [local, others] = splitProps(props as ToastDescriptionProps, ["class"]);
return (
<ToastPrimitive.Description
class={cn("text-sm opacity-90", local.class)}
{...others}
/>
);
};

function showToast(props: {
title?: JSX.Element;
description?: JSX.Element;
variant?: ToastVariant;
duration?: number;
}) {
ToastPrimitive.toaster.show((data) => (
<Toast
toastId={data.toastId}
variant={props.variant}
duration={props.duration}
>
<div class="grid gap-1">
{props.title && <ToastTitle>{props.title}</ToastTitle>}
{props.description && (
<ToastDescription>{props.description}</ToastDescription>
)}
</div>
<ToastClose />
</Toast>
));
}

function showToastPromise<T, U>(
promise: Promise<T> | (() => Promise<T>),
options: {
loading?: JSX.Element;
success?: (data: T) => JSX.Element;
error?: (error: U) => JSX.Element;
duration?: number;
},
) {
const variant: { [key in ToastPrimitive.ToastPromiseState]: ToastVariant } = {
pending: "default",
fulfilled: "success",
rejected: "error",
};
return ToastPrimitive.toaster.promise<T, U>(promise, (props) => (
<Toast
toastId={props.toastId}
variant={variant[props.state]}
duration={options.duration}
>
<Switch>
<Match when={props.state === "pending"}>{options.loading}</Match>
<Match when={props.state === "fulfilled"}>
{/* biome-ignore lint/style/noNonNullAssertion: <explanation> */}
{options.success?.(props.data!)}
</Match>
<Match when={props.state === "rejected"}>
{/* biome-ignore lint/style/noNonNullAssertion: <explanation> */}
{options.error?.(props.error!)}
</Match>
</Switch>
</Toast>
));
}

export {
Toaster,
Toast,
ToastClose,
ToastTitle,
ToastDescription,
showToast,
showToastPromise,
};
2 changes: 1 addition & 1 deletion apps/class-solid/src/lib/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ExperimentConfigSchema } from "@classmodel/class/validate";
import { BlobReader, BlobWriter, ZipWriter } from "@zip.js/zip.js";
import type { Experiment } from "./store";

function toConfig(experiment: Experiment): ExperimentConfigSchema {
export function toConfig(experiment: Experiment): ExperimentConfigSchema {
return {
name: experiment.name,
description: experiment.description,
Expand Down
47 changes: 47 additions & 0 deletions apps/class-solid/src/lib/encode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
type ExperimentConfigSchema,
type PartialConfig,
parseExperimentConfig,
} from "@classmodel/class/validate";
import type { Experiment } from "./store";

/**
* URL safe representation of an experiment
*
* @param experiment
* @returns
*/
export function encodeExperiment(experiment: Experiment) {
const minimizedExperiment = {
n: experiment.name,
d: experiment.description,
r: experiment.reference.config,
p: experiment.permutations.map((perm) => ({
n: perm.name,
c: perm.config,
})),
};
return encodeURIComponent(JSON.stringify(minimizedExperiment, undefined, 0));
}

/**
* Decode an experiment config from a URL safe string
*
* @param encoded
* @returns
*
*/
export function decodeExperiment(encoded: string): ExperimentConfigSchema {
const decoded = decodeURIComponent(encoded);
const parsed = JSON.parse(decoded);
const rawExperiment = {
name: parsed.n,
description: parsed.d,
reference: parsed.r,
permutations: parsed.p.map((perm: { n: string; c: PartialConfig }) => ({
name: perm.n,
config: perm.c,
})),
};
return parseExperimentConfig(rawExperiment);
}
Loading