Initial commit

This commit is contained in:
Arnaud 2024-08-20 15:57:58 +02:00
commit 263dd53132
No known key found for this signature in database
GPG Key ID: 69D6CE281FCAE663
94 changed files with 13459 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

21
.storybook/main.ts Normal file
View File

@ -0,0 +1,21 @@
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: [
"../stories/**/*.mdx",
"../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)",
],
addons: [
"@storybook/addon-onboarding",
"@storybook/addon-links",
"@storybook/addon-essentials",
"@chromatic-com/storybook",
"@storybook/addon-interactions",
// 'storybook-dark-mode'
],
framework: {
name: "@storybook/react-vite",
options: {},
},
};
export default config;

8
.storybook/manager.ts Normal file
View File

@ -0,0 +1,8 @@
import { addons } from '@storybook/manager-api';
import { themes } from '@storybook/theming';
addons.setConfig({
theme: {
...themes.dark,
},
});

View File

@ -0,0 +1,43 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&amp;display=swap"
rel="stylesheet"
/>
<style>
:root {
--codex-background: rgb(23 23 23);
--codex-color: #e1e4d9;
--codex-color-contrast: #f8f8f8;
--codex-color-error: #f85723;
--codex-color-primary: #c1f0a4;
--codex-color-primary-variant: #c1f0a4cc;
--codex-color-on-primary: #333;
--codex-color-disabled: #717171;
--codex-color-light: #aba9ad;
--codex-border-color: rgb(82 82 82);
--codex-background-secondary: rgb(38 38 38);
--codex-background-light: rgb(64 64 64);
--codex-background-backdrop: rgba(70, 70, 70, 0.75);
--codex-border-radius: 0.5rem;
--codex-font-size: 0.875rem;
--codex-font-family: Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans,
sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol,
Noto Color Emoji;
--codex-color-warning: rgb(234 179 8);
font-family: var(--codex-font-family);
font-feature-settings: normal;
font-variation-settings: normal;
tab-size: 4;
font-size: 0.875rem;
line-height: 1.5rem;
font-size: var(--codex-font-size);
color-scheme: dark;
}
p {
margin: 0;
}
</style>

21
.storybook/preview.ts Normal file
View File

@ -0,0 +1,21 @@
import type { Preview } from "@storybook/react";
import { themes } from "@storybook/theming";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
docs: {
theme: themes.dark,
},
backgrounds: {
default: "dark",
},
},
};
export default preview;

