feat(www): Finish up on the onboarding (#210)

Merging this prematurely to make sure the team is on the same boat... like dang! We need to find a better way to do this. 

Plus it has become too big
This commit is contained in:
Wanjohi
2025-03-26 02:21:53 +03:00
committed by GitHub
parent 957eca7794
commit f62fc1fb4b
106 changed files with 6329 additions and 866 deletions

View File

@@ -0,0 +1,97 @@
const DEFAULT_COLORS = ['#6A5ACD', '#E63525', '#20B2AA', '#E87D58'];
const getModulo = (value: number, divisor: number, useEvenCheck?: number) => {
const remainder = value % divisor;
if (useEvenCheck && Math.floor(value / Math.pow(10, useEvenCheck) % 10) % 2 === 0) {
return -remainder;
}
return remainder;
};
const generateColors = (name: string, colors = DEFAULT_COLORS) => {
const hashCode = name.split('').reduce((acc, char) => {
acc = ((acc << 5) - acc) + char.charCodeAt(0);
return acc & acc;
}, 0);
const hash = Math.abs(hashCode);
const numColors = colors.length;
return Array.from({ length: 3 }, (_, index) => ({
color: colors[(hash + index) % numColors],
translateX: getModulo(hash * (index + 1), 4, 1),
translateY: getModulo(hash * (index + 1), 4, 2),
scale: 1.2 + getModulo(hash * (index + 1), 2) / 10,
rotate: getModulo(hash * (index + 1), 360, 1)
}));
};
type Props = {
name: string;
size?: number;
class?: string;
colors?: string[]
}
export default function Avatar({ class: className, name, size = 80, colors = DEFAULT_COLORS }: Props) {
const colorData = generateColors(name, colors);
const blurValue = Math.max(1, Math.min(7, size / 10));
return (
<svg
viewBox="0 0 80 80"
fill="none"
role="img"
class={className}
preserveAspectRatio="xMidYMid meet"
aria-describedby={name}
width={size}
height={size}
>
<title id={name}>{`Fallback avatar for ${name}`}</title>
<mask
id="mask__marble"
maskUnits="userSpaceOnUse"
x={0}
y={0}
width={80}
height={80}
>
<rect width={80} height={80} rx={160} fill="#FFFFFF" />
</mask>
<g mask="url(#mask__marble)">
<rect width={80} height={80} rx={160} fill={colorData[0].color} />
<path
filter="url(#prefix__filter0_f)"
d="M32.414 59.35L50.376 70.5H72.5v-71H33.728L26.5 13.381l19.057 27.08L32.414 59.35z"
fill={colorData[1].color}
transform={
`translate(${colorData[1].translateX} ${colorData[1].translateY})
rotate(${colorData[1].rotate} 40 40)
scale(${colorData[1].scale})`}
/>
<path
filter="url(#prefix__filter0_f)"
style={{ "mix-blend-mode": "overlay" }}
d="M22.216 24L0 46.75l14.108 38.129L78 86l-3.081-59.276-22.378 4.005 12.972 20.186-23.35 27.395L22.215 24z"
fill={colorData[2].color}
transform={
`translate(${colorData[2].translateX} ${colorData[2].translateY})
rotate(${colorData[2].rotate} 40 40)
scale(${colorData[2].scale})`}
/>
</g>
<defs>
<filter
id="prefix__filter0_f"
filterUnits="userSpaceOnUse"
color-interpolation-filters="s-rGB"
>
<feFlood flood-opacity={0} result="BackgroundImageFix" />
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation={blurValue} result="effect1_foregroundBlur" />
</filter>
</defs>
</svg>
)
}

View File

@@ -11,6 +11,7 @@ export const Button = styled("button", {
lineHeight: "normal",
fontFamily: theme.font.family.heading,
textAlign: "center",
cursor: "pointer",
transitionDelay: "0s, 0s",
transitionDuration: "0.2s, 0.2s",
transitionProperty: "background-color, border",

View File

@@ -0,0 +1,160 @@
import QRCodeUtil from 'qrcode';
import { createMemo, JSXElement } from "solid-js"
const generateMatrix = (
value: string,
errorCorrectionLevel: QRCodeUtil.QRCodeErrorCorrectionLevel
) => {
const arr = Array.prototype.slice.call(
QRCodeUtil.create(value, { errorCorrectionLevel }).modules.data,
0
);
const sqrt = Math.sqrt(arr.length);
return arr.reduce(
(rows, key, index) =>
(index % sqrt === 0
? rows.push([key])
: rows[rows.length - 1].push(key)) && rows,
[]
);
};
type Props = {
ecl?: QRCodeUtil.QRCodeErrorCorrectionLevel;
size?: number;
uri: string;
clearArea?: boolean;
image?: HTMLImageElement;
imageBackground?: string;
};
export function QRCode({
ecl = 'M',
size: sizeProp = 200,
uri,
clearArea = false,
image,
imageBackground = 'transparent',
}: Props) {
const logoSize = clearArea ? 32 : 0;
const size = sizeProp - 10 * 2;
const dots = createMemo(() => {
const dots: JSXElement[] = [];
const matrix = generateMatrix(uri, ecl);
const cellSize = size / matrix.length;
let qrList = [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 0, y: 1 },
];
qrList.forEach(({ x, y }) => {
const x1 = (matrix.length - 7) * cellSize * x;
const y1 = (matrix.length - 7) * cellSize * y;
for (let i = 0; i < 3; i++) {
dots.push(
<rect
id={`${i}-${x}-${y}`}
fill={
i % 2 !== 0
? 'var(--nestri-qr-background, var(--nestri-body-background))'
: 'var(--nestri-qr-dot-color)'
}
rx={(i - 2) * -5 + (i === 0 ? 2 : 3)}
ry={(i - 2) * -5 + (i === 0 ? 2 : 3)}
width={cellSize * (7 - i * 2)}
height={cellSize * (7 - i * 2)}
x={x1 + cellSize * i}
y={y1 + cellSize * i}
/>
);
}
});
if (image) {
const x1 = (matrix.length - 7) * cellSize * 1;
const y1 = (matrix.length - 7) * cellSize * 1;
dots.push(
<>
<rect
fill={imageBackground}
rx={(0 - 2) * -5 + 2}
ry={(0 - 2) * -5 + 2}
width={cellSize * (7 - 0 * 2)}
height={cellSize * (7 - 0 * 2)}
x={x1 + cellSize * 0}
y={y1 + cellSize * 0}
/>
<foreignObject
width={cellSize * (7 - 0 * 2)}
height={cellSize * (7 - 0 * 2)}
x={x1 + cellSize * 0}
y={y1 + cellSize * 0}
>
<div style={{ "border-radius": `${(0 - 2) * -5 + 2}px`, overflow: 'hidden' }}>
{image}
</div>
</foreignObject>
</>
);
}
const clearArenaSize = Math.floor((logoSize + 25) / cellSize);
const matrixMiddleStart = matrix.length / 2 - clearArenaSize / 2;
const matrixMiddleEnd = matrix.length / 2 + clearArenaSize / 2 - 1;
matrix.forEach((row: QRCodeUtil.QRCode[], i: number) => {
row.forEach((_: any, j: number) => {
if (matrix[i][j]) {
// Do not render dots under position squares
if (
!(
(i < 7 && j < 7) ||
(i > matrix.length - 8 && j < 7) ||
(i < 7 && j > matrix.length - 8)
)
) {
//if (image && i > matrix.length - 9 && j > matrix.length - 9) return;
if (
image ||
!(
i > matrixMiddleStart &&
i < matrixMiddleEnd &&
j > matrixMiddleStart &&
j < matrixMiddleEnd
)
) {
dots.push(
<circle
id={`circle-${i}-${j}`}
cx={i * cellSize + cellSize / 2}
cy={j * cellSize + cellSize / 2}
fill="var(--nestri-qr-dot-color)"
r={cellSize / 3}
/>
);
}
}
}
});
});
return dots;
}, [ecl, size, uri]);
return (
<svg
height={size}
width={size}
viewBox={`0 0 ${size} ${size}`}
style={{
width: `${size}px`,
height: `${size}px`,
}}
>
<rect fill="transparent" height={size} width={size} />
{dots()}
</svg>
);
}

View File

@@ -1,15 +1,16 @@
import { theme } from "./theme";
import { utility } from "./utility";
import { Container } from "./layout";
import { styled } from "@macaron-css/solid"
import { CSSProperties } from "@macaron-css/core";
import { ComponentProps, createMemo, For, JSX, Show, splitProps } from "solid-js";
import { Container } from "./layout";
import { utility } from "./utility";
import { ComponentProps, For, JSX, Show, splitProps } from "solid-js";
// FIXME: Make sure the focus ring goes to red when the input is invalid
export const inputStyles: CSSProperties = {
lineHeight: theme.font.lineHeight,
appearance: "none",
width: "100%",
fontSize: theme.font.size.sm,
borderRadius: theme.borderRadius,
padding: `0 ${theme.space[3]}`,
@@ -57,12 +58,7 @@ export const Root = styled("label", {
color: theme.color.gray.d900
},
danger: {
color: theme.color.red.d900,
// selectors: {
// "&:has(input)": {
// ...inputDangerFocusStyles
// }
// }
color: theme.color.gray.d900,
},
},
},
@@ -88,6 +84,12 @@ export const Input = styled("input", {
"::placeholder": {
color: theme.color.gray.d800
},
selectors: {
"[data-type='url'] &": {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}
}
// ":invalid":{
// ...inputDangerFocusStyles
// },
@@ -121,11 +123,8 @@ export const Input = styled("input", {
export const InputRadio = styled("input", {
base: {
padding: 0,
// borderRadius: 0,
WebkitAppearance: "none",
appearance: "none",
/* For iOS < 15 to remove gradient background */
backgroundColor: theme.color.background.d100,
/* Not removed via appearance */
margin: 0,
font: "inherit",
@@ -179,6 +178,7 @@ const InputLabel = styled("label", {
borderColor: theme.color.gray.d400,
color: theme.color.gray.d800,
backgroundColor: theme.color.background.d100,
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
position: "relative",
display: "flex",
alignItems: "center",
@@ -197,7 +197,8 @@ const InputLabel = styled("label", {
borderBottomLeftRadius: theme.borderRadius,
},
":hover": {
backgroundColor: theme.color.background.d200,
backgroundColor: theme.color.grayAlpha.d200,
color: theme.color.d1000.gray
},
selectors: {
"&:has(input:checked)": {
@@ -243,9 +244,9 @@ export function FormField(props: FormFieldProps) {
}
type SelectProps = {
ref: (element: HTMLInputElement) => void;
name: string;
value: any;
ref: (element: HTMLInputElement) => void;
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
onChange: JSX.EventHandler<HTMLInputElement, Event>;
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
@@ -276,7 +277,6 @@ const Badge = styled("div", {
padding: "0 6px",
fontSize: theme.font.size.xs
}
})
export function Select(props: SelectProps) {

View File

@@ -3,18 +3,41 @@ import { styled } from "@macaron-css/solid";
export const FullScreen = styled("div", {
base: {
inset: 0,
display: "flex",
position: "fixed",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: theme.color.background.d200,
textAlign: "center",
width: "100%",
justifyContent: "center"
},
variants: {
inset: {
none: {},
header: {
top: theme.headerHeight.root,
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
},
},
},
})
export const Screen = styled("div", {
base: {
display: "flex",
position: "fixed",
inset: 0,
flexDirection: "column",
alignItems: "center",
textAlign: "center",
width: "100%",
justifyContent: "center"
},
variants: {
inset: {
none: {},
header: {
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
},
},
},

View File

@@ -0,0 +1,4 @@
export { HModalRoot as Root } from './modal-root';
export { HModalPanel as Panel } from './modal-panel';
export { HModalTrigger as Trigger } from './modal-trigger';
export * as Modal from "."

View File

@@ -0,0 +1,20 @@
import { Accessor, createContext, Setter, useContext } from "solid-js";
export const ModalContext = createContext<ModalContext>();
export function useModal() {
const ctx = useContext(ModalContext);
if (!ctx) throw new Error("No modal context");
return ctx;
}
export type ModalContext = {
// core state
localId: string;
show: Accessor<boolean>;
setShow: Setter<boolean>;
onShow?: () => void;
onClose?: () => void;
closeOnBackdropClick?: boolean;
alert?: boolean;
};

View File

@@ -0,0 +1,117 @@
import { useModal } from './use-modal';
import { useModal as useModalContext } from './modal-context';
import { Accessor, ComponentProps, createEffect, createSignal, onCleanup } from 'solid-js';
export type ModalProps = Omit<ComponentProps<'dialog'>, 'open'> & {
onShow?: () => void;
onClose?: () => void;
onKeyDown?: () => void;
'bind:show': Accessor<boolean>;
closeOnBackdropClick?: boolean;
alert?: boolean;
};
export const HModalPanel = (props: ComponentProps<'dialog'>) => {
const {
activateFocusTrap,
closeModal,
deactivateFocusTrap,
showModal,
trapFocus,
wasModalBackdropClicked,
} = useModal();
const context = useModalContext();
const [panelRef, setPanelRef] = createSignal<HTMLDialogElement>();
let focusTrapRef: any = null;
createEffect(async () => {
const dialog = panelRef();
if (!dialog) return;
if (context.show()) {
// Handle iOS scroll position issue
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
let originalRAF;
if (isIOS) {
originalRAF = window.requestAnimationFrame;
window.requestAnimationFrame = () => 42;
}
await showModal(dialog);
if (isIOS && originalRAF) {
window.requestAnimationFrame = originalRAF;
}
// Setup focus trap after showing modal
focusTrapRef = await trapFocus(dialog);
activateFocusTrap(focusTrapRef);
// Trigger show callback
context.onShow?.();
} else {
await closeModal(dialog);
// Trigger close callback
context.onClose?.();
}
});
onCleanup(() => {
if (focusTrapRef) {
deactivateFocusTrap(focusTrapRef);
}
});
const handleBackdropClick = async (e: MouseEvent) => {
if (context.alert === true || context.closeOnBackdropClick === false) {
return;
}
// Only close if the backdrop itself was clicked (not content)
if (e.target instanceof HTMLDialogElement && await wasModalBackdropClicked(panelRef(), e)) {
context.setShow(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
// Prevent spacebar/enter from triggering if dialog itself is focused
if (e.target instanceof HTMLDialogElement && [' ', 'Enter'].includes(e.key)) {
e.preventDefault();
}
// Handle escape key to close modal
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
context.setShow(false);
}
// Allow other keydown handlers to run
// props.onKeyDown?.(e);
};
return (
<dialog
{...props}
id={`${context.localId}-root`}
aria-labelledby={`${context.localId}-title`}
aria-describedby={`${context.localId}-description`}
data-state={context.show() ? 'open' : 'closed'}
data-open={context.show() ? '' : undefined}
data-closed={!context.show() ? '' : undefined}
role={context.alert === true ? 'alertdialog' : 'dialog'}
onKeyDown={handleKeyDown}
ref={setPanelRef}
onClick={(e) => {
e.stopPropagation();
handleBackdropClick(e);
}}
>
{props.children}
</dialog>
);
};

View File

@@ -0,0 +1,34 @@
import { ModalContext } from './modal-context';
import { Accessor, ComponentProps, createSignal, createUniqueId, splitProps } from 'solid-js';
type ModalRootProps = {
onShow?: () => void;
onClose?: () => void;
'bind:show'?: Accessor<boolean>;
closeOnBackdropClick?: boolean;
alert?: boolean;
} & ComponentProps<'div'>;
export const HModalRoot = (props: ModalRootProps) => {
const localId = createUniqueId();
const [modalProps, divProps] = splitProps(props, [
'bind:show',
'closeOnBackdropClick',
'onShow',
'onClose',
'alert',
]);
const [defaultShowSig, setDefaultShowSig] = createSignal<boolean>(false);
const show = props["bind:show"] ?? defaultShowSig;
return (
<ModalContext.Provider value={{ ...modalProps, setShow: setDefaultShowSig, show, localId }} >
<div {...divProps}>
{props.children}
</div>
</ModalContext.Provider>
);
};

View File

@@ -0,0 +1,24 @@
import { useModal } from './modal-context';
import { ComponentProps } from 'solid-js';
export const HModalTrigger = (props: ComponentProps<"button">) => {
const modal = useModal();
const handleClick = () => {
modal.setShow((prev) => !prev);
};
return (
<button
aria-haspopup="dialog"
aria-label="Open Theme Customization Panel"
aria-expanded={modal.show()}
data-open={modal.show() ? '' : undefined}
data-closed={!modal.show() ? '' : undefined}
onClick={[handleClick, props.onClick]}
{...props}
>
{props.children}
</button>
);
};

View File

@@ -0,0 +1,131 @@
import { FocusTrap, createFocusTrap } from 'focus-trap';
export type WidthState = {
width: number | null;
};
import { enableBodyScroll, disableBodyScroll } from 'body-scroll-lock-upgrade';
export function useModal() {
/**
* Listens for animation/transition events in order to
* remove Animation-CSS-Classes after animation/transition ended.
*/
const supportClosingAnimation = (modal: HTMLDialogElement) => {
modal.dataset.closing = '';
modal.classList.add('modal-closing');
const { animationDuration, transitionDuration } = getComputedStyle(modal);
if (animationDuration !== '0s') {
modal.addEventListener(
'animationend',
(e) => {
if (e.target === modal) {
delete modal.dataset.closing;
modal.classList.remove('modal-closing');
enableBodyScroll(modal);
modal.close();
}
},
{ once: true },
);
} else if (transitionDuration !== '0s') {
modal.addEventListener(
'transitionend',
(e) => {
if (e.target === modal) {
delete modal.dataset.closing;
modal.classList.remove('modal-closing');
enableBodyScroll(modal);
modal.close();
}
},
{ once: true },
);
} else if (animationDuration === '0s' && transitionDuration === '0s') {
delete modal.dataset.closing;
modal.classList.remove('modal-closing');
enableBodyScroll(modal);
modal.close();
}
};
/**
* Traps the focus of the given Modal
* @returns FocusTrap
*/
const trapFocus = (modal: HTMLDialogElement): FocusTrap => {
return createFocusTrap(modal, { escapeDeactivates: false });
};
const activateFocusTrap = (focusTrap: FocusTrap | null) => {
try {
focusTrap?.activate();
} catch {
// Activating the focus trap throws if no tabbable elements are inside the container.
// If this is the case we are fine with not activating the focus trap.
// That's why we ignore the thrown error.
}
};
const deactivateFocusTrap = (focusTrap: FocusTrap | null) => {
focusTrap?.deactivate();
focusTrap = null;
};
/**
* Shows the given Modal.
* Applies a CSS-Class to animate the modal-showing.
* Calls the given callback that is executed after the Modal has been opened.
*/
const showModal = async (modal: HTMLDialogElement) => {
disableBodyScroll(modal, { reserveScrollBarGap: true });
modal.showModal();
};
/**
* Closes the given Modal.
* Applies a CSS-Class to animate the Modal-closing.
* Calls the given callback that is executed after the Modal has been closed.
*/
const closeModal = async (modal: HTMLDialogElement) => {
await supportClosingAnimation(modal);
};
/**
* Determines if the backdrop of the Modal has been clicked.
*/
const wasModalBackdropClicked = (modal: HTMLDialogElement | undefined, clickEvent: MouseEvent): boolean => {
if (!modal) {
return false;
}
const rect = modal.getBoundingClientRect();
const wasBackdropClicked =
rect.left > clickEvent.clientX ||
rect.right < clickEvent.clientX ||
rect.top > clickEvent.clientY ||
rect.bottom < clickEvent.clientY;
/**
* If the inside focusable elements are not prevented, such as a button it will also fire a click event.
*
* Hitting the enter or space keys on a button inside of the dialog for example, will fire a "pointer" event. In reality, it fires our onClick$ handler because we have not prevented the default behavior.
*
* This is why we check if the pointerId is -1.
**/
return (clickEvent as PointerEvent).pointerId === -1 ? false : wasBackdropClicked;
};
return {
trapFocus,
activateFocusTrap,
deactivateFocusTrap,
showModal,
closeModal,
wasModalBackdropClicked,
supportClosingAnimation,
};
}

View File

@@ -89,7 +89,7 @@ const font = {
mono_2xl: "1.375rem",
"2xl": "1.5rem",
"3xl": "1.875rem",
"4xl": "2.25rem",
"4xl": "2rem",
"5xl": "3rem",
"6xl": "3.75rem",
"7xl": "4.5rem",
@@ -218,13 +218,16 @@ const light = (() => {
const brand = "#FF4F01"
const background = {
d100: '#f5f5f5',
d200: 'oklch(from #f5f5f5 calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h)'
d100: 'rgba(255,255,255,0.8)',
d200: '#f4f5f6',
};
const headerGradient = "linear-gradient(rgba(66, 144, 243, 0.2) 0%, rgba(206, 127, 243, 0.1) 52.58%, rgba(248, 236, 215, 0) 100%)"
const contrastFg = '#ffffff';
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(0,0,0,0.16)`;
const focusColor = blue.d700
const hoverColor = "hsl(0,0%,22%)"
const text = {
primary: {
@@ -257,7 +260,9 @@ const light = (() => {
focusColor,
d1000,
brand,
text
text,
headerGradient,
hoverColor
};
})()
@@ -380,13 +385,15 @@ const dark = (() => {
const brand = "#FF4F01"
const background = {
d200: '#171717',
d100: "oklch(from #171717 calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h)"
d100: "rgba(255,255,255,0.04)",
d200: 'rgb(19,21,23)',
};
const contrastFg = '#ffffff';
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(255,255,255,0.24)`;
const focusColor = blue.d900
const hoverColor = "hsl(0,0%,80%)"
const headerGradient = "linear-gradient(rgba(66, 144, 243, 0.2) 0%, rgba(239, 148, 225, 0.1) 50%, rgba(191, 124, 7, 0) 100%)"
const text = {
primary: {
@@ -419,7 +426,9 @@ const dark = (() => {
focusColor,
d1000,
text,
brand
brand,
headerGradient,
hoverColor
};
})()