9
.vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/// <reference types='vite/client' />
interface ImportMetaEnv {
VITE_CODEX_API_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

28
README.md Normal file
View File

@ -0,0 +1,28 @@
# Codex Marketplace Components
This project provide UI components for Codex Marketplace.
It's in pre-alpha version, the API will change.
## Philosophy
This CSS is written with semantic style. For more details check this [link](https://maintainablecss.com/chapters/semantics).
The components are designed for Codex Marketplace but they can be customized with CSS variables at the global level or at the component level (class or style).
## Prerequisites
- Node 18+
## Install
Run the npm install command:
```
npm install
```
## Run
```
npm run storybook
```

View File

@ -0,0 +1,47 @@
import "./alert.css";
import React, { CSSProperties, ReactNode } from "react";
interface CustomStyleCSS extends CSSProperties {
"--codex-border-radius"?: string;
"--codex-color-primary"?: string;
"--codex-color-warning"?: string;
"--codex-font-family"?: string;
}
type Props = {
variant: "success" | "warning" | "toast";
message: string;
className?: string;
/**
* Apply custom css variables.
* --codex-border-radius
* --codex-color-primary: string;
* --codex-color-warning
* --codex-font-family
*/
style?: CustomStyleCSS;
};
export function Alert({
variant,
message,
style,
className = "",
...rest
}: Props) {
return (
<div
className={`alert alert--${variant} ${className}`}
style={style}
{...rest}
>
<p>
<b className="alert-message">{variant} ! </b>
</p>
<p>{message}</p>
</div>
);
}

View File

@ -0,0 +1,23 @@
.alert {
border: 1px solid var(--codex-alert-border-color);
color: 1px solid var(--codex-alert-border-color);
border-radius: var(--codex-border-radius);
padding: 0.75rem 1.5rem;
font-family: var(--codex-font-family);
color: var(--codex-alert-color);
word-break: break-word;
}
.alert-message {
text-transform: capitalize;
}
.alert--success {
--codex-alert-border-color: var(--codex-color-primary);
--codex-alert-color: var(--codex-color-primary);
}
.alert--warning {
--codex-alert-border-color: var(--codex-color-warning);
--codex-alert-color: var(--codex-color-warning);
}

View File

@ -0,0 +1,31 @@
import { CSSProperties, useEffect } from "react";
import { attributes } from "../../utils/attributes";
import "./backdrop.css";
import React from "react";
interface CustomStyleCSS extends CSSProperties {
"--codex-background-backdrop"?: string;
}
type Props = {
open: boolean;
onClose: () => void;
/**
* Apply custom css variables.
* --codex-background-backdrop
*/
style?: CustomStyleCSS;
};
export function Backdrop({ open, onClose, style }: Props) {
const attr = attributes({ "aria-expanded": open });
useEffect(() => {
document.documentElement.classList.toggle("document-noOverflow");
}, [open]);
return (
<div {...attr} className="backdrop" onClick={onClose} style={style}></div>
);
}

View File

@ -0,0 +1,12 @@
.backdrop[aria-expanded] {
height: 100%;
width: 100%;
position: fixed;
top: 0;
left: 0;
background: var(--codex-background-backdrop);
display: none;
backdrop-filter: blur(2px);
display: block;
z-index: 1;
}

View File

@ -0,0 +1,95 @@
import React, { ComponentType, CSSProperties, ReactNode } from "react";
import "./button.css";
import { attributes } from "../../utils/attributes";
interface CustomStyleCSS extends CSSProperties {
"--codex-color-primary"?: string;
"--codex-border-radius"?: string;
"--codex-border-color"?: string;
"--codex-font-family"?: string;
"--codex-color-on-primary"?: string;
"--codex-color-disabled"?: string;
"--codex-color-outline"?: string;
"--codex-button-loader"?: string;
"--codex-button-background-busy"?: string;
"--codex-button-color-box-shadow"?: string;
}
type Props = {
/**
* Button style variant. Default is primary.
*/
variant?: "outline" | "primary";
onClick?: () => unknown | Promise<unknown>;
label: string;
/**
* Boolean to indicate that some work is in progress.
* It will display an indicator in the button.
* Default is false.
*/
fetching?: boolean;
/**
* Default is false.
*/
disabled?: boolean;
/**
* Add an icon before the label.
*/
Icon?: ComponentType;
/**
* Apply custom classname.
*/
className?: string;
/**
* Apply custom css variables.
* --codex-color-primary
* --codex-border-radius
* --codex-border-color
* --codex-font-family
* --codex-color-on-primary: The colors when the button is primary
* --codex-color-disabled: The disabled background color
* --codex-color-outline: The color when the button is outline
* --codex-button-loader: The url svg image for the spinner
* --codex-button-background-busy: The background color image when the button is busy
* --codex-button-color-box-shadow: The shadow color on focus
*/
style?: CustomStyleCSS;
};
export function Button({
label,
className = "",
Icon,
fetching = false,
disabled = false,
style,
variant = "primary",
onClick,
}: Props) {
return (
<button
onClick={onClick}
style={style}
className={`button ${className} button--${variant}`}
{...attributes({
disabled: disabled || fetching,
"aria-disabled": disabled || fetching,
"aria-busy": fetching,
})}
>
{Icon && (
<div className="button-icon">
<Icon />
</div>
)}
<span className="button-label">{label}</span>
</button>
);
}

View File

@ -0,0 +1,96 @@
.button {
border-radius: var(--codex-border-radius);
outline: none;
padding: 0.75rem 1.5rem;
display: flex;
place-items: center;
gap: 0.75rem;
font-weight: 500;
position: relative;
border: none;
transition:
box-shadow 0.35s,
opacity 0.35s;
font-family: var(--codex-font-family);
border: 1px solid transparent;
}
.button--primary {
background-color: var(--codex-color-primary);
color: var(--codex-color-on-primary);
}
.button:disabled {
cursor: not-allowed;
}
.button--primary:disabled {
background-color: var(--codex-color-disabled);
}
.button--outline:disabled {
color: var(--codex-color-disabled);
}
.button-label {
display: flex;
align-items: center;
gap: 0.75rem;
}
.button--outline {
color: var(--codex-color-outline, var(--codex-color-contrast));
border-color: var(--codex-border-color);
border-width: 1px;
border-style: solid;
background-color: transparent;
}
.button[aria-busy] {
cursor: wait;
}
.button-icon {
width: 16px;
height: 16px;
display: flex;
place-items: center;
}
.button[aria-busy]::after {
content: " ";
display: block;
background-image: var(
--codex-button-loader,
url('data:image/svg+xml,<svg version="1.1" id="loader-1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 50 50" style="enable-background: new 0 0 50 50" xml:space="preserve"><path fill="%23FFF" d="M43.935,25.145c0-10.318-8.364-18.683-18.683-18.683c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615c8.072,0,14.615,6.543,14.615,14.615H43.935z"><animateTransform attributeType="xml" attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="0.6s" repeatCount="indefinite"></animateTransform></path></svg>')
);
position: absolute;
background-color: var(
--codex-button-background-busy,
var(--codex-background-backdrop)
);
/**
* Set full size and add border.
*/
width: calc(100% + 2px);
height: calc(100% + 2px);
background-repeat: no-repeat;
background-position: center;
background-size: 28px;
left: -1px;
right: 0;
border-radius: var(--codex-border-radius);
}
.button--primary:not(:disabled):hover {
cursor: pointer;
box-shadow: 0 0 0 3px
var(--codex-button-color-box-shadow, var(--codex-color-primary-variant));
}
.button--outline:not(:disabled):hover {
cursor: pointer;
box-shadow: 0 0 0 2px var(--codex-border-color);
}

View File

@ -0,0 +1,48 @@
import React, { ComponentType, CSSProperties } from "react";
import "./buttonIcon.css";
import { attributes } from "../../utils/attributes";
interface CustomStyleCSS extends CSSProperties {
"--codex-button-icon-background"?: string;
"--codex-border-color"?: string;
"--codex-color-disabled"?: string;
}
type Props = {
Icon: ComponentType;
variant?: "big" | "small";
onClick?: () => void;
disabled?: boolean;
/**
* Apply custom css variables.
* --codex-button-icon-background
* --codex-border-color
* --codex-color-disabled
*/
style?: CustomStyleCSS;
className?: string;
};
export function ButtonIcon({
Icon,
onClick,
style,
disabled = false,
variant = "big",
}: Props) {
return (
<button
className={`buttonIcon buttonIcon--${variant}`}
onClick={onClick}
style={style}
{...attributes({ disabled: disabled, "aria-disabled": disabled })}
>
<Icon />
</button>
);
}

View File

@ -0,0 +1,37 @@
.buttonIcon {
display: flex;
align-items: center;
justify-content: center;
background-color: var(
--codex-button-icon-background,
var(--codex-background-light)
);
border-radius: 50%;
cursor: pointer;
transition: box-shadow 0.35s;
border: none;
}
.buttonIcon--big {
width: 4rem;
height: 4rem;
}
.buttonIcon--small {
width: 2rem;
height: 2rem;
}
.buttonIcon svg {
mix-blend-mode: difference;
}
.buttonIcon:not(:disabled):hover {
cursor: pointer;
box-shadow: 0 0 0 2px var(--codex-border-color);
}
.buttonIcon:disabled {
color: var(--codex-color-disabled);
cursor: not-allowed;
}

33
components/Card/Card.tsx Normal file
View File

@ -0,0 +1,33 @@
import React, { CSSProperties, ReactNode } from "react";
import "./card.css";
interface CustomStyleCSS extends CSSProperties {
"--codex-border-radius"?: string;
"--codex-border-color"?: string;
"--codex-font-family"?: string;
}
type Props = {
children: ReactNode;
className?: string;
title: string;
/**
* Apply custom css variables.
* --codex-border-radius
* --codex-border-color
* --codex-font-family
*/
style?: CustomStyleCSS;
};
export function Card({ children, style, className = "", title }: Props) {
return (
<div className={`card ${className}`} style={style}>
<div className="card-header">{title}</div>
<div className="card-body">{children}</div>
</div>
);
}

14
components/Card/card.css Normal file
View File

@ -0,0 +1,14 @@
.card {
border-radius: var(--codex-border-radius);
border: 1px solid var(--codex-border-color);
font-family: var(--codex-font-family);
}
.card-header {
border-bottom: 1px solid var(--codex-border-color);
padding: 0.75rem 1.5rem;
}
.card-body {
padding: 0.75rem 1.5rem;
}

View File

@ -0,0 +1,133 @@
import { ChangeEvent, ComponentType, useState } from "react";
import "./dropdown.css";
import { attributes } from "../../utils/attributes";
import { Backdrop } from "../Backdrop/Backdrop";
import React from "react";
import { Input, InputCustomStyleCSS } from "../Input/Input";
interface CustomStyleCSS extends InputCustomStyleCSS {
"--codex-dropdown-panel-background"?: string;
"--codex-dropdown-border"?: string;
"--codex-dropdown-option-background-hover"?: string;
}
export type DropdownOption = {
/**
* Dropdown option icon displayed on the left
*/
Icon?: ComponentType;
/**
* Main option text elemnt
*/
title: string;
/**
* Subtitle displayed under the main title of the option
*/
subtitle?: string;
};
type Props = {
/**
* Placeholder used when no option is selected
*/
placeholder: string;
options: DropdownOption[];
/**
* Default value passed to the dropdown
*/
value?: string;
className?: string;
/**
* OnChange event triggered every time the text is updated
*/
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
/**
* OnSelected triggered every time an option is clicked on
*/
onSelected?: (o: DropdownOption) => void;
/**
* Apply custom css variables.
* --codex-dropdown-panel-background
* --codex-dropdown-border
* --codex-dropdown-option-background-hover
*/
style?: CustomStyleCSS;
};
export function Dropdown({
placeholder,
style,
options,
onChange,
onSelected,
value = "",
className = "",
}: Props) {
const lower = value.toLocaleLowerCase();
const filtered = options.filter(
(o) =>
o.title.toLocaleLowerCase().includes(lower) ||
o.subtitle?.toLocaleLowerCase().includes(lower)
);
const [focused, setFocused] = useState(false);
const onFocus = () => setFocused(true);
const onBlur = () => () => window.setTimeout(() => setFocused(false), 150);
const onClick = (o: DropdownOption) => {
onSelected?.(o);
setFocused(false);
};
const onClose = () => setFocused(false);
const attr = attributes({ "aria-expanded": focused });
return (
<div className={`dropdown ${className}`} style={style}>
<Backdrop onClose={onClose} open={focused} />
<Input
className="dropdown-input"
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
value={value}
label={""}
id={""}
/>
<div className="dropdown-panel" {...attr}>
{filtered.length ? (
filtered.map((o) => (
<div
className="dropdown-option"
onClick={() => onClick(o)}
key={o.title}
>
{o.Icon && <o.Icon />}
<div>
<span className="dropdown-title">{o.title}</span>
{o.subtitle && (
<span className="dropdown-subtitle">{o.subtitle}</span>
)}
</div>
</div>
))
) : (
<p className="dropdown-noResults">No results found</p>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,74 @@
.dropdown {
position: relative;
}
.dropdown-panel {
position: absolute;
padding: 0.25rem;
background-color: var(
--codex-dropdown-panel-background,
var(--codex-background-secondary)
);
border: var(--codex-dropdown-border, 1px solid var(--codex-border-color));
border-radius: var(--codex-border-radius);
left: 0;
right: 0;
opacity: 0;
transform: translateY(-3rem);
transition:
transform 0.35s,
opacity 0.15s,
z-index 0.35s;
max-height: 20rem;
overflow-y: auto;
z-index: -1;
}
.dropdown-panel[aria-expanded] {
z-index: 2;
transform: translateY(0.5rem);
opacity: 1;
z-index: 2;
}
.dropdown-input {
position: relative;
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8.35355 4.06066C8.15829 3.8654 7.84171 3.8654 7.64645 4.06066L5.35355 6.35355C5.15829 6.54882 4.84171 6.54882 4.64645 6.35355C4.45118 6.15829 4.45118 5.84171 4.64645 5.64645L6.93934 3.35356C7.52513 2.76777 8.47487 2.76777 9.06066 3.35355L11.3536 5.64645C11.5488 5.84171 11.5488 6.15829 11.3536 6.35355C11.1583 6.54882 10.8417 6.54882 10.6464 6.35355L8.35355 4.06066Z' fill='%236b7280'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8.35355 11.9393C8.15829 12.1346 7.84171 12.1346 7.64645 11.9393L5.35355 9.64645C5.15829 9.45119 4.84171 9.45119 4.64645 9.64645C4.45118 9.84171 4.45118 10.1583 4.64645 10.3536L6.93934 12.6464C7.52513 13.2322 8.47487 13.2322 9.06066 12.6464L11.3536 10.3536C11.5488 10.1583 11.5488 9.84171 11.3536 9.64645C11.1583 9.45119 10.8417 9.45119 10.6464 9.64645L8.35355 11.9393Z' fill='%236b7280'/%3E%3C/svg%3E%0A");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.25em 1.25em;
}
.dropdown-input:focus {
z-index: 2;
}
.dropdown-option {
padding: 0.75rem 0.25rem;
border-radius: var(--codex-border-radius);
transition: background-color 0.35s;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
}
.dropdown-option:hover {
background-color: var(
--codex-dropdown-option-background-hover,
var(--codex-background-light)
);
}
.dropdown-noResults {
padding: 0.75rem 0.25rem;
}
.dropdown-title {
display: block;
}
.dropdown-subtitle {
mix-blend-mode: difference;
font-size: 0.75rem;
}

View File

@ -0,0 +1,28 @@
import { Button } from "../Button/Button";
import "./emptyPlaceholder.css";
import React from "react";
import { EmptyPlaceholderIcon } from "./EmptyPlaceholderIcon";
type Props = {
title: string;
message: string;
onRetry?: () => void | Promise<void>;
};
export function EmptyPlaceholder({ title, message, onRetry }: Props) {
return (
<div className="emptyPlaceholder">
<EmptyPlaceholderIcon className="emptyPlaceholder-icon" width={178} />
<b className="emptyPlaceholder-title">{title}</b>
<p className="emptyPlaceholder-message">{message} </p>
{onRetry && (
<Button
label="Retry"
onClick={onRetry}
className="emptyPlaceholder-button"
/>
)}
</div>
);
}

View File

@ -0,0 +1,196 @@
import React from "react";
import "./emptyPlaceholderIcon.css";
type Props = {
className?: string;
width?: number | string;
};
export function EmptyPlaceholderIcon({ width, className }: Props) {
return (
<svg
width={width}
className={`${className} emptyPlaceholderIcon`}
viewBox="0 0 178 90"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="27"
y="50.5"
width="124"
height="39"
rx="7.5"
fill="currentColor"
className="g3njg dark:fill-neutral-800"
></rect>
<rect
x="27"
y="50.5"
width="124"
height="39"
rx="7.5"
stroke="currentColor"
className="ir0tw dark:stroke-neutral-700/10"
></rect>
<rect
x="34.5"
y="58"
width="24"
height="24"
rx="4"
fill="currentColor"
className="q1q6p dark:fill-neutral-700/30"
></rect>
<rect
x="66.5"
y="61"
width="60"
height="6"
rx="3"
fill="currentColor"
className="q1q6p dark:fill-neutral-700/30"
></rect>
<rect
x="66.5"
y="73"
width="77"
height="6"
rx="3"
fill="currentColor"
className="q1q6p dark:fill-neutral-700/30"
></rect>
<rect
x="19.5"
y="28.5"
width="139"
height="39"
rx="7.5"
fill="currentColor"
className="g3njg dark:fill-neutral-800"
></rect>
<rect
x="19.5"
y="28.5"
width="139"
height="39"
rx="7.5"
stroke="currentColor"
className="byl25 dark:stroke-neutral-700/30"
></rect>
<rect
x="27"
y="36"
width="24"
height="24"
rx="4"
fill="currentColor"
className="m1d7o dark:fill-neutral-700/70"
></rect>
<rect
x="59"
y="39"
width="60"
height="6"
rx="3"
fill="currentColor"
className="m1d7o dark:fill-neutral-700/70"
></rect>
<rect
x="59"
y="51"
width="92"
height="6"
rx="3"
fill="currentColor"
className="m1d7o dark:fill-neutral-700/70"
></rect>
<g filter="url(#filter19)">
<rect
x="12"
y="6"
width="154"
height="40"
rx="8"
fill="currentColor"
className="g3njg dark:fill-neutral-800"
shapeRendering="crispEdges"
></rect>
<rect
x="12.5"
y="6.5"
width="153"
height="39"
rx="7.5"
stroke="currentColor"
className="byl25 dark:stroke-neutral-700/60"
shapeRendering="crispEdges"
></rect>
<rect
x="20"
y="14"
width="24"
height="24"
rx="4"
fill="currentColor"
className="ylane dark:fill-neutral-700 "
></rect>
<rect
x="52"
y="17"
width="60"
height="6"
rx="3"
fill="currentColor"
className="ylane dark:fill-neutral-700"
></rect>
<rect
x="52"
y="29"
width="106"
height="6"
rx="3"
fill="currentColor"
className="ylane dark:fill-neutral-700"
></rect>
</g>
<defs>
<filter
id="filter19"
x="0"
y="0"
width="178"
height="64"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
></feColorMatrix>
<feOffset dy="6"></feOffset>
<feGaussianBlur stdDeviation="6"></feGaussianBlur>
<feComposite in2="hardAlpha" operator="out"></feComposite>
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.03 0"
></feColorMatrix>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_1187_14810"
></feBlend>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_1187_14810"
result="shape"
></feBlend>
</filter>
</defs>
</svg>
);
}

View File

@ -0,0 +1,17 @@
.emptyPlaceholder {
text-align: center;
}
.emptyPlaceholder-icon {
display: block;
margin: auto auto 0.75rem;
}
.emptyPlaceholder-title {
margin-bottom: 0.25rem;
display: inline-block;
}
.emptyPlaceholder-button {
margin: 0.75rem auto 0;
}

View File

@ -0,0 +1,20 @@
.dark\:fill-neutral-800 {
fill: #262626;
}
.dark\:fill-neutral-700 {
fill: #404040;
}
.dark\:fill-neutral-700\/30 {
fill: rgb(64 64 64 / 0.3);
}
.dark\:fill-neutral-700\/70 {
fill: rgb(64 64 64 / 0.7);
}
.emptyPlaceholderIcon {
border: none;
color: var(--codex-background-secondary);
}

View File

@ -0,0 +1,42 @@
import "./failure.css";
import { Button } from "../Button/Button";
import React from "react";
interface CustomStyleCSS extends CSSProperties {
"--codex-code-font-size"?: string;
"--codex-text-contrast"?: string;
"--codex-font-family"?: string;
}
type Props = {
code: number;
message: string;
title: string;
onClick?: () => void | Promise<void>;
button?: string;
/**
* Apply custom css variables.
* --codex-code-font-size
* --codex-text-contrast
* --codex-font-family
*/
style?: CustomStyleCSS;
};
export function Failure({
code,
message,
onClick,
button = "Retry",
title = "Something went wrong",
}: Props) {
return (
<div className="failure">
<h1 className="failure-code">{code}</h1>
<h2 className="failure-title">{title}</h2>
<p className="failure-message">{message}</p>
{onClick && <Button label={button} onClick={onClick} />}
</div>
);
}

View File

@ -0,0 +1,28 @@
.failure-code {
font-size: var(--codex-code-font-size, 6rem);
line-height: 1;
color: var(--codex-text-contrast);
font-family: var(--codex-font-family);
margin: 0;
}
.failure {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
flex: 1;
}
.failure-title {
font-size: 1.875rem;
line-height: 2.25rem;
font-weight: 600;
margin: 0.75rem 0;
color: var(--codex-text-contrast);
}
.failure-message {
margin-bottom: 0.75rem;
mix-blend-mode: difference;
}

128
components/Input/Input.tsx Normal file
View File

@ -0,0 +1,128 @@
import React, { ChangeEvent, ComponentType, CSSProperties } from "react";
import { attributes } from "../../utils/attributes";
import { classnames } from "../../utils/classnames";
import "./input.css";
import { SimpleText } from "../SimpleText/SimpleText";
export interface InputCustomStyleCSS extends CSSProperties {
"--codex-input-background"?: string;
"--codex-color"?: string;
"--codex-border-radius"?: string;
"--codex-input-border"?: string;
"--codex-color-primary"?: string;
"--codex-input-background-disabled"?: string;
}
type Props = {
label?: string;
id: string;
/**
* OnChange event triggered every time the input value changed.
*/
onChange?: (e: ChangeEvent<HTMLInputElement>) => void | Promise<void>;
onFocus?: () => void | Promise<void>;
onBlur?: () => unknown | Promise<unknown>;
placeholder?: string;
value?: string;
/**
* Apply custom css variables.
* --codex-input-background
* --codex-color
* --codex-border-radius
* --codex-input-border
* --codex-color-primary
* --codex-input-background-disabled
*/
style?: InputCustomStyleCSS;
/**
* Helper text to add indication about your input.
*/
helper?: string;
disabled?: boolean;
/**
* Add an icon on the left.
*/
Icon?: ComponentType;
className?: string;
/**
* Default is text
*/
type?: string;
step?: string;
};
export function Input({
id,
label,
helper,
disabled,
value,
onBlur,
onFocus,
placeholder,
onChange,
className,
style,
Icon,
step,
type = "text",
}: Props) {
return (
<>
{label && (
<label className="input-label" htmlFor={id}>
{label}
</label>
)}
<div className={classnames(["input-icon", !!Icon])}>
{Icon && (
<div className="input-iconElement">
<Icon />
</div>
)}
<input
className={classnames(
["input"],
["input-icon-input", !!Icon],
[className || ""]
)}
id={id}
style={style}
{...attributes({
disabled,
"aria-disabled": disabled,
})}
value={value}
placeholder={placeholder}
onBlur={onBlur}
onFocus={onFocus}
onChange={onChange}
type={type}
step={step}
/>
</div>
{helper && (
<div>
<SimpleText className="input-helper-text" variant="light">
{helper}
</SimpleText>
</div>
)}
</>
);
}

100
components/Input/input.css Normal file
View File

@ -0,0 +1,100 @@
.input {
background-color: var(
--codex-input-background,
var(--codex-background-secondary)
);
color: var(--codex-color);
border-radius: var(--codex-border-radius);
border: var(--codex-input-border, 1px solid var(--codex-border-color));
padding: 0.75rem 1rem;
outline: none;
display: inline-block;
box-sizing: border-box;
display: flex;
flex-direction: column;
}
.input-label {
margin-bottom: 0.5rem;
font-weight: 500;
display: block;
color: var(--codex-color);
}
.input:not(.input[disabled]):active,
.input:not(.input[disabled]):focus {
box-shadow: 0 0 0 1px var(--codex-border-color);
}
.input-icon {
position: relative;
}
.input-iconElement {
position: absolute;
height: 100%;
padding: 0 0.75rem;
display: flex;
align-items: center;
}
.input-icon-input {
padding-left: 2.5rem;
}
.input[disabled] {
background-color: var(
--codex-input-background-disabled,
var(--codex-background-light)
);
cursor: not-allowed;
}
.input-floating {
position: relative;
}
.input-full {
width: 100%;
}
.input-spacing {
margin-bottom: 1.5rem;
}
.input-floating-label {
position: absolute;
font-weight: 600;
color: white;
left: 1rem;
top: 0;
bottom: 0;
display: flex;
align-items: center;
transition:
top 0.15s,
font-size 0.15s;
}
.input-floating-input {
padding-top: 1.75rem;
}
.input.input-floating-input:not(:placeholder-shown) ~ .input-floating-label,
.input.input-floating-input:focus ~ .input-floating-label {
top: -25px;
font-size: 0.875rem;
mix-blend-mode: difference;
}
.input-helper-text {
margin-top: 0.25rem;
display: inline-block;
font-size: 0.9rem;
}
@media (min-width: 801px) {
.input {
min-width: 20rem;
}
}

View File

@ -0,0 +1,106 @@
import { ChangeEvent, CSSProperties } from "react";
import "./inputGroup.css";
import React from "react";
import { Input } from "../Input/Input";
import { Select } from "../Select/Select";
export interface CustomStyleCSS extends CSSProperties {
"--codex-border-radius"?: string;
"--codex-border-color"?: string;
}
type Props = {
label: string;
className?: string;
/**
* The group can be an tuple array (select) or a single value
*/
group: [string, string][] | string;
/**
* Input type
*/
type?: string;
/**
* Input value
*/
value?: string;
/**
* Group value if the group is a select
*/
groupValue?: string;
/**
* OnChange event triggered when the input change
*/
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
/**
* OnChange event triggered when the group change if the group is a select
*/
onGroupChange?: (e: ChangeEvent<HTMLSelectElement>) => void;
id: string;
step?: string;
/**
* Apply custom css variables.
* --codex-border-radius
* --codex-border-color
*/
style?: CustomStyleCSS;
};
export function InputGroup({
label,
type = "text",
style,
group,
className,
onChange,
onGroupChange,
id,
step,
value = "",
groupValue = "",
}: Props) {
return (
<div className={`inputGroup ${className}`} style={style}>
<div className="inputGroup-container">
<div className="inputGroup-element">
<div>
<Input
label={label}
onChange={onChange}
className="inputGroup-input"
id={id}
type={type}
value={value}
step={step}
/>
</div>
<div>
{Array.isArray(group) ? (
<Select
label=""
id=""
onChange={onGroupChange}
className="inputGroup-select"
defaultValue={groupValue}
options={group}
/>
) : (
<div className="inputGroup-unit">{group}</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
.inputGroup-element {
display: flex;
align-items: flex-end;
}
.inputGroup-container {
flex-grow: 1;
}
input.inputGroup-input {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
}
select.inputGroup-select {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
min-width: 110px;
}
.inputGroup-unit {
display: flex;
align-items: center;
border: 1px solid var(--codex-border-color);
border-top-right-radius: var(--codex-border-radius);
border-bottom-right-radius: var(--codex-border-radius);
background-color: var(--codex-border-color);
padding: calc(0.5rem + 0.5px) 1rem;
}

View File

@ -0,0 +1,68 @@
import React, { ChangeEvent, CSSProperties } from "react";
import "./select.css";
interface CustomStyleCSS extends CSSProperties {
"--codex-select-background"?: string;
"--codex-color"?: string;
"--codex-border-radius"?: string;
"--codex-select-border"?: string;
"--codex-select-icon-url"?: string;
}
type Props = {
label: string;
id: string;
/**
* Tuple array for options.
* The first item is the value and the second is the text.
*/
options: [string, string][];
/**
* OnChange event called whenever the select value changed.
*/
onChange?: (e: ChangeEvent<HTMLSelectElement>) => void | Promise<void>;
/**
* Apply custom css variables.
*/
style?: CustomStyleCSS;
defaultValue?: string;
className?: string;
};
export function Select({
label,
id,
options,
onChange,
style,
className,
defaultValue,
}: Props) {
return (
<>
<label htmlFor={id} className="select-label">
{label}
</label>
<div>
<select
id={id}
className={`select ${className}`}
onChange={onChange}
style={style}
defaultValue={defaultValue}
>
{options.map(([value, text]) => (
<option key={value} value={value}>
{text}
</option>
))}
</select>
</div>
</>
);
}

View File

@ -0,0 +1,44 @@
.select {
appearance: none;
-moz-appearance: none;
-webkit-appearance: none;
background-color: var(
--codex-select-background,
var(--codex-background-secondary)
);
outline: 2px solid transparent;
outline-offset: 2px;
color: var(--codex-color);
border-radius: var(--codex-border-radius);
padding: 0.75rem 1rem;
padding-inline-end: 2.25rem;
transition: box-shadow 0.35s;
border: var(--codex-select-border, 1px solid var(--codex-border-color));
background-image: var(
--codex-select-icon-url,
url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8.35355 4.06066C8.15829 3.8654 7.84171 3.8654 7.64645 4.06066L5.35355 6.35355C5.15829 6.54882 4.84171 6.54882 4.64645 6.35355C4.45118 6.15829 4.45118 5.84171 4.64645 5.64645L6.93934 3.35356C7.52513 2.76777 8.47487 2.76777 9.06066 3.35355L11.3536 5.64645C11.5488 5.84171 11.5488 6.15829 11.3536 6.35355C11.1583 6.54882 10.8417 6.54882 10.6464 6.35355L8.35355 4.06066Z' fill='%236b7280'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8.35355 11.9393C8.15829 12.1346 7.84171 12.1346 7.64645 11.9393L5.35355 9.64645C5.15829 9.45119 4.84171 9.45119 4.64645 9.64645C4.45118 9.84171 4.45118 10.1583 4.64645 10.3536L6.93934 12.6464C7.52513 13.2322 8.47487 13.2322 9.06066 12.6464L11.3536 10.3536C11.5488 10.1583 11.5488 9.84171 11.3536 9.64645C11.1583 9.45119 10.8417 9.45119 10.6464 9.64645L8.35355 11.9393Z' fill='%236b7280'/%3E%3C/svg%3E%0A")
);
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.25em 1.25em;
box-sizing: border-box;
}
.select:hover,
.select:focus-visible,
.select:active {
box-shadow: 0 0 0 1px var(--codex-border-color);
}
.select-label {
margin-bottom: 0.5rem;
font-weight: 500;
display: block;
color: var(--codex-color);
}
@media (min-width: 801px) {
.select {
min-width: 20rem;
}
}

View File

@ -0,0 +1,60 @@
import React, { CSSProperties } from "react";
import "./simpleText.css";
interface CustomStyleCSS extends CSSProperties {
"--codex-color"?: string;
"--codex-color-primary"?: string;
"--codex-color-light"?: string;
"--codex-color-error"?: string;
"--codex-color-warning"?: string;
}
type Props = {
/**
* Default variant is normal
*/
variant?: "normal" | "primary" | "light" | "error" | "warning";
className?: string;
children: string;
/**
* Apply custom css variables.
* --codex-color
* --codex-color-primary
* --codex-color-light
* --codex-color-error
* --codex-color-warning
*/
style?: CustomStyleCSS;
size?: "normal" | "small";
center?: boolean;
};
export function SimpleText({
variant = "normal",
className = "",
center,
size = "normal",
style,
children,
}: Props) {
const c = `text text--${variant} ${className} ${center ? "text--center" : ""}`;
if (size === "small") {
return (
<small className={c} style={style}>
{children}
</small>
);
}
return (
<span className={c} style={style}>
{children}
</span>
);
}

View File

@ -0,0 +1,23 @@
.text--normal {
color: var(--codex-color);
}
.text--primary {
color: var(--codex-color-primary);
}
.text--light {
color: var(--codex-color-light);
}
.text--center {
text-align: center;
}
.text--error {
color: var(--codex-color-error);
}
.text--warning {
color: var(--codex-color-warning);
}

View File

@ -0,0 +1,39 @@
import React from "react";
type Props = {
width?: string;
className?: string;
};
export function Spinner({ width, className = "" }: Props) {
return (
<svg
width={width}
className={className}
version="1.1"
id="loader-1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 50 50"
enableBackground="new 0 0 50 50"
xmlSpace="preserve"
>
<path
fill="#FFF"
d="M43.935,25.145c0-10.318-8.364-18.683-18.683-18.683c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615c8.072,0,14.615,6.543,14.615,14.615H43.935z"
>
<animateTransform
attributeType="xml"
attributeName="transform"
type="rotate"
from="0 25 25"
to="360 25 25"
dur="0.6s"
repeatCount="indefinite"
></animateTransform>
</path>
</svg>
);
}

View File

@ -0,0 +1,66 @@
import { Check } from "lucide-react";
import React, { useEffect, useRef } from "react";
import { attributes } from "../../utils/attributes";
import { classnames } from "../../utils/classnames";
type StepProps = {
title: string;
step: number;
isActive: boolean;
isLast: boolean;
isDone: boolean;
onClick?: (step: number) => void;
};
export function Step({
step,
isActive,
isLast,
isDone,
title,
onClick,
}: StepProps) {
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
}, []);
return (
<div
className={classnames(
["stepper-step", true],
["stepper-step-active", isActive]
)}
onClick={() => onClick?.(step)}
{...attributes({ disabled: !onClick })}
>
<div className="stepper-step-info">
<div
className={classnames(
["stepper-number", true],
["stepper-number-active", isActive],
["stepper-number-done", isDone]
)}
>
<span className="stepper-numberValue">
{isDone ? <Check size={"1.25rem"} /> : step + 1}
</span>
</div>
</div>
{!isLast && (
<div className="stepper-step-between">
<div
className={classnames(
["stepper-separator", true],
["stepper-separator-done", isDone],
["stepper-separator-mounted", mounted.current]
)}
></div>
<span className={"stepper-text"}>{title}</span>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,131 @@
import { Button } from "../Button/Button";
import "./stepper.css";
import React, { CSSProperties } from "react";
import { Spinner } from "../Spinner/Spinner";
import { Step } from "./Step";
interface CustomStyleCSS extends CSSProperties {
"--codex-background"?: string;
"--codex-border-radius"?: string;
"--codex-stepper-background": string;
"--codex-color-primary": string;
"--codex-border-color": string;
}
type Props = {
/**
* The steps titles
*/
titles: string[];
/**
* The current component to show.
*/
Body: () => JSX.Element;
// The current step to display in stepper state.
step: number;
// If it's true, the stepper will display a spinner.
// The progress is controlled by the parent component,
// to give flexibility when changing step.
progress: boolean;
/**
* The duration between two steps in milliseconds.
* The default is 500.
*/
duration?: number;
/**
* Callback called whenever the step is changing.
* It's working in two phase:
* "before" - allow to do before actions like updating progress state
* "end" - executed after duration time
*/
onChangeStep: (s: number, state: "before" | "end") => void | Promise<void>;
/**
* Disable the next button.
* Default: progress == true
*/
isNextDisable?: boolean;
style?: CustomStyleCSS;
};
type Step = {
index: number;
title: string;
};
export function Stepper({
titles,
Body,
step,
progress,
onChangeStep,
duration = 500,
isNextDisable = progress,
style,
}: Props) {
const onMoveStep = async (newStep: number) => {
await onChangeStep(newStep, "before");
setTimeout(() => onChangeStep(newStep, "end"), duration);
};
const label = step === titles.length - 1 ? "Finish" : "Next";
return (
<div className="stepper" style={style}>
<div className="stepper-steps">
{titles.map((title, index) => (
<Step
title={title}
step={index}
isActive={index === step}
isLast={index === titles.length - 1}
isDone={index < step}
key={title}
onClick={step > index ? () => onMoveStep(step) : undefined}
/>
))}
</div>
<div className="stepper-body">
{progress ? (
<div className="stepper-progress">
<Spinner width={"3rem"} />
</div>
) : (
<Body />
)}
</div>
<div className="stepper-buttons">
<Button
label="Back"
variant="outline"
onClick={() => onMoveStep(step - 1)}
disabled={step === 0 || progress}
/>
<Button
label={label}
onClick={() => onMoveStep(step + 1)}
disabled={isNextDisable}
/>
</div>
</div>
);
}
// <div className="stepper-success">
// <video src="/animations/success.webm" autoPlay />
// <p>
// <b>Success ! </b>
// </p>
// <p className="text-center">
// Your request has been submitted. Check your purchases list to get
// more information about the status.
// </p>
// </div>

View File

@ -0,0 +1,175 @@
.stepper {
display: flex;
flex-direction: column;
background-color: var(--codex-background);
padding: 1.5rem;
border-radius: var(--codex-border-radius);
margin-bottom: 0.5rem;
}
.stepper-progress,
.stepper-step-info,
.stepper-steps,
.stepper-step {
display: flex;
align-items: center;
gap: 0.5rem;
transition: opacity 0.35s;
}
.stepper-step:not([disabled]):not(.stepper-separator-active):hover {
cursor: pointer;
opacity: 0.8;
}
.stepper-number {
width: 1.75rem;
height: 1.75rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.35s;
}
.stepper-number:not(.stepper-number-active):not(.stepper-number-done) {
background-color: var(
--codex-stepper-background,
var(--codex-background-light)
);
}
.stepper-separator {
height: 1px;
flex: 1;
background-color: var(
--codex-stepper-background,
var(--codex-background-light)
);
position: relative;
}
.stepper-number-done,
.stepper-number-active {
background-color: var(--codex-color-primary);
}
.stepper-numberValue {
mix-blend-mode: difference;
}
.stepper-buttons {
display: flex;
justify-content: space-between;
}
.stepper-body {
margin: 1.5rem 0;
border: 1px dashed var(--codex-border-color);
border-radius: var(--codex-border-radius);
background-color: var(
--codex-stepper-background,
var(--codex-background-light)
);
min-height: 200px;
padding: 1.5rem;
display: flex;
flex-direction: column;
}
.stepper-progress {
justify-content: center;
flex: 1;
}
.stepper-numberValue {
display: flex;
align-items: center;
justify-content: center;
}
.stepper-separator::before {
background-color: var(--codex-color-primary);
height: 1px;
content: " ";
position: absolute;
top: 0;
animation-duration: 1s;
animation-name: step-back;
animation-fill-mode: forwards;
opacity: 0;
/* animation-direction: reverse; */
}
.stepper-separator-done::before {
background-color: var(--codex-color-primary);
display: inline-block;
animation-duration: 1s;
animation-name: step;
animation-fill-mode: forwards;
opacity: 1;
}
.stepper-separator-mounted::before {
opacity: 1;
}
.stepper-success {
display: flex;
flex: 1;
place-items: center;
flex-direction: column;
}
@keyframes step {
0% {
width: 0;
}
100% {
width: 100%;
}
}
@keyframes step-back {
0% {
width: 100%;
}
100% {
width: 0%;
}
}
@media (min-width: 801px) {
.stepper-container {
width: 700px;
}
.stepper-step:not(:last-child) {
flex: 1;
}
.stepper-step-between {
display: flex;
flex: 1;
position: relative;
place-items: center;
}
.stepper-text {
position: absolute;
top: 5px;
}
}
@media (max-width: 800px) {
.stepper-container {
width: 100%;
}
.stepper-step:not(.stepper-step-active) .stepper-text,
.stepper-separator {
display: none;
}
}

106
components/Toast/Toast.tsx Normal file
View File

@ -0,0 +1,106 @@
import {
ComponentType,
CSSProperties,
useEffect,
useRef,
useState,
} from "react";
import { attributes } from "../../utils/attributes";
import "./toast.css";
import { X } from "lucide-react";
import React from "react";
import { ButtonIcon } from "../ButtonIcon/ButtonIcon";
interface CustomStyleCSS extends CSSProperties {
"--codex-toast-background"?: string;
"--codex-toast-border-color"?: string;
"--codex-border-radius"?: string;
"--codex-toast-color"?: string;
}
type Props = {
message: string;
/**
* Time is the beginning time of the toast.
* The toast will be closed after time + duration.
* Every time a toast should be displayed a new time
* shoukd be set.
*/
time: number;
/**
* Toast duration in msec
* Default: 3000
*/
duration?: number;
/**
* Icon displayed on the left of the toast
*/
Icon: ComponentType;
className?: string;
/**
* Apply custom css variables.
* codex-toast-background
* codex-toast-border-color
* codex-border-radius
* codex-toast-color
*/
style?: CustomStyleCSS;
};
export function Toast({
message,
time,
Icon,
style,
className = "",
duration = 3000,
}: Props) {
const [msg, setMsg] = useState(message);
const timeout = useRef<number | null>(null);
useEffect(() => {
if (timeout.current) {
clearTimeout(timeout.current);
}
setMsg(message);
if (message) {
timeout.current = window.setTimeout(() => setMsg(""), duration);
}
}, [message, time]);
const onClose = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
setMsg("");
};
return (
<div
className={`toast ${className}`}
{...attributes({ "aria-hidden": time == 0 || msg === "" })}
style={style}
>
<Icon />
<span>
<b>Success ! </b>
<span>{msg}</span>
</span>
<ButtonIcon
onClick={onClose}
variant="small"
className="toast-close"
Icon={() => <X size="1.25rem" />}
></ButtonIcon>
</div>
);
}

View File

@ -0,0 +1,26 @@
.toast {
position: fixed;
bottom: 1rem;
right: 1rem;
background: var(--codex-toast-background, var(--codex-background-light));
min-width: 150px;
transform: translateX(0px);
transition: transform 0.35s;
display: flex;
align-items: center;
gap: 0.75rem;
border: 1px solid var(--codex-toast-border-color, var(--codex-border-color));
border-radius: var(--codex-border-radius);
padding: 0.75rem;
padding-left: 1.5rem;
color: var(--codex-toast-color, --codex-color);
word-break: break-word;
}
.toast-close {
margin-left: 0.75rem;
}
.toast[aria-hidden] {
transform: translateX(1000px);
}

View File

@ -0,0 +1,176 @@
import { FileStack, Upload as UploadIcon } from "lucide-react";
import { ChangeEvent, CSSProperties, DragEventHandler, useRef } from "react";
import { attributes } from "../../utils/attributes.ts";
import "./upload.css";
import { UploadFile } from "./UploadFile.tsx";
import { useUploadStategy } from "./useUploadStrategy.ts";
import { classnames } from "../../utils/classnames.ts";
import React from "react";
import { ButtonIcon } from "../ButtonIcon/ButtonIcon";
import { CodexData, UploadResponse } from "@codex/sdk-js";
import { SimpleText } from "../SimpleText/SimpleText.tsx";
interface CustomStyleCSS extends CSSProperties {
"--codex-border-color"?: string;
"--codex-border-radius"?: string;
"--codex-upload-background"?: string;
"--codex-color-primary"?: string;
"--codex-color"?: string;
"--codex-color-error"?: string;
"--codex-color-warning"?: string;
}
type Props = {
/**
* Allow multiple files.
* Default is true.
*/
multiple?: boolean;
/**
* Event triggered when a file is uploaded.
* The cid is the unique identifier of the file in Codex network.
*/
onSuccess?: (cid: string) => void;
/**
* Event triggered when a file is deleted.
* The id is generated after the file are selected by the user.
*/
onDeleteItem?: (id: string) => void;
/**
* Allow to override the previous file(s).
* If false, the user cannot upload a new file(s) until he deletes the previous file(s).
*/
editable?: boolean;
/**
* Codex provider to upload the data.
* If not provider is passed, the cid returned will be empty.
* Default value: provider returning random cid.
*/
provider?: () => Promise<CodexData["upload"]>;
/**
* If true, the upload will run in a separate web worker.
* Default is !!window.Worker.
*/
useWorker?: boolean;
/**
* Apply custom css variables.
* --codex-border-color
* --codex-border-radius
* --codex-upload-background
* --codex-color-primary
* --codex-color
* --codex-color-error
* --codex-color-warning
*/
style?: CustomStyleCSS;
};
const defaultProvider = () =>
Promise.resolve(
(_: File, onProgress: (loaded: number, total: number) => void) => {
onProgress(100, 100);
return Promise.resolve({
abort: () => {},
result: Promise.resolve({
error: false as false,
data: Date.now().toString(),
}),
} satisfies UploadResponse);
}
);
export function Upload({
multiple = true,
editable = true,
onDeleteItem,
onSuccess,
provider = defaultProvider,
useWorker = !!window.Worker,
}: Props) {
const { deleteFile, files, uploadFiles, warning } = useUploadStategy(
multiple ? "multiple" : "single",
editable
);
const input = useRef<HTMLInputElement>(null);
const onDragPrevents: DragEventHandler = (e) => {
e.stopPropagation();
e.preventDefault();
};
const onDrop: DragEventHandler = (e) => {
onDragPrevents(e);
uploadFiles(e.dataTransfer.files);
};
const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
uploadFiles(e.target.files);
}
if (input.current) {
input.current.value = "";
}
};
const onClose = (id: string) => {
deleteFile(id);
onDeleteItem?.(id);
};
const onClick = () => input.current?.click();
return (
<>
<div
className={classnames(["upload"], ["upload-warning", !!warning])}
tabIndex={1}
onClick={onClick}
onDragOver={onDragPrevents}
onDragEnter={onDragPrevents}
onDrop={onDrop}
>
<ButtonIcon Icon={multiple ? FileStack : UploadIcon}></ButtonIcon>
<div className="upload-text">
<div>
<b>
Drop your {multiple ? "file(s)" : "file"} here or{" "}
<span className="text--primary">browse</span>
</b>
</div>
<SimpleText size="small" variant="light" center>
{multiple ? "Up to 10 files" : "Choose one single file"}
</SimpleText>
</div>
<input
type="file"
hidden
ref={input}
onChange={onFileChange}
{...attributes({ multiple: multiple })}
/>
{warning && <SimpleText variant="warning">{warning}</SimpleText>}
</div>
{files.map(({ id, file }) => (
<UploadFile
file={file}
key={id}
onClose={() => onClose(id)}
id={id}
onSuccess={onSuccess}
provider={provider}
useWorker={useWorker}
/>
))}
</>
);
}

View File

@ -0,0 +1,398 @@
import { useRef, useState, useReducer, Reducer, useEffect } from "react";
import { attributes } from "../../utils/attributes";
import { PrettyBytes } from "../../utils/bytes";
import { Toast } from "../Toast/Toast";
import { UploadStatus } from "./types";
import {
CircleCheck,
TriangleAlert,
CircleX,
CircleStop,
Info,
} from "lucide-react";
import { Spinner } from "../Spinner/Spinner";
import React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CodexData } from "@codex/sdk-js";
import { WebFileIcon } from "../WebFileIcon/WebFileIcon";
import { ButtonIcon } from "../ButtonIcon/ButtonIcon";
import { SimpleText } from "../SimpleText/SimpleText";
type UploadFileProps = {
file: File;
onClose: (id: string) => void;
id: string;
onSuccess: ((cid: string) => void) | undefined;
provider: () => Promise<CodexData["upload"]>;
useWorker: boolean;
};
type State = {
progress: { loaded: number; total: number };
cid: string;
preview: string;
status: UploadStatus;
error: string;
};
type Action =
| {
type: "reset";
}
| {
type: "progress";
loaded: number;
total: number;
}
| {
type: "preview";
preview: string;
}
| {
type: "completed";
cid: string;
}
| {
type: "cancel";
}
| {
type: "error";
error: string;
};
function reducer(state: State, action: Action) {
switch (action.type) {
case "progress": {
const { loaded, total } = action;
return {
...state,
progress: { loaded, total },
status: loaded === total ? "done" : state.status,
};
}
case "preview": {
return {
...state,
preview: action.preview,
};
}
case "completed": {
return {
...state,
status: "done" as UploadStatus,
cid: action.cid,
};
}
case "cancel": {
if (state.status === "progress") {
return {
...state,
status: "error" as UploadStatus,
error: "The upload has been cancelled.",
};
}
return {
progress: { loaded: 0, total: 0 },
cid: "",
preview: "",
status: "progress" as UploadStatus,
error: "",
};
}
case "error": {
return { ...state, error: action.error, status: "error" as UploadStatus };
}
default: {
return state;
}
}
}
const isImage = (type: string) => type.startsWith("image");
export function UploadFile({
file,
onClose,
id,
onSuccess,
provider,
useWorker,
}: UploadFileProps) {
const abort = useRef<(() => void) | null>(null);
const queryClient = useQueryClient();
const worker = useRef<Worker | null>(null);
const [toast, setToast] = useState({ time: 0, message: "" });
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
progress: { loaded: 0, total: 0 },
cid: "",
preview: "",
status: "progress" as UploadStatus,
error: "",
});
const { mutateAsync } = useMutation({
mutationKey: ["upload"],
mutationFn: (file: File) => {
return provider()
.then((upload) => upload(file, onProgress))
.then((res) => {
console.info("abort", res.abort);
abort.current = res.abort;
return res.result;
})
.then((safe) =>
safe.error
? Promise.reject(safe.data.message)
: Promise.resolve(safe.data)
);
},
onError: (error) => {
worker.current?.terminate();
// TODO report to Sentry
dispatch({ type: "error", error: error.message });
},
onSuccess: (cid: string) => {
onInternalSuccess(cid);
},
});
const init = useRef(false);
const onInternalSuccess = (cid: string) => {
worker.current?.terminate();
queryClient.invalidateQueries({
queryKey: ["cids"],
});
if (onSuccess) {
onSuccess(cid);
dispatch({ type: "reset" });
} else {
dispatch({ type: "completed", cid });
}
};
const onProgress = (loaded: number, total: number) => {
dispatch({
type: "progress",
loaded,
total,
});
};
useEffect(() => {
if (init.current) {
return;
}
init.current = true;
if (isImage(file.type)) {
const reader = new FileReader();
reader.onload = () => {
const preview = reader.result?.toString();
if (preview) {
dispatch({ type: "preview", preview });
}
};
reader.readAsDataURL(file);
}
if (useWorker) {
worker.current = new Worker(new URL("./worker", import.meta.url), {
type: "module",
});
provider().then((upload) => {
worker.current?.postMessage({ type: "init", upload: "" });
});
worker.current.onmessage = function (e) {
const data = e.data;
if (e.data.type === "progress") {
onProgress(data.loaded, data.total);
} else if (e.data.type === "completed") {
onInternalSuccess(e.data.value.data);
} else if (e.data.error) {
// TODO report with sentry
dispatch({ type: "error", error: e.data.error });
}
};
worker.current.onerror = function (e) {
// TODO report to sentry
console.error("Error in worker:", e);
dispatch({ type: "error", error: e.message });
worker.current?.terminate();
};
worker.current.postMessage({ type: "file", file });
} else {
console.info("running file !!");
mutateAsync(file);
}
}, []);
const onCancel = () => {
if (worker.current) {
worker.current.postMessage({ type: "abort" });
} else {
abort.current?.();
}
dispatch({ type: "cancel" });
};
const onInternalClose = () => {
if (worker.current) {
worker.current.postMessage({ type: "abort" });
} else {
abort.current?.();
}
onClose(id);
};
const onCopy = () => {
if (cid) {
navigator.clipboard.writeText(cid);
setToast({
time: Date.now(),
message: "The CID has been copied to your clipboard.",
});
}
};
const parts = file.name.split(".");
const extension = parts.pop();
const filename = parts.join(".");
const { cid, error, preview, progress, status } = state;
const onAction = state.status === "progress" ? onCancel : onInternalClose;
const percent =
progress.total > 0 ? (progress.loaded / progress.total) * 100 : 0;
const ActionIcon = () => <UploadActionIcon status={status} />;
return (
<div className={"uploadFile"}>
<div className="uploadFile-info">
<div className="uploadFile-infoLeft">
{preview ? (
<img
src={preview}
width="24"
alt="Preview"
className="uploadFile-preview"
/>
) : (
<WebFileIcon type={file.type} />
)}
<div className="uploadFile-infoText">
<b
className="uploadFile-name"
{...attributes({
"aria-invalid": status === "error",
"data-done": status === "done",
})}
>
<span className="uploadFile-filename">{filename}</span>
<span>.{extension}</span>
</b>
<div>
<small>{PrettyBytes(file.size)}</small>
</div>
</div>
</div>
<div className="uploadFile-infoRight">
<UploadStatusIcon status={status} />
<ButtonIcon
variant="small"
onClick={onAction}
Icon={ActionIcon}
></ButtonIcon>
</div>
</div>
<div className="uploadFile-progress">
<progress
className="uploadFile-progressBar"
{...attributes({
max: file ? progress.total.toString() : false,
value: file ? progress.loaded.toString() : false,
"aria-invalid": status === "error",
})}
/>
<span className="uploadFile-progressBarPercent">
{percent.toFixed(2)} %
</span>
</div>
{!!cid && (
<>
<div className="text--primary">
<span>Success !</span> Click on the CID to copy it to your
clipboard.
</div>
<a>
<small className="uploadFile-cid" onClick={onCopy}>
{cid}
</small>
</a>
</>
)}
{error && <SimpleText variant="error">{error}</SimpleText>}
<Toast message={toast.message} time={toast.time} Icon={Info} />
</div>
);
}
type UploadStatusIconProps = {
status: UploadStatus;
};
export function UploadStatusIcon({ status }: UploadStatusIconProps) {
switch (status) {
case "done":
return (
<CircleCheck
size={"1.25rem"}
fill="currentColor"
className="upload-progress-check"
stroke="var(--codex-background)"
></CircleCheck>
);
case "error":
return (
<TriangleAlert
size={"1.25rem"}
fill="currentColor"
className="upload-progress-cancelled"
stroke="var(--codex-background)"
></TriangleAlert>
);
case "progress":
return <Spinner width={"1.25rem"} className="upload-progress-check" />;
}
}
function UploadActionIcon({ status }: UploadStatusIconProps) {
switch (status) {
case "error":
case "done":
return <CircleX size={"1.25rem"} />;
case "progress":
return <CircleStop size={"1.25rem"} />;
}
}

View File

@ -0,0 +1 @@
export type UploadStatus = "progress" | "error" | "done";

View File

@ -0,0 +1,181 @@
.upload {
border: 1px dashed var(--codex-border-color);
background-color: var(
--codex-upload-background,
var(--codex-background-secondary)
);
min-height: 150px;
border-radius: var(--codex-border-radius);
justify-content: center;
flex-direction: column;
gap: 0.5rem;
cursor: pointer;
padding: 0.5rem 2rem;
}
.upload-selected {
border-color: var(--codex-color-primary);
}
.uploadFile {
background-color: var(
--codex-upload-background,
var(--codex-background-secondary)
);
border-radius: var(--codex-border-radius);
border: 1px solid var(--codex-border-color);
padding: 0.5rem 2rem;
margin-top: 0.5rem;
}
.uploadFile-info {
flex-grow: 1;
gap: 0.5rem;
}
.upload-actions {
gap: 0.5rem;
}
.upload-action-close {
color: var(--codex-color);
border: 1px solid var(--codex-color);
}
.upload-action-confirm {
color: var(--codex-color-primary);
border: 1px solid var(--codex-color-primary);
}
.uploadFile-progressBar {
flex-grow: 1;
background-color: var(
--codex-upload-background,
var(--codex-background-secondary)
);
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
border: none;
border-radius: var(--codex-border-radius);
}
.uploadFile-progressBar[value] {
height: 0.5rem;
width: 100%;
border: none;
}
.uploadFile-progressBar[value]::-webkit-progress-bar {
border-radius: var(--codex-border-radius);
background-color: var(
--codex-upload-background,
var(--codex-background-secondary)
);
}
.uploadFile-progressBar[value]::-webkit-progress-value {
border-radius: var(--codex-border-radius);
background-color: var(--codex-color-primary);
}
.uploadFile-progressBar[aria-invalid]::-webkit-progress-value {
background-color: var(--codex-color-error);
}
.uploadFile-progressBar[aria-invalid]::-moz-progress-bar {
border-radius: var(--codex-border-radius);
background-color: var(--codex-color-primary);
}
.uploadFile-progressBar[value]::-moz-progress-bar {
background-color: var(--codex-color-error);
}
.uploadFile-filename {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
display: inline-block;
}
.uploadFile-progressBarPercent {
white-space: nowrap;
display: inline-block;
text-align: right;
}
.upload-progress-check {
color: var(--codex-color-primary);
}
.upload-progress-cancelled {
color: var(--codex-color-error);
}
.uploadFile-preview {
border-radius: var(--codex-border-radius);
}
.uploadFile-infoLeft {
gap: 0.5rem;
flex-grow: 1;
}
.uploadFile-progress {
gap: 0.5rem;
}
.uploadFile-progress {
margin: 0.25 0;
}
.uploadFile-infoRight,
.uploadFile-progress,
.upload,
.uploadFile-info,
.uploadFile-infoLeft,
.uploadFile-name {
display: flex;
align-items: center;
}
.uploadFile-name[aria-invalid] {
color: var(--codex-color-error);
}
.uploadFile-name[data-done] {
color: var(--codex-color-primary);
}
.uploadFile-infoText {
flex-grow: 1;
}
.uploadFile-infoRight {
justify-content: space-around;
gap: 0.25rem;
}
.uploadFile-progressBarPercent,
.uploadFile-right {
width: 5rem;
}
.uploadFile-cid {
transition: color 0.35s;
cursor: pointer;
}
.uploadFile-cid:hover {
color: var(--codex-color-primary);
}
.upload-warning {
border-color: var(--codex-color-warning);
}
.upload-text {
text-align: center;
}

View File

@ -0,0 +1,55 @@
import { useState } from "react";
const MAX_FILES_ALLOWED = 10;
type CodexFile = { file: File; id: string };
export function useUploadStategy(
stategy: "multiple" | "single",
editable = true
) {
const [files, setFiles] = useState<CodexFile[]>([]);
const [warning, setWarning] = useState("");
const uploadFiles = (incoming: File[] | FileList) => {
setWarning("");
if (incoming.length === 0) {
return;
}
if (!editable && files.length) {
setWarning(
"You already uploaded a file, please delete the current file before uploading a new one."
);
return;
}
if (stategy === "single" && incoming.length > 1) {
setWarning(
"You tried to upload multiple files but this upload accept only one file, the first file will be used and the rest will be ignored."
);
}
const ingest = stategy === "multiple" ? incoming : [incoming[0]];
const f: CodexFile[] = [];
const length = Math.min(ingest.length, MAX_FILES_ALLOWED);
for (let i = 0; i < length; i++) {
const id = Date.now() + "-" + i;
f.push({ file: ingest[i], id });
}
setFiles((files) => [...f, ...files]);
return { error: false, data: null };
};
const deleteFile = (id: string) => {
setWarning("");
setFiles((files) => files.filter((f) => f.id !== id));
};
return { uploadFiles, deleteFile, files, warning };
}

View File

@ -0,0 +1,39 @@
import { Codex } from "@codex/sdk-js";
const codex = new Codex(import.meta.env.VITE_CODEX_API_URL);
let abort: () => void;
self.addEventListener("message", function (e) {
const { type, ...rest } = e.data;
if (type === "abort") {
console.debug("Aborting request");
abort?.();
return;
}
const onProgress = (loaded: number, total: number) => {
self.postMessage({
type: "progress",
loaded,
total,
});
};
return codex
.data()
.then((data) => data.upload(rest.file, onProgress))
.then((result) => {
abort = result.abort;
return result.result;
})
.then((value) => {
self.postMessage({
type: "completed",
value,
});
});
});

View File

@ -0,0 +1,22 @@
import React from "react";
type Props = {
size?: number;
};
export const AnyFileIcon = ({ size }: Props) => (
<svg width={size} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="#E5E5E5"
d="M137.902 0a48.573 48.573 0 0 0-35.589 15.294 53.965 53.965 0 0 0-15.273 35.64v917.412a48.707 48.707 0 0 0 15.273 35.64 49.971 49.971 0 0 0 35.59 15.293h746.336a48.64 48.64 0 0 0 35.59-15.293 50.37 50.37 0 0 0 15.273-35.64V288.717L646.728 0H137.902z"
/>
<path
fill="#CCC"
d="M935.102 288.717H697.656c-27.822-.666-50.227-23.076-50.928-50.934V0l288.374 288.717z"
/>
<path
fill="#FFF"
d="M248.126 365.184h220.518a25.518 25.518 0 0 0 24.192-25.497 25.518 25.518 0 0 0-24.197-25.503H248.126a25.518 25.518 0 0 0-24.197 25.498 25.518 25.518 0 0 0 24.197 25.497zm0 169.825H773.95a25.446 25.446 0 0 0 25.43-25.466 25.446 25.446 0 0 0-25.43-25.467H248.126a25.446 25.446 0 0 0-25.431 25.467 25.446 25.446 0 0 0 25.43 25.466zM773.95 653.896H248.126a25.518 25.518 0 0 0-24.197 25.497 25.518 25.518 0 0 0 24.197 25.503H773.95a25.518 25.518 0 0 0 24.197-25.497A25.518 25.518 0 0 0 773.95 653.9z"
/>
</svg>
);

View File

@ -0,0 +1,23 @@
import React from "react";
type Props = {
size?: number;
};
export const AudioIcon = ({ size = 24 }: Props) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} viewBox="0 0 1024 1024">
<path
fill="#FFC547"
d="m594.944 0 335.124 341.32v563.2c0 65.996-52.5 119.48-117.294 119.48H209.546c-64.793 0-117.299-53.53-117.299-119.48V119.48C92.252 53.484 144.757 0 209.551 0h385.393z"
/>
<path
fill="#FFF"
fillOpacity={0.4}
d="M930.068 341.32H718.152c-64.748 0-123.208-59.49-123.208-125.492V0l335.124 341.32z"
/>
<path
fill="#FFF"
d="M568.78 446.464V675.84a27.238 27.238 0 0 1-6.793 18.248 45.66 45.66 0 0 1-17.316 12.38 112.64 112.64 0 0 1-20.803 6.518c-6.937 1.438-13.404 2.186-19.41 2.186-6.052 0-12.523-.748-19.41-2.14a112.456 112.456 0 0 1-20.807-6.564 45.706 45.706 0 0 1-17.316-12.38 27.228 27.228 0 0 1-6.84-18.248c0-6.84 2.33-12.892 6.84-18.248a45.706 45.706 0 0 1 17.316-12.38c6.983-2.934 13.916-5.12 20.807-6.518 6.887-1.484 13.358-2.186 19.405-2.186 14.06 0 26.952 2.652 38.636 8.008V534.528l-154.481 48.548v145.218a27.228 27.228 0 0 1-6.8 18.202 45.66 45.66 0 0 1-17.315 12.38 112.64 112.64 0 0 1-20.803 6.564c-6.937 1.398-13.404 2.14-19.41 2.14-6.052 0-12.523-.696-19.41-2.14a112.502 112.502 0 0 1-20.807-6.518 45.706 45.706 0 0 1-17.311-12.426 27.233 27.233 0 0 1-6.845-18.248c0-6.794 2.33-12.892 6.845-18.196a45.706 45.706 0 0 1 17.31-12.38c6.984-2.934 13.922-5.12 20.808-6.564a94.802 94.802 0 0 1 19.405-2.14c14.06 0 26.957 2.652 38.636 7.956V498.872a19.876 19.876 0 0 1 13.685-18.846l167.286-52.414a17.63 17.63 0 0 1 5.581-.84c5.402 0 9.964 1.91 13.686 5.73 3.773 3.86 5.632 8.468 5.632 13.962z"
/>
</svg>
);

View File

@ -0,0 +1,23 @@
import React from "react";
type Props = {
size?: number;
};
export const DocIcon = ({ size }: Props) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} viewBox="0 0 1024 1024">
<path
fill="#5895FF"
d="m594.944 0 335.124 341.32v563.2c0 65.996-52.5 119.48-117.294 119.48H209.546c-64.793 0-117.299-53.53-117.299-119.48V119.48C92.252 53.484 144.757 0 209.551 0h385.393z"
/>
<path
fill="#FFF"
fillOpacity={0.4}
d="M930.068 341.32H718.152c-64.748 0-123.208-59.49-123.208-125.492V0l335.124 341.32z"
/>
<path
fill="#FFF"
d="M427.377 725.32V768H259.814v-42.68h167.563zM594.944 640v42.68h-335.13V640h335.13zm0-85.32v42.64h-335.13v-42.64h335.13zm0-85.36V512h-335.13v-42.68h335.13z"
/>
</svg>
);

View File

@ -0,0 +1,23 @@
import React from "react";
type Props = {
size?: number;
};
export const ExcelIcon = ({ size }: Props) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} viewBox="0 0 1024 1024">
<path
fill="#1ABF74"
d="m594.944 0 335.124 341.32v563.2c0 65.996-52.5 119.48-117.294 119.48H209.546c-64.793 0-117.299-53.53-117.299-119.48V119.48C92.252 53.484 144.757 0 209.551 0h385.393z"
/>
<path
fill="#FFF"
fillOpacity={0.4}
d="M930.068 341.32H718.152c-64.748 0-123.208-59.49-123.208-125.492V0l335.124 341.32z"
/>
<path
fill="#FFF"
d="M594.616 426.824v.23h.092V768h-27.873v-.051h-83.968.046-83.143.05-139.919v-28.39l.052-.047v-85.826h-.052V625.29h.052v-85.826h-.098v-28.396l.093-.046v-55.854h-.093v-28.39h334.71l.046.046zM371.988 653.686h-84.106v85.826h84.106v-85.826zm83.083 0h-55.245v85.826h55.199v-85.826h.05zm111.76 0h-83.923v85.826h83.876v-85.826h.046zM371.987 539.459h-84.106v85.831h84.106v-85.826zm83.083 0h-55.245v85.831h55.199v-85.826h.05zm111.76 0h-83.923v85.831h83.876v-85.826h.046zm0-84.245H287.881v55.808h278.902v-55.854l.046.051z"
/>
</svg>
);

View File

@ -0,0 +1,23 @@
import React from "react";
type Props = {
size?: number;
};
export const ImageIcon = ({ size = 24 }: Props) => (
<svg width={size} viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
d="M594.944 0l335.12448 341.31968v563.2c0 65.9968-52.50048 119.48032-117.29408 119.48032H209.54624c-64.7936 0-117.2992-53.5296-117.2992-119.48032V119.48032C92.25216 53.48352 144.75776 0 209.55136 0H594.944z"
fill="#36D2AD"
/>
<path
d="M930.06848 341.31968h-211.9168c-64.74752 0-123.20768-59.48928-123.20768-125.4912V0l335.12448 341.31968z"
fill="#FFFFFF"
fillOpacity={0.4}
/>
<path
d="M613.56032 426.68032H278.4256c-10.24 0-18.61632 8.4736-18.61632 18.944V749.056c0 10.4704 8.37632 18.944 18.61632 18.944h335.1296c10.28608 0 18.61632-8.4736 18.61632-18.944V445.62432a18.80576 18.80576 0 0 0-18.61632-18.944z m-37.23776 284.39552H315.66848v-94.7712l55.8592-56.87296 93.08672 94.81216 55.8592-56.92416 55.84896 56.92416v56.87808-0.0512z m-37.23776-151.64416a37.56032 37.56032 0 0 1-37.23264-37.9392c0-20.9408 16.66048-37.93408 37.23264-37.93408 20.57728 0 37.23776 16.99328 37.23776 37.9392 0 20.9408-16.66048 37.93408-37.23776 37.93408z"
fill="#FFFFFF"
/>
</svg>
);

View File

@ -0,0 +1,30 @@
import React from "react";
type Props = {
size?: number;
};
export function PdfIcon({ size = 24 }: Props) {
return (
<svg
width={size}
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M594.944 0l335.12448 341.31968v563.2c0 65.9968-52.50048 119.48032-117.29408 119.48032H209.54624c-64.7936 0-117.2992-53.5296-117.2992-119.48032V119.48032C92.25216 53.48352 144.75776 0 209.55136 0H594.944z"
fill="#E94848"
/>
<path
d="M482.95424 375.48032a40.58624 40.58624 0 0 0-40.02816 40.77568c0 27.78624 15.17056 62.32576 31.1808 94.7712-12.56448 39.8336-26.71616 82.47296-44.82048 118.5024-37.0944 14.7968-70.1952 25.8304-90.0608 42.16832a41.70752 41.70752 0 0 0-12.3904 29.9264c0 22.34368 18.06336 40.77568 40.0384 40.77568a39.33184 39.33184 0 0 0 29.27104-12.47232c14.6176-17.82784 31.88736-50.12992 47.29344-79.6416 35.42016-14.19776 72.60672-28.672 108.44672-37.3248 26.1632 21.4528 64.0512 35.65056 95.18592 35.65056 21.96992 0 40.03328-18.38592 40.03328-40.77568a40.58624 40.58624 0 0 0-40.03328-40.72448c-24.99072 0-61.29664 9.07264-89.0368 18.61632a301.3376 301.3376 0 0 1-58.09152-76.98432c10.65984-33.3312 23.04-66.65728 23.04-92.48768a40.58624 40.58624 0 0 0-40.02816-40.77568z m0 24.43776c8.98048 0 16.01024 7.168 16.01024 16.29184 0 12.2368-6.42048 34.816-13.87008 59.01824C475.136 451.67616 466.944 429.056 466.944 416.256c0-9.1648 7.02464-16.29184 16.01024-16.29184v-0.04608z m6.8864 139.5456a323.57376 323.57376 0 0 0 41.5232 53.76c-23.74144 6.6048-46.91968 15.0784-69.82144 23.92064 11.07968-25.36448 19.9168-51.75808 28.29824-77.72672v0.04608z m157.2352 52.12672c8.98048 0 16.01024 7.12192 16.01024 16.29184 0 9.12384-7.02976 16.29184-16.01536 16.29184-18.05824 0-43.65824-8.28416-64.18432-19.87584 23.552-6.79424 49.2032-12.7488 64.18432-12.7488v0.04096zM408.15104 664.576c-11.264 20.48-22.43584 39.56224-30.2592 49.152a15.0784 15.0784 0 0 1-11.02848 4.18816 15.96416 15.96416 0 0 1-16.01024-16.29184c0.03072-4.16256 1.53088-8.18688 4.23424-11.35616 9.40032-7.3984 29.83424-16.29184 53.06368-25.69216z"
fill="#FFFFFF"
/>
<path
d="M930.06848 341.31968h-211.9168c-64.74752 0-123.20768-59.48928-123.20768-125.4912V0l335.12448 341.31968z"
fill="#FFFFFF"
fillOpacity=".4"
/>
</svg>
);
}

View File

@ -0,0 +1,23 @@
import React from "react";
type Props = {
size?: number;
};
export const VideoIcon = ({ size = 24 }: Props) => (
<svg width={size} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
fill="#8095FF"
d="M137.902 0a48.573 48.573 0 0 0-35.589 15.294 53.965 53.965 0 0 0-15.273 35.64v917.412a48.707 48.707 0 0 0 15.273 35.64 49.971 49.971 0 0 0 35.59 15.293h746.336a48.64 48.64 0 0 0 35.59-15.293 50.37 50.37 0 0 0 15.273-35.64V288.717L646.728 0H137.902z"
/>
<path
fill="#FFF"
d="M138.24 83.451a42.49 42.49 0 1 0 84.977 0 42.49 42.49 0 0 0-84.977 0zm0 169.825a42.49 42.49 0 1 0 84.977 0 42.49 42.49 0 0 0-84.977 0zm0 169.82a42.49 42.49 0 1 0 84.977.134 42.49 42.49 0 0 0-84.977-.133zm0 169.892a42.49 42.49 0 1 0 42.491-42.49 42.424 42.424 0 0 0-42.49 42.495zm0 169.826a42.49 42.49 0 1 0 84.977.133 42.49 42.49 0 0 0-84.977-.133zm0 169.891a42.49 42.49 0 1 0 84.977 0 42.49 42.49 0 0 0-84.977 0zm658.688-509.608a42.49 42.49 0 1 0 84.977.133 42.49 42.49 0 0 0-84.982-.133zm0 169.891a42.49 42.49 0 1 0 42.486-42.49 42.424 42.424 0 0 0-42.491 42.495zm0 169.826a42.49 42.49 0 1 0 84.977.133 42.49 42.49 0 0 0-84.982-.133zm0 169.891a42.49 42.49 0 1 0 84.977 0 42.49 42.49 0 0 0-84.982 0zM631.091 505.754l-225.08-154.599a16.691 16.691 0 0 0-17.817-.798 17.818 17.818 0 0 0-9.314 15.227v308.331a18.022 18.022 0 0 0 9.308 15.293 16.824 16.824 0 0 0 17.818-.865l225.085-155.392a15.36 15.36 0 0 0 7.645-13.634 18.688 18.688 0 0 0-7.645-13.297v-.266z"
/>
<path
fill="#FFF"
d="M935.102 288.717H697.656c-27.822-.666-50.227-23.076-50.928-50.934V0l288.374 288.717z"
opacity={0.4}
/>
</svg>
);

View File

@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react";
import { WebFileIcon } from "./WebFileIcon";
const meta = {
title: "Example/WebFileIcon",
component: WebFileIcon,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof WebFileIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Audio: Story = {
args: {
type: "audio/mp3",
},
};
export const Image: Story = {
args: {
type: "image/jpeg",
},
};
export const Video: Story = {
args: {
type: "video/mp4",
},
};
export const Pdf: Story = {
args: {
type: "application/pdf",
},
};
export const Excel: Story = {
args: {
type: "application/vnd.ms-excel",
},
};
export const Doc: Story = {
args: {
type: "application/msdoc",
},
};
export const Other: Story = {
args: {
type: "text/plain",
},
};
export const CustomSize: Story = {
args: {
type: "image/jpeg",
size: 64,
},
};

View File

@ -0,0 +1,46 @@
import { AudioIcon } from "./AudioIcon";
import { ImageIcon } from "./ImageIcon";
import { VideoIcon } from "./VideoIcon";
import { PdfIcon } from "./PdfIcon";
import { ExcelIcon } from "./ExcelIcon";
import { DocIcon } from "./DocIcon";
import { AnyFileIcon } from "./AnyFileIcon";
import React from "react";
type Props = {
type: string;
size?: number;
};
export function WebFileIcon({ type, size = 24 }: Props) {
if (type.startsWith("audio")) {
return <AudioIcon size={size} />;
}
if (type.startsWith("image")) {
return <ImageIcon size={size} />;
}
if (type.startsWith("video")) {
return <VideoIcon size={size} />;
}
switch (type) {
case "application/pdf": {
return <PdfIcon size={size} />;
}
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
case "application/vnd.ms-excel": {
return <ExcelIcon size={size} />;
}
case "application/msdoc": {
return <DocIcon size={size} />;
}
default: {
return <AnyFileIcon size={size} />;
}
}
}

View File

@ -0,0 +1,38 @@
import * as React from "react";
export const ZipIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
className="svg-icon"
style={{
width: "1em",
height: "1em",
verticalAlign: "middle",
fill: "currentColor",
overflow: "hidden",
}}
viewBox="0 0 1024 1024"
>
<path
fill="#D0D8E1"
d="m594.944 0 335.124 341.32v563.2c0 65.996-52.5 119.48-117.294 119.48H209.546c-64.793 0-117.299-53.53-117.299-119.48V119.48C92.252 53.484 144.757 0 209.551 0h385.393z"
/>
<path
fill="#FFF"
fillOpacity={0.4}
d="M930.068 341.32H718.152c-64.748 0-123.208-59.49-123.208-125.492V0l335.124 341.32z"
/>
<path
fill="#FFF"
d="M516.838 438.042h-31.283c-2.227 0-3.348 1.162-4.464 2.278-1.117 1.116-1.117 3.4-1.117 5.683 2.233 4.557 3.354 10.24 3.354 14.797v147.917l3.348 22.763c0 2.284 2.233 3.4 3.354 4.516 1.116 0 2.232 1.162 2.232 1.162l5.581 3.4a45.148 45.148 0 0 1 29.051 42.122v51.2c0 2.284 1.116 4.516 3.349 4.516 1.116 0 1.116 1.162 2.237 1.162 1.116 0 2.232 0 3.348-1.162a32.63 32.63 0 0 0 13.405-27.274V472.156a31.652 31.652 0 0 0-32.395-34.114zM376.09 460.8 361.564 512a12.288 12.288 0 0 0 2.232 13.64c4.47 4.556 21.228 4.556 25.693 0a15.2 15.2 0 0 0 3.353-13.64l-14.525-51.2h-2.233z"
/>
<path
fill="#FFF"
d="M366.029 453.96c1.121-3.4 3.353-4.516 5.586-4.516h11.172c2.237 0 4.47 1.116 5.585 4.516l6.708 26.204 8.929-19.364-13.41-31.836v-2.33h-30.151v2.33L349.276 460.8l8.935 21.596 7.818-28.39v-.051z"
/>
<path
fill="#FFF"
d="m354.396 426.68-11.172 31.842v4.556l11.172 28.442a5.975 5.975 0 0 0 3.395 3.02l2.191.38-4.47 13.68a23.644 23.644 0 0 0 5.581 24.996c4.475 4.562 12.288 6.846 21.228 6.846s16.757-2.284 21.227-6.846a24.294 24.294 0 0 0 6.656-21.453l-5.586-18.345c.789 0 1.536-.512 1.956-1.162l.323-1.116 14.525-28.442a3.492 3.492 0 0 0 .742-3.118l-.742-1.392-14.525-31.888h35.747a33.05 33.05 0 0 1 33.234 29.374l.282 4.746v164.956c0 8.796 4.93 15.923 11.96 19.922l11.495 5.12a32.86 32.86 0 0 1 22.016 26.998l.327 4.884v51.2c0 17.69-12.195 31.652-28.856 33.844l-4.66.276H298.548a33.05 33.05 0 0 1-33.234-29.414l-.281-4.706V460.8c0-17.69 12.196-31.652 28.861-33.843l4.654-.277h55.854zm16.753 284.396h-11.162c-3.353 0-5.586 2.324-5.586 5.724 0 2.744 1.393 4.746 3.723 5.448l1.863.23h11.167c3.354 0 5.586-2.278 5.586-5.678 0-2.744-1.393-4.746-3.722-5.448l-1.864-.23v-.051zm33.515-11.356h-11.172c-3.348 0-5.58 2.324-5.58 5.724 0 2.698 1.392 4.7 3.717 5.396l1.863.282h11.172c3.349 0 5.581-2.33 5.581-5.683 0-2.79-1.393-4.741-3.717-5.443l-1.864-.276zm-33.515-11.362h-11.162c-3.353 0-5.586 2.284-5.586 5.684 0 2.744 1.393 4.746 3.723 5.442l1.863.236h11.167c3.354 0 5.586-2.284 5.586-5.684 0-2.744-1.393-4.746-3.722-5.396l-1.864-.282zm33.515-11.402h-11.172c-3.348 0-5.58 2.33-5.58 5.724 0 2.704 1.392 4.706 3.717 5.402l1.863.276h11.172c3.349 0 5.581-2.278 5.581-5.678 0-2.744-1.393-4.746-3.717-5.442l-1.864-.282zM371.15 665.6h-11.162c-3.353 0-5.586 2.278-5.586 5.683 0 2.74 1.393 4.741 3.723 5.443l1.863.23h11.167c3.354 0 5.586-2.232 5.586-5.678 0-2.698-1.393-4.7-3.722-5.396l-1.87-.282zm33.515-11.356h-11.172c-3.348 0-5.58 2.232-5.58 5.678 0 2.698 1.392 4.7 3.717 5.396l1.863.282h11.172c3.349 0 5.581-2.278 5.581-5.683 0-2.74-1.393-4.741-3.717-5.443l-1.864-.23zm-33.515-11.402h-11.162c-3.353 0-5.586 2.278-5.586 5.678 0 2.744 1.393 4.746 3.723 5.442l1.863.282h11.167c3.354 0 5.586-2.33 5.586-5.724 0-2.704-1.393-4.706-3.722-5.402l-1.864-.276zm33.515-11.362h-11.172c-3.348 0-5.58 2.284-5.58 5.684 0 2.744 1.392 4.746 3.717 5.396l1.863.282h11.172c3.349 0 5.581-2.284 5.581-5.684 0-2.744-1.393-4.746-3.717-5.442l-1.864-.236zm-33.515-11.402h-11.162c-3.353 0-5.586 2.33-5.586 5.683 0 2.79 1.393 4.741 3.723 5.443l1.863.276h11.167c3.354 0 5.586-2.324 5.586-5.724 0-2.698-1.393-4.7-3.722-5.396l-1.864-.282zm33.515-11.356h-11.172c-3.348 0-5.58 2.278-5.58 5.678 0 2.744 1.392 4.746 3.717 5.448l1.863.23h11.172c3.349 0 5.581-2.278 5.581-5.678 0-2.744-1.393-4.746-3.717-5.448l-1.864-.23zM371.15 597.32h-11.162c-3.353 0-5.586 2.324-5.586 5.724 0 2.698 1.393 4.7 3.723 5.396l1.863.282h11.167c3.354 0 5.586-2.33 5.586-5.683 0-2.79-1.393-4.741-3.722-5.443l-1.864-.276zm33.515-11.362h-11.172c-3.348 0-5.58 2.284-5.58 5.684 0 2.744 1.392 4.746 3.717 5.442l1.863.236h11.172c3.349 0 5.581-2.284 5.581-5.684 0-2.744-1.393-4.746-3.717-5.396l-1.864-.282zm-33.515-11.402h-11.162c-3.353 0-5.586 2.33-5.586 5.724 0 2.704 1.393 4.706 3.723 5.402l1.863.276h11.167c3.354 0 5.586-2.278 5.586-5.678 0-2.744-1.393-4.746-3.722-5.442l-1.864-.282zm33.515-11.356h-11.172c-3.348 0-5.58 2.278-5.58 5.683 0 2.74 1.392 4.741 3.717 5.443l1.863.23h11.172c3.349 0 5.581-2.232 5.581-5.678 0-2.698-1.393-4.7-3.717-5.396l-1.864-.282zm-33.515-11.356h-11.162c-3.353 0-5.586 2.232-5.586 5.678 0 2.698 1.393 4.7 3.723 5.396l1.863.282h11.167c3.354 0 5.586-2.278 5.586-5.683 0-2.74-1.393-4.741-3.722-5.443l-1.864-.23z"
/>
</svg>
);

8862
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
package.json Normal file
View File

@ -0,0 +1,55 @@
{
"name": "@codex/marketplace-ui-components",
"description": "Marketplace UI components for Codex decentralized storage network.",
"repository": {
"type": "git",
"url": "https://github.com/codex-storage/codex-marketplace-storybook"
},
"version": "0.0.0",
"type": "module",
"scripts": {
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"format": "prettier --write ./src",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
"keywords": [
"Codex",
"Javascript",
"Storybook",
"Components",
"UI",
"React"
],
"dependencies": {
"@tanstack/react-query": "^5.51.24",
"lucide-react": "^0.428.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^1.6.1",
"@storybook/addon-essentials": "^8.2.9",
"@storybook/addon-interactions": "^8.2.9",
"@storybook/addon-links": "^8.2.9",
"@storybook/addon-onboarding": "^8.2.9",
"@storybook/blocks": "^8.2.9",
"@storybook/react": "^8.2.9",
"@storybook/react-vite": "^8.2.9",
"@storybook/test": "^8.2.9",
"prettier": "^3.3.3",
"storybook": "^8.2.9",
"storybook-dark-mode": "^4.0.2",
"typescript": "^5.2.2"
},
"peerDependencies": {
"@codex/sdk-js": "@codex/marketplace-ui#master"
},
"engines": {
"node": ">=18"
},
"author": "Codex team",
"readme": "README.md",
"license": "MIT"
}

41
stories/Alert.stories.ts Normal file
View File

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Alert } from "../components/Alert/Alert";
const meta = {
title: "Overlays/Alert",
component: Alert,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
variant: {
control: { type: "select" },
},
},
} satisfies Meta<typeof Alert>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Success: Story = {
args: {
message: "This is a success message.",
variant: "success",
},
};
export const Warning: Story = {
args: {
message: "This is a warning message.",
variant: "warning",
},
};
export const CustomStyle: Story = {
args: {
message: "This is a custom style message.",
variant: "warning",
style: { "--codex-color-warning": "red" },
},
};

View File

@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Backdrop } from "../components/Backdrop/Backdrop";
import { useState } from "react";
import React from "react";
const meta = {
title: "Overlays/Backdrop",
component: Backdrop,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof Backdrop>;
export default meta;
type Story = StoryObj<typeof meta>;
const Template = () => {
const [open, setOpen] = useState(false);
const onClick = () => setOpen(true);
return (
<div style={{ padding: "2rem" }}>
<button onClick={onClick}>Show backdrop</button>
<Backdrop onClose={() => setOpen(false)} open={open} />
</div>
);
};
export const Default: Story = Template.bind({});

89
stories/Button.stories.ts Normal file
View File

@ -0,0 +1,89 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { Button } from "../components/Button/Button";
import { Plus } from "lucide-react";
// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export
const meta = {
title: "Components/Button",
component: Button,
parameters: {
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
layout: "centered",
},
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
tags: ["autodocs"],
// More on argTypes: https://storybook.js.org/docs/api/argtypes
argTypes: {
variant: {
control: { type: "select" },
},
},
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
args: { onClick: fn() },
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
export const Primary: Story = {
args: {
label: "Button",
},
};
export const Outline: Story = {
args: {
label: "Button",
variant: "outline",
},
};
export const Icon: Story = {
args: {
label: "Button",
Icon: Plus,
},
};
export const PrimaryFetching: Story = {
args: {
label: "Button",
fetching: true,
},
};
export const OutlineFetching: Story = {
args: {
label: "Button",
variant: "outline",
fetching: true,
},
};
export const PrimaryDisabled: Story = {
args: {
label: "Button",
disabled: true,
},
};
export const OutlineDisabled: Story = {
args: {
label: "Button",
variant: "outline",
disabled: true,
},
};
export const CustomStyle: Story = {
args: {
label: "Button",
variant: "primary",
style: {
"--codex-color-primary": "red",
"--codex-button-color-box-shadow": "#f85723",
},
},
};

View File

@ -0,0 +1,53 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { Plus } from "lucide-react";
import { ButtonIcon } from "../components/ButtonIcon/ButtonIcon";
const meta = {
title: "Components/ButtonIcon",
component: ButtonIcon,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
variant: {
control: { type: "select" },
},
},
args: { onClick: fn() },
} satisfies Meta<typeof ButtonIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Small: Story = {
args: {
Icon: Plus,
variant: "small",
},
};
export const Big: Story = {
args: {
Icon: Plus,
variant: "big",
},
};
export const Disabled: Story = {
args: {
Icon: Plus,
disabled: true,
},
};
export const CustomStyle: Story = {
args: {
Icon: Plus,
variant: "big",
style: {
"--codex-button-icon-background": "red",
},
},
};

33
stories/Card.stories.ts Normal file
View File

@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { Plus } from "lucide-react";
import { Card } from "../components/Card/Card";
import React, { CSSProperties } from "react";
const meta = {
title: "Components/Card",
component: Card,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof Card>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Hello",
children: React.createElement("p", {}, "Hello World !"),
},
};
export const CustomStyle: Story = {
args: {
title: "Hello",
children: React.createElement("p", {}, "Hello World !"),
style: { "--codex-border-radius": "0px" },
},
};

364
stories/Configure.mdx Normal file
View File

@ -0,0 +1,364 @@
import { Meta } from "@storybook/blocks";
import Github from "./assets/github.svg";
import Discord from "./assets/discord.svg";
import Youtube from "./assets/youtube.svg";
import Tutorials from "./assets/tutorials.svg";
import Styling from "./assets/styling.png";
import Context from "./assets/context.png";
import Assets from "./assets/assets.png";
import Docs from "./assets/docs.png";
import Share from "./assets/share.png";
import FigmaPlugin from "./assets/figma-plugin.png";
import Testing from "./assets/testing.png";
import Accessibility from "./assets/accessibility.png";
import Theming from "./assets/theming.png";
import AddonLibrary from "./assets/addon-library.png";
export const RightArrow = () => <svg
viewBox="0 0 14 14"
width="8px"
height="14px"
style={{
marginLeft: '4px',
display: 'inline-block',
shapeRendering: 'inherit',
verticalAlign: 'middle',
fill: 'currentColor',
'path fill': 'currentColor'
}}
>
<path d="m11.1 7.35-5.5 5.5a.5.5 0 0 1-.7-.7L10.04 7 4.9 1.85a.5.5 0 1 1 .7-.7l5.5 5.5c.2.2.2.5 0 .7Z" />
</svg>
<Meta title="Configure your project" />
<div className="sb-container">
<div className='sb-section-title'>
# Configure your project
Because Storybook works separately from your app, you'll need to configure it for your specific stack and setup. Below, explore guides for configuring Storybook with popular frameworks and tools. If you get stuck, learn how you can ask for help from our community.
</div>
<div className="sb-section">
<div className="sb-section-item">
<img
src={Styling}
alt="A wall of logos representing different styling technologies"
/>
<h4 className="sb-section-item-heading">Add styling and CSS</h4>
<p className="sb-section-item-paragraph">Like with web applications, there are many ways to include CSS within Storybook. Learn more about setting up styling within Storybook.</p>
<a
href="https://storybook.js.org/docs/configure/styling-and-css/?renderer=react"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-section-item">
<img
src={Context}
alt="An abstraction representing the composition of data for a component"
/>
<h4 className="sb-section-item-heading">Provide context and mocking</h4>
<p className="sb-section-item-paragraph">Often when a story doesn't render, it's because your component is expecting a specific environment or context (like a theme provider) to be available.</p>
<a
href="https://storybook.js.org/docs/writing-stories/decorators/?renderer=react#context-for-mocking"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-section-item">
<img src={Assets} alt="A representation of typography and image assets" />
<div>
<h4 className="sb-section-item-heading">Load assets and resources</h4>
<p className="sb-section-item-paragraph">To link static files (like fonts) to your projects and stories, use the
`staticDirs` configuration option to specify folders to load when
starting Storybook.</p>
<a
href="https://storybook.js.org/docs/configure/images-and-assets/?renderer=react"
target="_blank"
>Learn more<RightArrow /></a>
</div>
</div>
</div>
</div>
<div className="sb-container">
<div className='sb-section-title'>
# Do more with Storybook
Now that you know the basics, let's explore other parts of Storybook that will improve your experience. This list is just to get you started. You can customise Storybook in many ways to fit your needs.
</div>
<div className="sb-section">
<div className="sb-features-grid">
<div className="sb-grid-item">
<img src={Docs} alt="A screenshot showing the autodocs tag being set, pointing a docs page being generated" />
<h4 className="sb-section-item-heading">Autodocs</h4>
<p className="sb-section-item-paragraph">Auto-generate living,
interactive reference documentation from your components and stories.</p>
<a
href="https://storybook.js.org/docs/writing-docs/autodocs/?renderer=react"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-grid-item">
<img src={Share} alt="A browser window showing a Storybook being published to a chromatic.com URL" />
<h4 className="sb-section-item-heading">Publish to Chromatic</h4>
<p className="sb-section-item-paragraph">Publish your Storybook to review and collaborate with your entire team.</p>
<a
href="https://storybook.js.org/docs/sharing/publish-storybook/?renderer=react#publish-storybook-with-chromatic"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-grid-item">
<img src={FigmaPlugin} alt="Windows showing the Storybook plugin in Figma" />
<h4 className="sb-section-item-heading">Figma Plugin</h4>
<p className="sb-section-item-paragraph">Embed your stories into Figma to cross-reference the design and live
implementation in one place.</p>
<a
href="https://storybook.js.org/docs/sharing/design-integrations/?renderer=react#embed-storybook-in-figma-with-the-plugin"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-grid-item">
<img src={Testing} alt="Screenshot of tests passing and failing" />
<h4 className="sb-section-item-heading">Testing</h4>
<p className="sb-section-item-paragraph">Use stories to test a component in all its variations, no matter how
complex.</p>
<a
href="https://storybook.js.org/docs/writing-tests/?renderer=react"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-grid-item">
<img src={Accessibility} alt="Screenshot of accessibility tests passing and failing" />
<h4 className="sb-section-item-heading">Accessibility</h4>
<p className="sb-section-item-paragraph">Automatically test your components for a11y issues as you develop.</p>
<a
href="https://storybook.js.org/docs/writing-tests/accessibility-testing/?renderer=react"
target="_blank"
>Learn more<RightArrow /></a>
</div>
<div className="sb-grid-item">
<img src={Theming} alt="Screenshot of Storybook in light and dark mode" />
<h4 className="sb-section-item-heading">Theming</h4>
<p className="sb-section-item-paragraph">Theme Storybook's UI to personalize it to your project.</p>
<a
href="https://storybook.js.org/docs/configure/theming/?renderer=react"
target="_blank"
>Learn more<RightArrow /></a>
</div>
</div>
</div>
</div>
<div className='sb-addon'>
<div className='sb-addon-text'>
<h4>Addons</h4>
<p className="sb-section-item-paragraph">Integrate your tools with Storybook to connect workflows.</p>
<a
href="https://storybook.js.org/addons/"
target="_blank"
>Discover all addons<RightArrow /></a>
</div>
<div className='sb-addon-img'>
<img src={AddonLibrary} alt="Integrate your tools with Storybook to connect workflows." />
</div>
</div>
<div className="sb-section sb-socials">
<div className="sb-section-item">
<img src={Github} alt="Github logo" className="sb-explore-image"/>
Join our contributors building the future of UI development.
<a
href="https://github.com/storybookjs/storybook"
target="_blank"
>Star on GitHub<RightArrow /></a>
</div>
<div className="sb-section-item">
<img src={Discord} alt="Discord logo" className="sb-explore-image"/>
<div>
Get support and chat with frontend developers.
<a
href="https://discord.gg/storybook"
target="_blank"
>Join Discord server<RightArrow /></a>
</div>
</div>
<div className="sb-section-item">
<img src={Youtube} alt="Youtube logo" className="sb-explore-image"/>
<div>
Watch tutorials, feature previews and interviews.
<a
href="https://www.youtube.com/@chromaticui"
target="_blank"
>Watch on YouTube<RightArrow /></a>
</div>
</div>
<div className="sb-section-item">
<img src={Tutorials} alt="A book" className="sb-explore-image"/>
<p>Follow guided walkthroughs on for key workflows.</p>
<a
href="https://storybook.js.org/tutorials/"
target="_blank"
>Discover tutorials<RightArrow /></a>
</div>
</div>
<style>
{`
.sb-container {
margin-bottom: 48px;
}
.sb-section {
width: 100%;
display: flex;
flex-direction: row;
gap: 20px;
}
img {
object-fit: cover;
}
.sb-section-title {
margin-bottom: 32px;
}
.sb-section a:not(h1 a, h2 a, h3 a) {
font-size: 14px;
}
.sb-section-item, .sb-grid-item {
flex: 1;
display: flex;
flex-direction: column;
}
.sb-section-item-heading {
padding-top: 20px !important;
padding-bottom: 5px !important;
margin: 0 !important;
}
.sb-section-item-paragraph {
margin: 0;
padding-bottom: 10px;
}
.sb-chevron {
margin-left: 5px;
}
.sb-features-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 32px 20px;
}
.sb-socials {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.sb-socials p {
margin-bottom: 10px;
}
.sb-explore-image {
max-height: 32px;
align-self: flex-start;
}
.sb-addon {
width: 100%;
display: flex;
align-items: center;
position: relative;
background-color: #EEF3F8;
border-radius: 5px;
border: 1px solid rgba(0, 0, 0, 0.05);
background: #EEF3F8;
height: 180px;
margin-bottom: 48px;
overflow: hidden;
}
.sb-addon-text {
padding-left: 48px;
max-width: 240px;
}
.sb-addon-text h4 {
padding-top: 0px;
}
.sb-addon-img {
position: absolute;
left: 345px;
top: 0;
height: 100%;
width: 200%;
overflow: hidden;
}
.sb-addon-img img {
width: 650px;
transform: rotate(-15deg);
margin-left: 40px;
margin-top: -72px;
box-shadow: 0 0 1px rgba(255, 255, 255, 0);
backface-visibility: hidden;
}
@media screen and (max-width: 800px) {
.sb-addon-img {
left: 300px;
}
}
@media screen and (max-width: 600px) {
.sb-section {
flex-direction: column;
}
.sb-features-grid {
grid-template-columns: repeat(1, 1fr);
}
.sb-socials {
grid-template-columns: repeat(2, 1fr);
}
.sb-addon {
height: 280px;
align-items: flex-start;
padding-top: 32px;
overflow: hidden;
}
.sb-addon-text {
padding-left: 24px;
}
.sb-addon-img {
right: 0;
left: 0;
top: 130px;
bottom: 0;
overflow: hidden;
height: auto;
width: 124%;
}
.sb-addon-img img {
width: 1200px;
transform: rotate(-12deg);
margin-left: 0;
margin-top: 48px;
margin-bottom: -40px;
margin-left: -24px;
}
}
`}
</style>

View File

@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Dropdown, DropdownOption } from "../components/Dropdown/Dropdown";
import { PdfIcon } from "../components/WebFileIcon/PdfIcon";
import { ImageIcon } from "../components/WebFileIcon/ImageIcon";
import React, { ChangeEvent, useState } from "react";
const meta = {
title: "Forms/Dropdown",
component: Dropdown,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof Dropdown>;
export default meta;
type Story = StoryObj<typeof meta>;
const Template = () => {
const [value, setValue] = useState<string>("");
const onChange = (e: ChangeEvent<HTMLInputElement>) =>
setValue(e.currentTarget.value);
const onSelected = (o: DropdownOption) => setValue(o.title);
return (
<Dropdown
placeholder="Select your file"
onChange={onChange}
onSelected={onSelected}
value={value}
options={[
{
title: "File1.pdf",
Icon: PdfIcon,
subtitle: "cid1",
},
{
title: "File2.jpg",
Icon: ImageIcon,
subtitle: "cid2",
},
]}
/>
);
};
export const Default: Story = Template.bind({});
export const CustomStyle: Story = {
args: {
placeholder: "Select your file",
options: [],
style: { "--codex-input-border": "1px solid red" },
value: "",
onChange: () => "",
},
};

View File

@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react";
import { EmptyPlaceholder } from "../components/EmptyPlaceholder/EmptyPlaceholder";
const meta = {
title: "Content/EmptyPlaceholder",
component: EmptyPlaceholder,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof EmptyPlaceholder>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Nothing to show",
message: "No data here yet. We will notify you when there's an update.",
},
};
export const Retry: Story = {
args: {
title: "Nothing to show",
message: "No data here yet. We will notify you when there's an update.",
onRetry: () => {},
},
};

View File

@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Failure } from "../components/Failure/Failure";
const meta = {
title: "Content/Failure",
component: Failure,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof Failure>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
title: "Some error",
code: 500,
message: "Something went wrong",
},
};
export const WithAction: Story = {
args: {
title: "Some error",
code: 500,
message: "Something went wrong",
onClick: () => {},
},
};

57
stories/Input.stories.ts Normal file
View File

@ -0,0 +1,57 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { Input } from "../components/Input/Input";
import { InputIcon } from "./InputIcon";
const meta = {
title: "Forms/Input",
component: Input,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
args: { onChange: fn() },
} satisfies Meta<typeof Input>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
id: "input",
label: "Input",
},
};
export const Helper: Story = {
args: {
id: "helper",
label: "Input",
helper: "Helper text to give some indication.",
},
};
export const Icon: Story = {
args: {
id: "icon",
label: "Icon",
Icon: InputIcon,
},
};
export const Disabled: Story = {
args: {
id: "disabled",
label: "Disabled",
disabled: true,
},
};
export const CustomStyle: Story = {
args: {
id: "custom",
label: "Label",
style: { "--codex-input-border": "1px solid red" },
},
};

View File

@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { InputGroup } from "../components/InputGroup/InputGroup";
const meta = {
title: "Components/InputGroup",
component: InputGroup,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
args: { onChange: fn() },
} satisfies Meta<typeof InputGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Simple: Story = {
args: {
id: "input",
label: "Input",
group: "Seconds",
},
};
export const Select: Story = {
args: {
id: "input",
label: "Input",
group: [
["seconds", "Seconds"],
["minutes", "Minutes"],
],
},
};

6
stories/InputIcon.tsx Normal file
View File

@ -0,0 +1,6 @@
import { User } from "lucide-react";
import React from "react";
export function InputIcon() {
return <User size="1.25rem" />;
}

40
stories/Select.stories.ts Normal file
View File

@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { Select } from "../components/Select/Select";
const meta = {
title: "Forms/Select",
component: Select,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
args: { onChange: fn() },
} satisfies Meta<typeof Select>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
id: "select",
label: "Select",
options: [
["value 1", "Text 1"],
["value 2", "Text 2"],
],
},
};
export const CustomStyle: Story = {
args: {
id: "select",
label: "Select",
options: [
["value 1", "Text 1"],
["value 2", "Text 2"],
],
style: { "--codex-select-border": "1px solid red" },
},
};

View File

@ -0,0 +1,58 @@
import type { Meta, StoryObj } from "@storybook/react";
import { SimpleText } from "../components/SimpleText/SimpleText";
const meta = {
title: "Components/SimpleText",
component: SimpleText,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof SimpleText>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Normal: Story = {
args: {
variant: "normal",
children: "Normal message.",
},
};
export const Light: Story = {
args: {
variant: "light",
children: "Light message.",
},
};
export const Success: Story = {
args: {
variant: "primary",
children: "Primary message.",
},
};
export const Error: Story = {
args: {
variant: "error",
children: "Error message.",
},
};
export const Warning: Story = {
args: {
variant: "warning",
children: "Warning message.",
},
};
export const CustomStyle: Story = {
args: {
variant: "normal",
children: "Normal message.",
style: { "--codex-color": "red" },
},
};

View File

@ -0,0 +1,20 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Spinner } from "../components/Spinner/Spinner";
import React from "react";
const meta = {
title: "Overlays/Spinner",
component: Spinner,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof Spinner>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: { width: "32" },
};

View File

@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Stepper } from "../components/Stepper/Stepper";
import React, { CSSProperties, useState } from "react";
const meta = {
title: "Advanced/Stepper",
component: Stepper,
parameters: {
layout: "fullscreen",
},
tags: ["autodocs"],
argTypes: {},
// args: { onClick: fn() },
} satisfies Meta<typeof Stepper>;
export default meta;
type Story = StoryObj<typeof meta>;
const Template = () => {
const [step, setStep] = useState(0);
const [progress, setProgress] = useState(false);
const titles = ["Hello world", "Hello world 2", "Hello world 3"];
const title = titles[step];
const onChangeStep = (newStep: number, event: "before" | "end") => {
if (event === "before") {
setProgress(true);
return;
}
setProgress(false);
setStep(newStep);
};
return (
<Stepper
Body={() => React.createElement("p", {}, title)}
titles={titles}
onChangeStep={onChangeStep}
progress={progress}
step={step}
isNextDisable={progress || step === 2}
/>
);
};
export const Default: Story = Template.bind({});

47
stories/Toast.stories.tsx Normal file
View File

@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react";
import { CircleCheck } from "lucide-react";
import { Toast } from "../components/Toast/Toast";
import { MouseEvent, useState } from "react";
import React from "react";
const meta = {
title: "Overlays/Toast",
component: Toast,
parameters: {
layout: "fullscreen",
inlineStories: false,
iframeHeight: "425px",
},
tags: ["autodocs"],
argTypes: {},
args: {},
} satisfies Meta<typeof Toast>;
export default meta;
type Story = StoryObj<typeof meta>;
const Template = () => {
const [time, setTime] = useState(0);
const onClick = (_: MouseEvent) => setTime(Date.now());
return (
<div style={{ padding: "2rem" }}>
<button onClick={onClick}>Make Toast</button>
<Toast
Icon={() => (
<CircleCheck
size="1.25rem"
fill="var(--codex-color-primary"
className="primary upload-progress-check"
stroke="var(--codex-background)"
></CircleCheck>
)}
message="Toast displayed with success"
time={time}
/>
</div>
);
};
export const Default: Story = Template.bind({});

View File

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Upload } from "../components/Upload/Upload";
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { UploadResponse } from "@codex/sdk-js";
const meta = {
title: "Advanced/Upload",
component: Upload,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {},
} satisfies Meta<typeof Upload>;
export default meta;
type Story = StoryObj<typeof meta>;
const queryClient = new QueryClient();
const Template = () => {
return (
<QueryClientProvider client={queryClient}>
{<Upload useWorker={false} multiple />}
</QueryClientProvider>
);
};
export const Multiple: Story = Template.bind({});
const slowProvider = () =>
Promise.resolve(
(_: File, onProgress: (loaded: number, total: number) => void) => {
return new Promise<UploadResponse>((resolve) => {
let timeout;
resolve({
abort: () => {
window.clearInterval(timeout);
},
result: new Promise((resolve) => {
let count = 0;
timeout = setInterval(() => {
count++;
onProgress(500 * count, 1500);
if (count === 3) {
window.clearInterval(timeout);
resolve({
error: false as false,
data: Date.now().toString(),
});
}
}, 1500);
}),
});
});
}
);
const SlowTemplate = () => {
return (
<div className="demo">
<QueryClientProvider client={queryClient}>
{<Upload useWorker={false} multiple provider={slowProvider} />}
</QueryClientProvider>
</div>
);
};
export const Slow: Story = SlowTemplate.bind({});
const SingleTemplate = () => {
return (
<div className="demo">
<QueryClientProvider client={queryClient}>
{
<Upload
useWorker={false}
multiple={false}
editable={false}
provider={slowProvider}
/>
}
</QueryClientProvider>
</div>
);
};
export const Single: Story = SingleTemplate.bind({});

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="none" viewBox="0 0 48 48"><title>Accessibility</title><circle cx="24.334" cy="24" r="24" fill="#A849FF" fill-opacity=".3"/><path fill="#A470D5" fill-rule="evenodd" d="M27.8609 11.585C27.8609 9.59506 26.2497 7.99023 24.2519 7.99023C22.254 7.99023 20.6429 9.65925 20.6429 11.585C20.6429 13.575 22.254 15.1799 24.2519 15.1799C26.2497 15.1799 27.8609 13.575 27.8609 11.585ZM21.8922 22.6473C21.8467 23.9096 21.7901 25.4788 21.5897 26.2771C20.9853 29.0462 17.7348 36.3314 17.3325 37.2275C17.1891 37.4923 17.1077 37.7955 17.1077 38.1178C17.1077 39.1519 17.946 39.9902 18.9802 39.9902C19.6587 39.9902 20.253 39.6293 20.5814 39.0889L20.6429 38.9874L24.2841 31.22C24.2841 31.22 27.5529 37.9214 27.9238 38.6591C28.2948 39.3967 28.8709 39.9902 29.7168 39.9902C30.751 39.9902 31.5893 39.1519 31.5893 38.1178C31.5893 37.7951 31.3639 37.2265 31.3639 37.2265C30.9581 36.3258 27.698 29.0452 27.0938 26.2771C26.8975 25.4948 26.847 23.9722 26.8056 22.7236C26.7927 22.333 26.7806 21.9693 26.7653 21.6634C26.7008 21.214 27.0231 20.8289 27.4097 20.7005L35.3366 18.3253C36.3033 18.0685 36.8834 16.9773 36.6256 16.0144C36.3678 15.0515 35.2722 14.4737 34.3055 14.7305C34.3055 14.7305 26.8619 17.1057 24.2841 17.1057C21.7062 17.1057 14.456 14.7947 14.456 14.7947C13.4893 14.5379 12.3937 14.9873 12.0715 15.9502C11.7493 16.9131 12.3293 18.0044 13.3604 18.3253L21.2873 20.7005C21.674 20.8289 21.9318 21.214 21.9318 21.6634C21.9174 21.9493 21.9053 22.2857 21.8922 22.6473Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

BIN
stories/assets/assets.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

BIN
stories/assets/context.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="33" height="32" fill="none" viewBox="0 0 33 32"><g clip-path="url(#clip0_10031_177575)"><mask id="mask0_10031_177575" style="mask-type:luminance" width="33" height="25" x="0" y="4" maskUnits="userSpaceOnUse"><path fill="#fff" d="M32.5034 4.00195H0.503906V28.7758H32.5034V4.00195Z"/></mask><g mask="url(#mask0_10031_177575)"><path fill="#5865F2" d="M27.5928 6.20817C25.5533 5.27289 23.3662 4.58382 21.0794 4.18916C21.0378 4.18154 20.9962 4.20057 20.9747 4.23864C20.6935 4.73863 20.3819 5.3909 20.1637 5.90358C17.7042 5.53558 15.2573 5.53558 12.8481 5.90358C12.6299 5.37951 12.307 4.73863 12.0245 4.23864C12.003 4.20184 11.9614 4.18281 11.9198 4.18916C9.63431 4.58255 7.44721 5.27163 5.40641 6.20817C5.38874 6.21578 5.3736 6.22848 5.36355 6.24497C1.21508 12.439 0.078646 18.4809 0.636144 24.4478C0.638667 24.477 0.655064 24.5049 0.677768 24.5227C3.41481 26.5315 6.06609 27.7511 8.66815 28.5594C8.70979 28.5721 8.75392 28.5569 8.78042 28.5226C9.39594 27.6826 9.94461 26.7968 10.4151 25.8653C10.4428 25.8107 10.4163 25.746 10.3596 25.7244C9.48927 25.3945 8.66058 24.9922 7.86343 24.5354C7.80038 24.4986 7.79533 24.4084 7.85333 24.3653C8.02108 24.2397 8.18888 24.109 8.34906 23.977C8.37804 23.9529 8.41842 23.9478 8.45249 23.963C13.6894 26.3526 19.359 26.3526 24.5341 23.963C24.5682 23.9465 24.6086 23.9516 24.6388 23.9757C24.799 24.1077 24.9668 24.2397 25.1358 24.3653C25.1938 24.4084 25.19 24.4986 25.127 24.5354C24.3298 25.0011 23.5011 25.3945 22.6296 25.7232C22.5728 25.7447 22.5476 25.8107 22.5754 25.8653C23.0559 26.7955 23.6046 27.6812 24.2087 28.5213C24.234 28.5569 24.2794 28.5721 24.321 28.5594C26.9357 27.7511 29.5869 26.5315 32.324 24.5227C32.348 24.5049 32.3631 24.4783 32.3656 24.4491C33.0328 17.5506 31.2481 11.5584 27.6344 6.24623C27.6256 6.22848 27.6105 6.21578 27.5928 6.20817ZM11.1971 20.8146C9.62043 20.8146 8.32129 19.3679 8.32129 17.5913C8.32129 15.8146 9.59523 14.368 11.1971 14.368C12.8115 14.368 14.0981 15.8273 14.0729 17.5913C14.0729 19.3679 12.7989 20.8146 11.1971 20.8146ZM21.8299 20.8146C20.2533 20.8146 18.9541 19.3679 18.9541 17.5913C18.9541 15.8146 20.228 14.368 21.8299 14.368C23.4444 14.368 24.7309 15.8273 24.7057 17.5913C24.7057 19.3679 23.4444 20.8146 21.8299 20.8146Z"/></g></g><defs><clipPath id="clip0_10031_177575"><rect width="32" height="32" fill="#fff" transform="translate(0.5)"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
stories/assets/docs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path fill="#161614" d="M16.0001 0C7.16466 0 0 7.17472 0 16.0256C0 23.1061 4.58452 29.1131 10.9419 31.2322C11.7415 31.3805 12.0351 30.8845 12.0351 30.4613C12.0351 30.0791 12.0202 28.8167 12.0133 27.4776C7.56209 28.447 6.62283 25.5868 6.62283 25.5868C5.89499 23.7345 4.8463 23.2419 4.8463 23.2419C3.39461 22.2473 4.95573 22.2678 4.95573 22.2678C6.56242 22.3808 7.40842 23.9192 7.40842 23.9192C8.83547 26.3691 11.1514 25.6609 12.0645 25.2514C12.2081 24.2156 12.6227 23.5087 13.0803 23.1085C9.52648 22.7032 5.7906 21.3291 5.7906 15.1886C5.7906 13.4389 6.41563 12.0094 7.43916 10.8871C7.27303 10.4834 6.72537 8.85349 7.59415 6.64609C7.59415 6.64609 8.93774 6.21539 11.9953 8.28877C13.2716 7.9337 14.6404 7.75563 16.0001 7.74953C17.3599 7.75563 18.7297 7.9337 20.0084 8.28877C23.0623 6.21539 24.404 6.64609 24.404 6.64609C25.2749 8.85349 24.727 10.4834 24.5608 10.8871C25.5868 12.0094 26.2075 13.4389 26.2075 15.1886C26.2075 21.3437 22.4645 22.699 18.9017 23.0957C19.4756 23.593 19.9869 24.5683 19.9869 26.0634C19.9869 28.2077 19.9684 29.9334 19.9684 30.4613C19.9684 30.8877 20.2564 31.3874 21.0674 31.2301C27.4213 29.1086 32 23.1037 32 16.0256C32 7.17472 24.8364 0 16.0001 0ZM5.99257 22.8288C5.95733 22.9084 5.83227 22.9322 5.71834 22.8776C5.60229 22.8253 5.53711 22.7168 5.57474 22.6369C5.60918 22.5549 5.7345 22.5321 5.85029 22.587C5.9666 22.6393 6.03284 22.7489 5.99257 22.8288ZM6.7796 23.5321C6.70329 23.603 6.55412 23.5701 6.45291 23.4581C6.34825 23.3464 6.32864 23.197 6.40601 23.125C6.4847 23.0542 6.62937 23.0874 6.73429 23.1991C6.83895 23.3121 6.85935 23.4605 6.7796 23.5321ZM7.31953 24.4321C7.2215 24.5003 7.0612 24.4363 6.96211 24.2938C6.86407 24.1513 6.86407 23.9804 6.96422 23.9119C7.06358 23.8435 7.2215 23.905 7.32191 24.0465C7.41968 24.1914 7.41968 24.3623 7.31953 24.4321ZM8.23267 25.4743C8.14497 25.5712 7.95818 25.5452 7.82146 25.413C7.68156 25.2838 7.64261 25.1004 7.73058 25.0035C7.81934 24.9064 8.00719 24.9337 8.14497 25.0648C8.28381 25.1938 8.3262 25.3785 8.23267 25.4743ZM9.41281 25.8262C9.37413 25.9517 9.19423 26.0088 9.013 25.9554C8.83203 25.9005 8.7136 25.7535 8.75016 25.6266C8.78778 25.5003 8.96848 25.4408 9.15104 25.4979C9.33174 25.5526 9.45044 25.6985 9.41281 25.8262ZM10.7559 25.9754C10.7604 26.1076 10.6067 26.2172 10.4165 26.2196C10.2252 26.2238 10.0704 26.1169 10.0683 25.9868C10.0683 25.8534 10.2185 25.7448 10.4098 25.7416C10.6001 25.7379 10.7559 25.8441 10.7559 25.9754ZM12.0753 25.9248C12.0981 26.0537 11.9658 26.1862 11.7769 26.2215C11.5912 26.2554 11.4192 26.1758 11.3957 26.0479C11.3726 25.9157 11.5072 25.7833 11.6927 25.7491C11.8819 25.7162 12.0512 25.7937 12.0753 25.9248Z"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
stories/assets/share.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
stories/assets/styling.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

BIN
stories/assets/testing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
stories/assets/theming.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="33" height="32" fill="none" viewBox="0 0 33 32"><g clip-path="url(#clip0_10031_177597)"><path fill="#B7F0EF" fill-rule="evenodd" d="M17 7.87059C17 6.48214 17.9812 5.28722 19.3431 5.01709L29.5249 2.99755C31.3238 2.64076 33 4.01717 33 5.85105V22.1344C33 23.5229 32.0188 24.7178 30.6569 24.9879L20.4751 27.0074C18.6762 27.3642 17 25.9878 17 24.1539L17 7.87059Z" clip-rule="evenodd" opacity=".7"/><path fill="#87E6E5" fill-rule="evenodd" d="M1 5.85245C1 4.01857 2.67623 2.64215 4.47507 2.99895L14.6569 5.01848C16.0188 5.28861 17 6.48354 17 7.87198V24.1553C17 25.9892 15.3238 27.3656 13.5249 27.0088L3.34311 24.9893C1.98119 24.7192 1 23.5242 1 22.1358V5.85245Z" clip-rule="evenodd"/><path fill="#61C1FD" fill-rule="evenodd" d="M15.543 5.71289C15.543 5.71289 16.8157 5.96289 17.4002 6.57653C17.9847 7.19016 18.4521 9.03107 18.4521 9.03107C18.4521 9.03107 18.4521 25.1106 18.4521 26.9629C18.4521 28.8152 19.3775 31.4174 19.3775 31.4174L17.4002 28.8947L16.2575 31.4174C16.2575 31.4174 15.543 29.0765 15.543 27.122C15.543 25.1674 15.543 5.71289 15.543 5.71289Z" clip-rule="evenodd"/></g><defs><clipPath id="clip0_10031_177597"><rect width="32" height="32" fill="#fff" transform="translate(0.5)"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="none" viewBox="0 0 32 32"><path fill="#ED1D24" d="M31.3313 8.44657C30.9633 7.08998 29.8791 6.02172 28.5022 5.65916C26.0067 5.00026 16 5.00026 16 5.00026C16 5.00026 5.99333 5.00026 3.4978 5.65916C2.12102 6.02172 1.03665 7.08998 0.668678 8.44657C0 10.9053 0 16.0353 0 16.0353C0 16.0353 0 21.1652 0.668678 23.6242C1.03665 24.9806 2.12102 26.0489 3.4978 26.4116C5.99333 27.0703 16 27.0703 16 27.0703C16 27.0703 26.0067 27.0703 28.5022 26.4116C29.8791 26.0489 30.9633 24.9806 31.3313 23.6242C32 21.1652 32 16.0353 32 16.0353C32 16.0353 32 10.9053 31.3313 8.44657Z"/><path fill="#fff" d="M12.7266 20.6934L21.0902 16.036L12.7266 11.3781V20.6934Z"/></svg>

After

Width:  |  Height:  |  Size: 716 B

6
utils/attributes.ts Normal file
View File

@ -0,0 +1,6 @@
type Attributes = Record<string, unknown>;
export const attributes = (attributes: Attributes) =>
Object.keys(attributes)
.filter((key) => attributes[key] !== false)
.reduce((prev, key) => ({ ...prev, [key]: attributes[key] }), {});

14
utils/bytes.ts Normal file
View File

@ -0,0 +1,14 @@
export const PrettyBytes = (bytes: number) => {
const sizes = ["bytes", "KB", "MB", "GB", "TB"];
if (bytes == 0) {
return "0 b";
}
const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)).toString());
if (i == 0) {
return bytes + " " + sizes[i];
}
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + sizes[i];
};

7
utils/classnames.ts Normal file
View File

@ -0,0 +1,7 @@
export type Classname = [string, boolean?];
export const classnames = (...classnames: Classname[]) =>
classnames
.filter(([, visible = true]) => visible)
.map(([name]) => name)
.join(" ");