feat: Add qwik-react (#103)

This adds the following pages:

The landing page (/)
The pricing page (/pricing)
The contact page (/contact)
The changelog page (/changelog)
Terms Of Service page (/terms)
Privacy Policy (/privacy)
This commit is contained in:
Wanjohi
2024-08-30 16:19:58 +03:00
committed by GitHub
parent d13d3dc5d8
commit 73cec51728
102 changed files with 5096 additions and 105 deletions

View File

@@ -1,10 +1,16 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@nestri/eslint-config/qwik.js"],
extends: [
"@nestri/eslint-config/qwik.js",
],
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.lint.json",
tsconfigRootDir: __dirname,
},
project: ["./tsconfig.json"],
ecmaVersion: 2021,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
}
};

View File

@@ -2,46 +2,51 @@
"name": "@nestri/ui",
"version": "0.0.0",
"private": true,
"sideEffects": false,
"files": [
"tailwind.config.ts",
"postcss.config.js",
"globals.css"
],
"exports": {
".": "./src/index.ts",
"./react": "./src/react/index.ts",
"./tailwind.config": "./tailwind.config.js",
"./globals.css": "./globals.css",
"./postcss": "./post.config.js"
"./postcss.config": "./postcss.config.js",
"./tailwind.config": "./tailwind.config.js",
"./image": "./src/image/index.ts",
"./design": "./src/design/index.ts"
},
"files": [
"tailwind.config.js",
"post.config.js",
"globals.css",
"postcss.config.js"
],
"scripts": {
"lint": "eslint . --max-warnings 0"
},
"devDependencies": {
"@builder.io/qwik": "^1.8.0",
"@builder.io/qwik-city": "^1.8.0",
"@builder.io/qwik-react": "^0.5.5",
"@builder.io/qwik-react": "0.5.0",
"@fontsource/bricolage-grotesque": "^5.0.7",
"@fontsource/geist-sans": "^5.0.3",
"@nestri/eslint-config": "*",
"@nestri/typescript-config": "*",
"@nestri/eslint-config": "workspace:*",
"@nestri/typescript-config": "workspace:*",
"@nestri/core": "workspace:*",
"@turbo/gen": "^1.12.4",
"@types/eslint": "^9.6.1",
"@types/eslint": "^8.56.5",
"@types/node": "^20.11.24",
"@types/nprogress": "^0.2.3",
"@types/react": "^18.3.4",
"@types/react-dom": "^18.3.0",
"@types/react": "^18.2.28",
"@types/react-dom": "^18.2.13",
"autoprefixer": "^10.4.20",
"eslint": "^9.9.1",
"framer-motion": "^11.3.31",
"clsx": "^2.1.1",
"eslint": "^8.57.0",
"framer-motion": "^11.3.24",
"nprogress": "^0.2.0",
"postcss": "^8.4.41",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.5.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-wrap-balancer": "^1.1.1",
"tailwind-merge": "^2.4.0",
"tailwind-variants": "^0.2.1",
"tailwindcss": "^3.4.10",
"tailwindcss": "^3.4.9",
"typescript": "^5.3.3"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,178 @@
// The solid and outlined variants are based on CSS Pro 3D Buttons code (https://csspro.com/css-3d-buttons/)
import { tv } from "tailwind-variants";
export type ButtonVariantProps = {
variant?: keyof typeof buttonVariants | undefined;
intent?: keyof typeof solid.variants.intent;
size?: keyof typeof baseButton.variants.size;
};
export type ButtonVariantIconProps = {
type?: keyof typeof buttonIcon.variants.type;
size?: keyof typeof buttonIcon.variants.size;
};
const baseButton = tv({
base: "group font-title flex justify-center select-none gap-1.5 items-center rounded-lg outline-2 outline-offset-2 focus-visible:outline outline-primary-6000 disabled:text-gray-400 disabled:border disabled:border-gray-300 dark:disabled:bg-gray-500/10 disabled:bg-gray-200 disabled:shadow-none disabled:hover:brightness-100 dark:disabled:text-gray-700 dark:disabled:shadow-none disabled:cursor-not-allowed dark:disabled:border dark:disabled:border-gray-700", // dark:disabled:[background-image:none]
variants: {
size: {
xs: "text-sm h-7 px-3",
sm: "text-sm h-8 px-3.5",
md: "text-base h-9 px-4",
lg: "text-base h-10 px-5",
xl: "text-lg h-12 px-6",
},
iconOnlyButtonSize: {
xs: "size-7",
sm: "size-8",
md: "size-9",
lg: "size-10",
xl: "size-12",
},
defaultVariants: {
intent: "primary",
size: "md",
},
},
});
const link = tv({
// extend: baseButton,
// base: "group transition-shadows relative ml-1.5 font-medium shadow-[inset_0_-0.2em_0_0_theme(colors.primary.200)] dark:shadow-[inset_0_-0.2em_0_0_theme(colors.primary.600)] duration-300 ease-out hover:shadow-[inset_0_-1em_0_0_theme(colors.primary.200)] dark:hover:shadow-[inset_0_-1em_0_0_theme(colors.primary.600)] text-primary-950 font-medium dark:text-primary-50", //"relative text-primary-950 font-medium dark:text-primary-50 hover:after:w-[calc(100%+2px)] focus:after:w-[calc(100%+2px)] px-1 after:-bottom-1 transition-all duration-[.2s] ease-[cubic-bezier(.165,.84,.44,1)] after:w-0 after:h-0.5 after:bg-primary-950 dark:after:bg-primary-50 after:absolute after:left-0 after:transition-[width] after:duration-[.2s] focus:shadow-none outline-none"
base: "group relative ml-1.5 font-medium border-b-2 border-primary-950 dark:border-primary-50 hover:border-primary-950/60 dark:hover:border-primary-50/60 hover:text-primary-950/70 dark:hover:text-primary-50/70 text-primary-950 font-medium dark:text-primary-50", //"relative text-primary-950 font-medium dark:text-primary-50 hover:after:w-[calc(100%+2px)] focus:after:w-[calc(100%+2px)] px-1 after:-bottom-1 transition-all duration-[.2s] ease-[cubic-bezier(.165,.84,.44,1)] after:w-0 after:h-0.5 after:bg-primary-950 dark:after:bg-primary-50 after:absolute after:left-0 after:transition-[width] after:duration-[.2s] focus:shadow-none outline-none"
variants: {
size: {
xs: "text-sm h-7 px-3",
sm: "text-sm h-8 px-3.5",
md: "text-base h-9 px-4",
lg: "text-base h-10 px-5",
xl: "text-lg h-12 px-6",
},
iconOnlyButtonSize: {
xs: "size-7",
sm: "size-8",
md: "size-9",
lg: "size-10",
xl: "size-12",
},
defaultVariants: {
intent: "primary",
size: "md",
},
},
});
const solid = tv({
extend: baseButton,
base: "bg-gradient-to-b [box-shadow:rgba(255,255,255,0.25)_0px_1px_0px_0px_inset,var(--btn-border-color)_0px_0px_0px_1px] text-white hover:brightness-[1.1] transition-[filter] duration-150 ease-in-out active:brightness-95 dark:border-t dark:shadow-white/10 disabled:from-gray-200 disabled:to-gray-200 dark:disabled:text-gray-400 dark:disabled:from-gray-800 dark:disabled:to-gray-800",
variants: {
intent: {
primary:
"from-primary-500 to-primary-600 [--btn-border-color:theme(colors.primary.600)] dark:border-primary-500/75",
secondary:
"from-secondary-500 to-secondary-600 [--btn-border-color:theme(colors.secondary.700)] dark:border-secondary-400/75",
accent: "from-accent-500 to-accent-600 [--btn-border-color:theme(colors.accent.700)] dark:border-accent-400/75",
danger: "from-danger-500 to-danger-600 [--btn-border-color:theme(colors.danger.700)] dark:border-danger-400/75",
info: "from-info-500 to-info-600 [--btn-border-color:theme(colors.info.700)] dark:border-info-400/75",
success:
"from-success-500 to-success-600 [--btn-border-color:theme(colors.success.700)] dark:border-success-400/75",
warning:
"from-warning-400 to-warning-500 text-warning-950 [--btn-border-color:theme(colors.warning.600)] dark:border-warning-300",
gray: "from-gray-500 to-gray-600 [--btn-border-color:theme(colors.gray.700)] dark:border-gray-500",
neutral:
"bg-gray-900 [background-image:radial-gradient(76%_151%_at_52%_-52%,rgba(255,255,255,0.5)_0%,transparent_100%)] [box-shadow:rgba(255,255,255,0.3)_0px_1px_0px_0px_inset,theme(colors.gray.950)_0px_0px_0px_1px] hover:brightness-125 dark:bg-white dark:text-gray-950 dark:border-gray-300",
},
},
});
const outlined = tv({
extend: baseButton,
base: "[--outline-radial-opacity:0.6] dark:[background-image:none] [--inner-border-color:1] dark:[--inner-border-color:0] dark:[--outline-radial-opacity:0.2] [background-image:radial-gradient(76%_151%_at_52%_-52%,rgba(255,255,255,var(--outline-radial-opacity))_0%,transparent_100%)] [box-shadow:rgba(255,255,255,var(--inner-border-color))_0px_1px_0px_0px_inset,var(--btn-border-color)_0px_0px_0px_1px,0px_1px_2px_rgba(0,0,0,0.1)] hover:brightness-[0.98] active:brightness-100 transtion-[filter] ease-in-out duration-150",
variants: {
intent: {
primary:
"[--btn-border-color:theme(colors.primary.200)] dark:[--btn-border-color:theme(colors.primary.500/0.3)] text-primary-800 bg-primary-50 dark:text-primary-300 dark:bg-primary-500/5 dark:hover:bg-primary-500/10 dark:active:bg-primary-500/5",
secondary:
"[--btn-border-color:theme(colors.secondary.200)] dark:[--btn-border-color:theme(colors.secondary.500/0.3)] text-secondary-800 bg-secondary-50 dark:text-secondary-300 dark:bg-secondary-500/5 dark:hover:bg-secondary-500/10 dark:active:bg-secondary-500/5",
accent: "[--btn-border-color:theme(colors.accent.200)] dark:[--btn-border-color:theme(colors.accent.500/0.3)] text-accent-800 bg-accent-50 dark:text-accent-300 dark:bg-accent-500/5 dark:hover:bg-accent-500/10 dark:active:bg-accent-500/5",
danger: "[--btn-border-color:theme(colors.danger.200)] dark:[--btn-border-color:theme(colors.danger.500/0.3)] text-danger-800 bg-danger-50 dark:text-danger-300 dark:bg-danger-500/5 dark:hover:bg-danger-500/10 dark:active:bg-danger-500/5",
info: "[--btn-border-color:theme(colors.info.200)] dark:[--btn-border-color:theme(colors.info.500/0.3)] text-info-800 bg-info-50 dark:text-info-300 dark:bg-info-500/5 dark:hover:bg-info-500/10 dark:active:bg-info-500/5",
success:
"[--btn-border-color:theme(colors.success.200)] dark:[--btn-border-color:theme(colors.success.500/0.3)] text-success-800 bg-success-100 dark:text-success-300 dark:bg-success-500/5 dark:hover:bg-success-500/10 dark:active:bg-success-500/5",
warning:
"[--btn-border-color:theme(colors.warning.200)] dark:[--btn-border-color:theme(colors.warning.500/0.3)] text-warning-800 bg-warning-50 dark:text-warning-300 dark:bg-warning-500/5 dark:hover:bg-warning-500/10 dark:active:bg-warning-500/5",
gray: "[--btn-border-color:theme(colors.gray.200)] dark:[--btn-border-color:theme(colors.gray.500/0.3)] text-gray-800 bg-gray-50 dark:text-gray-300 dark:bg-gray-500/5 dark:hover:bg-gray-500/10 dark:active:bg-gray-500/5",
neutral:
"[--btn-border-color:theme(colors.gray.300)] dark:[--btn-border-color:theme(colors.gray.700)] text-gray-800 bg-gray-100 dark:text-white dark:bg-gray-500/5 dark:hover:bg-gray-500/10 dark:active:bg-gray-500/5",
},
},
});
const soft = tv({
extend: baseButton,
variants: {
intent: {
primary:
"text-primary-700 bg-primary-100 hover:bg-primary-200/75 active:bg-primary-100 dark:text-primary-300 dark:bg-primary-500/10 dark:hover:bg-primary-500/15 dark:active:bg-primary-500/10",
secondary:
"text-secondary-700 bg-secondary-100 hover:bg-secondary-200/75 active:bg-secondary-100 dark:text-secondary-300 dark:bg-secondary-500/10 dark:hover:bg-secondary-500/15 dark:active:bg-secondary-500/10",
accent: "text-accent-700 bg-accent-100 hover:bg-accent-200/75 active:bg-accent-100 dark:text-accent-300 dark:bg-accent-500/10 dark:hover:bg-accent-500/15 dark:active:bg-accent-500/10",
danger: "text-danger-700 bg-danger-100 hover:bg-danger-200/75 active:bg-danger-100 dark:text-danger-300 dark:bg-danger-500/10 dark:hover:bg-danger-500/15 dark:active:bg-danger-500/10",
info: "text-info-700 bg-info-100 hover:bg-info-200/75 active:bg-info-100 dark:text-info-300 dark:bg-info-500/10 dark:hover:bg-info-500/15 dark:active:bg-info-500/10",
success:
"text-success-700 bg-success-100 hover:bg-success-200/75 active:bg-success-100 dark:text-success-300 dark:bg-success-500/10 dark:hover:bg-success-500/15 dark:active:bg-success-500/10",
warning:
"text-warning-700 bg-warning-100 hover:bg-warning-200/75 active:bg-warning-100 dark:text-warning-300 dark:bg-warning-500/10 dark:hover:bg-warning-500/15 dark:active:bg-warning-500/10",
gray: "text-gray-800 bg-gray-100 hover:bg-gray-200/75 active:bg-gray-100 dark:text-gray-300 dark:bg-gray-500/10 dark:hover:bg-gray-500/15 dark:active:bg-gray-500/10",
neutral:
"text-gray-950 bg-gray-100 hover:bg-gray-950 hover:text-white active:text-white active:bg-gray-900 dark:text-gray-300 dark:bg-gray-500/10 dark:hover:bg-white dark:hover:text-gray-950 dark:active:bg-gray-200 dark:active:text-gray-950",
},
},
});
const ghost = tv({
extend: baseButton,
variants: {
intent: {
primary:
"hover:bg-gray-100 active:bg-primary-200/75 dark:hover:bg-gray-800 dark:active:bg-primary-500/15",
secondary:
"text-secondary-600 hover:bg-secondary-100 active:bg-secondary-200/75 dark:text-secondary-400 dark:hover:bg-secondary-500/10 dark:active:bg-secondary-500/15",
accent: "text-accent-600 hover:bg-accent-100 active:bg-accent-200/75 dark:text-accent-400 dark:hover:bg-accent-500/10 dark:active:bg-accent-500/15",
danger: "text-danger-600 hover:bg-danger-100 active:bg-danger-200/75 dark:text-danger-400 dark:hover:bg-danger-500/10 dark:active:bg-danger-500/15",
info: "text-info-600 hover:bg-info-100 active:bg-info-200/75 dark:text-info-400 dark:hover:bg-info-500/10 dark:active:bg-info-500/15",
success:
"text-success-600 hover:bg-success-100 active:bg-success-200/75 dark:text-success-400 dark:hover:bg-success-500/10 dark:active:bg-success-500/15",
warning:
"text-warning-600 hover:bg-warning-100 active:bg-warning-200/75 dark:text-warning-400 dark:hover:bg-warning-500/10 dark:active:bg-warning-500/15",
gray: "text-gray-800 hover:bg-gray-100 active:bg-gray-200/75 dark:text-gray-300 dark:hover:bg-gray-500/10 dark:active:bg-gray-500/15",
neutral:
"text-gray-950 hover:bg-gray-950 hover:text-white active:text-white active:bg-gray-900 dark:text-white dark:hover:bg-white dark:hover:text-gray-950 dark:active:bg-gray-200 dark:active:text-gray-950",
},
},
});
export const buttonIcon = tv({
variants: {
type: {
leading: "-ml-1",
trailing: "-mr-1",
only: "m-auto",
},
size: {
xs: "size-3.5",
sm: "size-4",
md: "size-[1.125rem]",
lg: "size-5",
xl: "size-6",
},
},
});
export const buttonVariants = {
solid,
outlined,
soft,
ghost,
link
};

View File

@@ -0,0 +1,215 @@
import { tv } from "tailwind-variants";
export const form = tv({
slots: {
label: "text-nowrap text-[--title-text-color]",
input: "[--btn-radius:lg] w-full px-[--input-px] bg-transparent peer transition-[outline] placeholder-[--placeholder-text-color] text-[--title-text-color] rounded-[--btn-radius] disabled:opacity-50",
message: "mt-2 text-[--caption-text-color]",
icon: "absolute inset-y-0 my-auto text-[--placeholder-text-color] pointer-events-none",
field: "relative group *:has-[:disabled]:opacity-50 *:has-[:disabled]:pointer-events-none data-[invalid]:[--caption-text-color:theme(colors.danger.600)] dark:data-[invalid]:[--caption-text-color:theme(colors.danger.400)] data-[valid]:[--caption-text-color:theme(colors.success.600)] dark:data-[valid]:[--caption-text-color:theme(colors.success.400)]",
textarea: "py-[calc(var(--input-px)/1.5)] h-auto",
},
variants: {
variant: {
outlined: {
input: "outline-2 focus:outline-primary-600 -outline-offset-1 focus:outline border data-[invalid]:border-danger-600 focus:data-[invalid]:outline-danger-600 dark:data-[invalid]:border-danger-500 dark:focus:data-[invalid]:outline-danger-500 data-[valid]:border-success-600 focus:data-[valid]:outline-success-600 dark:data-[valid]:border-success-500 dark:focus:data-[valid]:outline-success-500",
},
soft: {
input: "outline-none bg-[--ui-soft-bg] focus:brightness-95 dark:focus:brightness-105 data-[invalid]:[--ui-soft-bg:theme(colors.danger.100)] dark:data-[invalid]:[--ui-soft-bg:theme(colors.danger.800/0.25)] data-[valid]:[--ui-soft-bg:theme(colors.success.100)] dark:data-[valid]:[--ui-soft-bg:theme(colors.success.800/0.25)]",
},
mixed: {
input: "placeholder-gray-950/50 dark:placeholder-gray-50/50 shadow-sm hover:shadow-lg dark:hover:shadow-sm shadow-gray-950/50 dark:shadow-gray-50 outline-2 focus:outline-primary-600 focus:outline -outline-offset-1 border border-primary-950 dark:border-primary-50 bg-gray-100 dark:bg-gray-800 data-[invalid]:border-2 data-[invalid]:border-danger-600 focus:data-[invalid]:outline-danger-600 dark:data-[invalid]:border-danger-500 dark:focus:data-[invalid]:outline-danger-500 data-[valid]:border-success-600 focus:data-[valid]:outline-success-600 dark:data-[valid]:border-success-500 dark:focus:data-[valid]:outline-success-500",
},
plain: {
input: "rounded-none px-0 outline-none bg-transparent invalid:text-danger-600 dark:invalid:text-danger-400",
},
bottomOutlined: {
input: "rounded-none transition-[border] px-0 focus:outline-none border-b focus:border-b-2 focus:border-primary-600 data-[invalid]:border-danger-400 dark:data-[invalid]:border-danger-600 data-[valid]:border-success-400 dark:data-[valid]:border-success-600",
},
},
size: {
xs: {
message: "text-xs",
},
sm: {
label: "text-sm",
message: "text-sm",
input: "text-sm h-8 [--input-px:theme(spacing[2.5])]",
field: "[--input-px:theme(spacing[2.5])]",
},
md: {
label: "text-base",
message: "text-base",
input: "text-sm h-9 [--input-px:theme(spacing[3])]",
field: "[--input-px:theme(spacing[3])]",
},
lg: {
label: "text-lg",
input: "text-base h-10 [--input-px:theme(spacing[4])]",
field: "[--input-px:theme(spacing[4])]",
},
xl: {
label: "text-xl",
input: "text-base h-12 [--input-px:theme(spacing[5])]",
field: "[--input-px:theme(spacing[5])]",
},
},
fancy: {
true: {
input: "shadow-inner shadow-gray-950/5 dark:shadow-gray-950/35",
}
},
floating: {
true: {
label: "absolute block inset-y-0 text-base left-[--input-px] h-fit text-nowrap my-auto text-[--caption-text-color] pointer-events-none transition duration-150 scale-[.8] origin-top-left peer-placeholder-shown:scale-100 peer-focus:scale-[.8] peer-placeholder-shown:translate-y-0",
},
},
asTextarea: {
true: {
label: "top-[calc(var(--input-px)/1.5)] mt-0",
},
},
},
compoundVariants: [
{
floating: true,
variant: ["bottomOutlined", "plain"],
class: {
input: "px-0",
label: "left-0",
},
},
{
floating: true,
variant: ["outlined", "soft", "bottomOutlined", "mixed"],
size: "xl",
class: {
field: "[--input-px:theme(spacing[2.5])]",
input: "pt-3.5 h-14",
label: "-translate-y-2.5 peer-focus:-translate-y-2.5",
},
},
{
floating: true,
variant: ["soft", "bottomOutlined"],
size: "lg",
class: {
input: "pt-4 h-12",
label: "-translate-y-2 peer-focus:-translate-y-2",
},
},
{
floating: true,
variant: ["outlined", "mixed"],
size: "lg",
class: {
input: "h-12",
label: "-translate-y-[21.5px] peer-focus:-translate-y-[21.5px]",
},
},
{
floating: true,
variant: ["outlined", "bottomOutlined", "mixed"],
size: "md",
class: {
input: "h-9",
label: "-translate-y-[15.5px] peer-focus:-translate-y-[15.5px]",
},
},
{
floating: true,
variant: ["outlined", "bottomOutlined", "mixed"],
size: "sm",
class: {
input: "h-8",
label: "text-base -translate-y-[13.5px] peer-focus:-translate-y-[13.5px] peer-placeholder-shown:text-sm peer-focus:text-base",
},
},
{
floating: true,
size: ["sm", "md", "lg"],
variant: ["outlined", "mixed"],
class: {
label: "peer-placeholder-shown:before:scale-x-0 peer-focus:before:scale-x-100 before:scale-x-100 before:absolute peer-placeholder-shown:before:transition-none before:transition-none peer-focus:before:transition peer-focus:before:delay-[.01s] before:duration-500 before:-z-[1] peer-placeholder-shown:before:-top-[9px] before:top-[48%] peer-focus:before:top-[44%] before:-inset-x-1 before:h-0.5 before:my-auto group-has-[:focus]:before:h-[3px] before:bg-white dark:before:bg-[--ui-bg]",
},
},
{
floating: true,
variant: ["outlined", "mixed"],
class: {
label: "translate-x-px",
},
},
{
floating: true,
asTextarea: true,
size: ["lg", "xl"],
variant: "bottomOutlined",
class: {
input: "pt-0",
},
},
{
variant: "plain",
class: {
input: "py-0",
},
},
{
floating: true,
asTextarea: true,
size: ["xl"],
class: {
input: "pt-7",
label: "-translate-y-1 peer-focus:-translate-y-1",
},
},
{
floating: true,
asTextarea: true,
size: ["lg"],
class: {
input: "pt-6",
label: "-translate-y-1 peer-focus:-translate-y-1 before:hidden",
},
},
{
floating: true,
asTextarea: true,
size: ["md"],
variant: ["outlined", "bottomOutlined", "mixed"],
class: {
label: "-translate-y-[17.5px] peer-focus:-translate-y-[17.5px]",
},
},
{
floating: true,
asTextarea: true,
size: ["sm"],
variant: ["outlined", "bottomOutlined", "mixed"],
class: {
label: "-translate-y-4 peer-focus:-translate-y-4",
},
},
],
});
export type FormProps = {
variant?: keyof typeof form.variants.variant;
size?: keyof typeof form.variants.size;
floating?: boolean;
asTextarea?: boolean;
};
export type InputProps = Omit<FormProps, "asTextarea"> & {
size?: Exclude<FormProps["size"], "xs">;
fancy?: boolean;
};
export type LabelProps = FormProps & {
size?: Exclude<FormProps["size"], "xs">;
};
export type MessageProps = {
size?: Exclude<FormProps["size"], "lg" | "xl">;
};

View File

@@ -0,0 +1,4 @@
export * from "./button-variants"
export * from "./typography"
export * from "./utils"
export * from "./form"

View File

@@ -0,0 +1,401 @@
import { tv } from "tailwind-variants";
export const base = tv({
variants: {
weight: {
black: "font-black",
bold: "font-bold",
semibold: "font-semibold",
medium: "font-medium",
normal: "font-normal",
},
align: {
left: "text-left",
center: "text-center",
right: "text-right",
},
},
defaultVariants: {
size: "xl",
weight: "normal",
},
});
export const caption = tv(
{
extend: base,
base: "text-gray-500",
variants: {
size: {
xs: "text-xs",
sm: "text-sm",
base: "text-base",
},
neutral: {
true: "text-gray-950 dark:text-white",
},
},
defaultVariants: {
size: "sm",
weight: "normal",
},
},
{
responsiveVariants: ["sm", "md", "lg", "xl", "2xl"],
},
);
export const text = tv(
{
extend: base,
base: "text-gray-700",
variants: {
size: {
sm: "text-sm",
base: "text-base",
lg: "text-lg",
xl: "text-xl",
},
neutral: {
true: "text-gray-950 dark:text-white",
},
},
defaultVariants: {
size: "base",
weight: "normal",
},
},
{
responsiveVariants: ["sm", "md", "lg", "xl", "2xl"],
},
);
export const list = tv(
{
extend: text,
base: "list-outside pl-4",
variants: {
type: {
disc: "list-disc",
decimal: "list-decimal",
none: "list-none",
},
inside: {
true: "list-outside pl-0",
},
},
defaultVariants: {
size: "base",
type: "disc",
weight: "normal",
inside: false,
},
},
{
responsiveVariants: ["sm", "md", "lg", "xl", "2xl"],
},
);
export const link = tv(
{
extend: base,
base: "transition",
variants: {
size: {
xs: "text-xs",
sm: "text-sm",
base: "text-base",
lg: "text-lg",
xl: "text-xl",
},
intent: {
primary:
"text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-500",
secondary:
"text-secondary-600 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-500",
accent: "text-accent-600 hover:text-accent-700 dark:text-accent-400 dark:hover:text-accent-500",
info: "text-info-600 hover:text-info-700 dark:text-info-400 dark:hover:text-info-500",
danger: "text-danger-600 hover:text-danger-700 dark:text-danger-400 dark:hover:text-danger-500",
success:
"text-success-600 hover:text-success-700 dark:text-success-400 dark:hover:text-success-500",
warning:
"text-warning-700 hover:text-warning-600 dark:text-warning-400 dark:hover:text-warning-500",
gray: "text-gray-700",
neutral:
"text-gray-950 hover:text-gray-800 dark:text-white dark:hover:text-gray-200",
},
variant: {
plain: "",
underlined: "underline",
ghost: "hover:underline",
animated:
"relative before:absolute before:inset-x-0 before:bottom-0 before:h-px before:scale-x-0 before:origin-right hover:before:origin-left hover:before:scale-x-100 before:transition before:duration-200",
},
visited: {
true: "visited:text-accent-600 dark:visited:text-accent-400",
},
},
compoundVariants: [
{
variant: ["plain", "ghost", "underlined"],
intent: "gray",
class: "hover:text-gray-950 dark:hover:text-white",
},
{
variant: "animated",
intent: "primary",
class: "before:bg-primary-600/50 dark:before:bg-primary-400/50",
},
{
variant: "animated",
intent: "info",
class: "before:bg-info-600/50 dark:before:bg-info-400/50",
},
{
variant: "animated",
intent: "neutral",
class: "before:bg-gray-950/50 dark:before:bg-white/50",
},
{
variant: "animated",
intent: "gray",
class: "before:bg-gray-600/50 dark:before:bg-gray-400/50",
},
{
variant: "animated",
intent: "secondary",
class: "before:bg-secondary-600/50 dark:before:bg-secondary-400/50",
},
{
variant: "animated",
intent: "accent",
class: "before:bg-accent-600/50 dark:before:bg-accent-400/50",
},
{
variant: "animated",
intent: "danger",
class: "before:bg-danger-600/50 dark:before:bg-danger-400/50",
},
{
variant: "animated",
intent: "success",
class: "before:bg-success-600/50 dark:before:bg-success-400/50",
},
{
variant: "animated",
intent: "warning",
class: "before:bg-warning-600/50 dark:before:bg-warning-400/50",
},
{
variant: "underlined",
intent: "primary",
class: "decoration-primary-600/50 dark:decoration-primary-400/50",
},
{
variant: "underlined",
intent: "info",
class: "decoration-info-600/50 dark:decoration-info-400/50",
},
{
variant: "underlined",
intent: "gray",
class: "decoration-gray-600/50 dark:decoration-gray-400/50",
},
{
variant: "underlined",
intent: "neutral",
class: "decoration-gray-950/25 dark:decoration-white/25",
},
{
variant: "underlined",
intent: "secondary",
class: "decoration-secondary-600/50 dark:decoration-secondary-400/50",
},
{
variant: "underlined",
intent: "accent",
class: "decoration-accent-600/50 dark:decoration-accent-400/50",
},
{
variant: "underlined",
intent: "danger",
class: "decoration-danger-600/50 dark:decoration-danger-400/50",
},
{
variant: "underlined",
intent: "success",
class: "decoration-success-600/50 dark:decoration-success-400/50",
},
{
variant: "underlined",
intent: "warning",
class: "decoration-warning-600/50 dark:decoration-warning-400/50",
},
],
defaultVariants: {
intent: "primary",
variant: "ghost",
size: "base",
},
},
{
responsiveVariants: ["sm", "md", "lg", "xl", "2xl"],
},
);
export const display = tv(
{
extend: base,
base: "block text-gray-950 dark:text-gray-50",
variants: {
size: {
"4xl": "text-4xl",
"5xl": "text-5xl",
"6xl": "text-6xl",
"7xl": "text-7xl",
"8xl": "text-8xl",
"9xl": "text-9xl",
},
},
defaultVariants: {
size: "6xl",
weight: "bold",
},
},
{
responsiveVariants: ["sm", "md", "lg", "xl", "2xl"],
},
);
export const title = tv(
{
extend: base,
base: "block text-gray-950",
variants: {
size: {
base: "text-base",
lg: "text-lg",
xl: "text-xl",
"2xl": "text-2xl",
"3xl": "text-3xl",
},
},
defaultVariants: {
size: "xl",
weight: "semibold",
},
},
{
responsiveVariants: ["sm", "md", "lg", "xl", "2xl"],
},
);
export const codeTheme = tv({
base: "text-sm inline-block border rounded-md py-px px-1",
variants: {
intent: {
primary:
"bg-primary-50 text-primary-600 dark:text-primary-300 border-primary-200 dark:border-primary-500/20 dark:bg-primary-500/5",
secondary:
"bg-secondary-50 text-secondary-600 dark:text-secondary-300 border-secondary-200 dark:border-secondary-500/20 dark:bg-secondary-500/5",
accent: "bg-accent-50 text-accent-600 dark:text-accent-300 border-accent-200 dark:border-accent-500/20 dark:bg-accent-500/5",
gray: "bg-gray-50 text-gray-700 dark:border-gray-500/20 dark:bg-gray-500/5 dark:border-gray-500/20",
neutral: "bg-gray-50 text-gray-950 dark:text-white dark:bg-gray-500/5 dark:border-gray-500/20",
},
},
defaultVariants: {
intent: "gray",
},
});
export const kbdTheme = tv({
base: "inline-flex items-center justify-center text-gray-800 dark:text-white h-5 text-[11px] min-w-5 px-1.5 rounded font-sans bg-gray-100 dark:bg-white/10 ring-1 border-b border-t border-t-white border-b-gray-200 dark:border-t-transparent dark:border-b-gray-950 ring-gray-300 dark:ring-white/15",
});
export type CodeThemeProps = {
intent?: keyof typeof codeTheme.variants.intent;
};
export type Weight = keyof typeof base.variants.weight;
export type Align = keyof typeof base.variants.align;
type BaseTextProps = {
weight?: Weight;
align?: Align;
};
export type CaptionProps = BaseTextProps & {
size?: keyof typeof caption.variants.size;
neutral?: boolean;
};
export type TextProps = BaseTextProps & {
size?: keyof typeof text.variants.size;
neutral?: boolean;
};
export type ListProps = BaseTextProps & {
size?: keyof typeof text.variants.size;
type?: keyof typeof list.variants.type;
inside?: boolean;
neutral?: boolean;
};
export type LinkProps = BaseTextProps & {
size?: keyof typeof text.variants.size | keyof typeof link.variants.size;
variant?: keyof typeof link.variants.variant;
intent?: keyof typeof link.variants.intent;
visited?: boolean;
};
export type TitleProps = BaseTextProps & {
size?: keyof typeof title.variants.size;
};
export type TitleSizeProp =
| TitleProps["size"]
| {
initial?: TitleProps["size"];
sm?: TitleProps["size"];
md?: TitleProps["size"];
lg?: TitleProps["size"];
xl?: TitleProps["size"];
xxl?: TitleProps["size"];
};
export type TextSizeProp =
| TextProps["size"]
| {
initial?: TextProps["size"];
sm?: TextProps["size"];
md?: TextProps["size"];
lg?: TextProps["size"];
xl?: TextProps["size"];
xxl?: TextProps["size"];
};
export type DisplayProps = BaseTextProps & {
size?: keyof typeof display.variants.size;
};
export type TextWeightProp =
| Weight
| {
initial?: Weight;
sm?: Weight;
md?: Weight;
lg?: Weight;
xl?: Weight;
xxl?: Weight;
};
export type TextAlignProp =
| Align
| {
initial?: Align;
sm?: Align;
md?: Align;
lg?: Align;
xl?: Align;
xxl?: Align;
};

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

17
packages/ui/src/fonts.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { component$, Slot } from "@builder.io/qwik";
// import SansNormal from "@fontsource/geist-sans/400.css?inline"
import "@fontsource/geist-sans/400.css"
import "@fontsource/geist-sans/500.css"
import "@fontsource/geist-sans/600.css"
import "@fontsource/geist-sans/700.css"
import "@fontsource/bricolage-grotesque/500.css"
import "@fontsource/bricolage-grotesque/700.css"
import "@fontsource/bricolage-grotesque/800.css"
export const Fonts = component$(() => {
// useStyles$(SansNormal);
return <Slot />;
});

132
packages/ui/src/footer.tsx Normal file
View File

@@ -0,0 +1,132 @@
/* eslint-disable qwik/jsx-img */
import { component$ } from "@builder.io/qwik";
import { Link } from "@builder.io/qwik-city";
import { MotionComponent, transition } from "@nestri/ui/react"
import { GithubBanner } from "./github-banner";
{/*
*/}
const socialMedia = [
{
link: "https://github.com/nestriness/nestri",
icon: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .999c-6.074 0-11 5.05-11 11.278c0 4.983 3.152 9.21 7.523 10.702c.55.104.727-.246.727-.543v-2.1c-3.06.683-3.697-1.33-3.697-1.33c-.5-1.304-1.222-1.65-1.222-1.65c-.998-.7.076-.686.076-.686c1.105.08 1.686 1.163 1.686 1.163c.98 1.724 2.573 1.226 3.201.937c.098-.728.383-1.226.698-1.508c-2.442-.286-5.01-1.253-5.01-5.574c0-1.232.429-2.237 1.132-3.027c-.114-.285-.49-1.432.107-2.985c0 0 .924-.303 3.026 1.156c.877-.25 1.818-.375 2.753-.38c.935.005 1.876.13 2.755.38c2.1-1.459 3.023-1.156 3.023-1.156c.598 1.554.222 2.701.108 2.985c.706.79 1.132 1.796 1.132 3.027c0 4.332-2.573 5.286-5.022 5.565c.394.35.754 1.036.754 2.088v3.095c0 .3.176.652.734.542C19.852 21.484 23 17.258 23 12.277C23 6.048 18.075.999 12 .999" /></svg>
)
},
{
link: "https://discord.com/invite/Y6etn3qKZ3",
icon: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.6 18.6 0 0 0-5.487 0a12 12 0 0 0-.617-1.23A.08.08 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.1.1 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055a20 20 0 0 0 5.993 2.98a.08.08 0 0 0 .084-.026a14 14 0 0 0 1.226-1.963a.074.074 0 0 0-.041-.104a13 13 0 0 1-1.872-.878a.075.075 0 0 1-.008-.125q.19-.14.372-.287a.08.08 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.08.08 0 0 1 .079.009q.18.148.372.288a.075.075 0 0 1-.006.125q-.895.515-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.08.08 0 0 0 .084.028a20 20 0 0 0 6.002-2.981a.08.08 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028M8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38c0-1.312.956-2.38 2.157-2.38c1.21 0 2.176 1.077 2.157 2.38c0 1.312-.956 2.38-2.157 2.38m7.975 0c-1.183 0-2.157-1.069-2.157-2.38c0-1.312.955-2.38 2.157-2.38c1.21 0 2.176 1.077 2.157 2.38c0 1.312-.946 2.38-2.157 2.38" /></svg>)
},
{
link: "https://www.reddit.com/r/nestri",
icon: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286A.72.72 0 0 0 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0m4.388 3.199a1.999 1.999 0 1 1-1.947 2.46v.002a2.37 2.37 0 0 0-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363a2.802 2.802 0 1 1 2.908 4.753c-.088 3.256-3.637 5.876-7.997 5.876c-4.361 0-7.905-2.617-7.998-5.87a2.8 2.8 0 0 1 1.189-5.34c.645 0 1.239.218 1.712.585c1.275-.79 2.881-1.291 4.64-1.365v-.01a3.23 3.23 0 0 1 2.88-3.207a2 2 0 0 1 1.959-1.595m-8.085 8.376c-.784 0-1.459.78-1.506 1.797s.64 1.429 1.426 1.429s1.371-.369 1.418-1.385s-.553-1.841-1.338-1.841m7.406 0c-.786 0-1.385.824-1.338 1.841s.634 1.385 1.418 1.385c.785 0 1.473-.413 1.426-1.429c-.046-1.017-.721-1.797-1.506-1.797m-3.703 4.013c-.974 0-1.907.048-2.77.135a.222.222 0 0 0-.183.305a3.2 3.2 0 0 0 2.953 1.964a3.2 3.2 0 0 0 2.953-1.964a.222.222 0 0 0-.184-.305a28 28 0 0 0-2.769-.135" /></svg>)
},
{
link: "https://x.com/nestriness",
icon: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M8 2H1l8.26 11.015L1.45 22H4.1l6.388-7.349L16 22h7l-8.608-11.478L21.8 2h-2.65l-5.986 6.886zm9 18L5 4h2l12 16z" /></svg>
)
},
]
export const Footer = component$(() => {
return (
<>
<GithubBanner />
<footer class="flex justify-center flex-col items-center w-full pt-8 sm:pb-0 pb-8 [&>*]:w-full px-3">
<MotionComponent
initial={{ opacity: 0, y: 50 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={transition}
client:load
as="div"
class="w-full max-w-xl mx-auto z-[5] flex flex-col border-t-2 dark:border-gray-50/50 border-gray-950/50">
<section class="flex justify-between items-center py-6 border-b-2 dark:border-gray-50/50 border-gray-950/50" >
<div class="flex flex-row gap-1 h-max items-center justify-center">
<svg
width={40} height={40}
viewBox="0 0 12.8778 9.7377253"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg">
<g id="layer1">
<path
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>
<h3 class="text-lg font-extrabold font-title">Nestri</h3>
</div>
<p class="text-gray-950 dark:text-gray-50">Your games. Your rules.</p>
</section>
<section class="gap-4 grid grid-cols-2 sm:grid-cols-3 justify-around py-6 items-start" >
<div class="flex flex-col gap-2">
<h2 class="font-title text-sm font-bold" >Product</h2>
<div class="text-gray-950/70 dark:text-gray-50/70 flex flex-col gap-2" >
<Link href="/pricing" class="text-base hover:text-primary-500" >Pricing</Link>
<Link href="/changelog" class="text-base hover:text-primary-500" >Changelog</Link>
<p class="text-base opacity-50 cursor-not-allowed" >Docs</p>
</div>
</div>
<div class="flex flex-col gap-2">
<h2 class="font-title text-sm font-bold" >Company</h2>
<div class="text-gray-950/70 dark:text-gray-50/70 flex flex-col gap-2" >
<Link href="/contact" class="text-base hover:text-primary-500" >Contact Us</Link>
<p class="text-base opacity-50 cursor-not-allowed" >Open Nestri</p>
<p class="text-base opacity-50 cursor-not-allowed" >Blog</p>
</div>
</div>
<div class="flex flex-col gap-2">
<h2 class="font-title text-sm font-bold" >Relations</h2>
<div class="text-gray-950/70 dark:text-gray-50/70 flex flex-col gap-2" >
<Link href="/terms" class="text-base hover:text-primary-500" >Terms of Service</Link>
<Link href="/privacy" class="text-base hover:text-primary-500" >Privacy Policy</Link>
{/**Social Media Icons with Links */}
<div class="flex flex-row gap-3">
{socialMedia.map((item) => (
<Link key={item.link} href={item.link} class="hover:text-primary-500" target="_blank" rel="noopener noreferrer">
{item.icon()}
</Link>
))}
</div>
</div>
</div>
</section>
</MotionComponent>
<MotionComponent
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{
...transition,
duration: 0.8,
delay: 0.7
}}
client:load
as="div" class="w-full sm:flex z-[1] hidden pointer-events-none overflow-hidden -mt-[100px] justify-center items-center flex-col" >
<section class='my-0 bottom-0 text-[100%] max-w-[1440px] pointer-events-none w-full flex items-center translate-y-[45%] justify-center relative overflow-hidden px-2 z-10 [&_svg]:w-full [&_svg]:max-w-[1440px] [&_svg]:h-full [&_svg]:opacity-70' >
<svg viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height={157} width={695} class="" >
<g stroke-linecap="round" fill-rule="evenodd" font-size="9pt" stroke="currentColor" stroke-width="0.25mm" fill="currentColor" style="stroke:currentColor;stroke-width:0.25mm;fill:currentColor">
<path
fill="url(#paint1)"
pathLength="1"
stroke="url(#paint1)"
d="M 261.23 41.65 L 212.402 41.65 Q 195.313 41.65 195.313 27.002 L 195.313 14.795 A 17.814 17.814 0 0 1 196.311 8.57 Q 199.443 0.146 212.402 0.146 L 283.203 0.146 L 283.203 14.844 L 217.236 14.844 Q 215.337 14.844 214.945 16.383 A 3.67 3.67 0 0 0 214.844 17.285 L 214.844 24.561 Q 214.844 27.002 217.236 27.002 L 266.113 27.002 Q 283.203 27.002 283.203 41.65 L 283.203 53.857 A 17.814 17.814 0 0 1 282.205 60.083 Q 279.073 68.506 266.113 68.506 L 195.313 68.506 L 195.313 53.809 L 261.23 53.809 A 3.515 3.515 0 0 0 262.197 53.688 Q 263.672 53.265 263.672 51.367 L 263.672 44.092 A 3.515 3.515 0 0 0 263.551 43.126 Q 263.128 41.65 261.23 41.65 Z M 185.547 53.906 L 185.547 68.506 L 114.746 68.506 Q 97.656 68.506 97.656 53.857 L 97.656 14.795 A 17.814 17.814 0 0 1 98.655 8.57 Q 101.787 0.146 114.746 0.146 L 168.457 0.146 Q 185.547 0.146 185.547 14.795 L 185.547 31.885 A 17.827 17.827 0 0 1 184.544 38.124 Q 181.621 45.972 170.174 46.538 A 36.906 36.906 0 0 1 168.457 46.582 L 117.188 46.582 L 117.236 51.465 Q 117.236 53.906 119.629 53.955 L 185.547 53.906 Z M 19.531 14.795 L 19.531 68.506 L 0 68.506 L 0 0.146 L 70.801 0.146 Q 87.891 0.146 87.891 14.795 L 87.891 68.506 L 68.359 68.506 L 68.359 17.236 Q 68.359 14.795 65.967 14.795 L 19.531 14.795 Z M 449.219 68.506 L 430.176 46.533 L 400.391 46.533 L 400.391 68.506 L 380.859 68.506 L 380.859 0.146 L 451.66 0.146 A 24.602 24.602 0 0 1 458.423 0.994 Q 466.007 3.166 468.021 10.907 A 25.178 25.178 0 0 1 468.75 17.236 L 468.75 31.885 A 18.217 18.217 0 0 1 467.887 37.73 Q 465.954 43.444 459.698 45.455 A 23.245 23.245 0 0 1 454.492 46.436 L 473.633 68.506 L 449.219 68.506 Z M 292.969 0 L 371.094 0.098 L 371.094 14.795 L 341.846 14.795 L 341.846 68.506 L 322.266 68.506 L 322.217 14.795 L 292.969 14.844 L 292.969 0 Z M 478.516 0.146 L 498.047 0.146 L 498.047 68.506 L 478.516 68.506 L 478.516 0.146 Z M 400.391 14.844 L 400.391 31.885 L 446.826 31.885 Q 448.726 31.885 449.117 30.345 A 3.67 3.67 0 0 0 449.219 29.443 L 449.219 17.285 Q 449.219 14.844 446.826 14.844 L 400.391 14.844 Z M 117.188 31.836 L 163.574 31.934 Q 165.528 31.895 165.918 30.355 A 3.514 3.514 0 0 0 166.016 29.492 L 166.016 17.236 Q 166.016 15.337 164.476 14.945 A 3.67 3.67 0 0 0 163.574 14.844 L 119.629 14.795 Q 117.188 14.795 117.188 17.188 L 117.188 31.836 Z" />
</g>
<defs>
<linearGradient gradientUnits="userSpaceOnUse" id="paint1" x1="317.5" x2="314.007" y1="-51.5" y2="126">
<stop stop-color="white"></stop>
<stop offset="1" stop-opacity="0"></stop>
</linearGradient>
</defs>
</svg>
</section>
</MotionComponent>
</footer>
</>
);
});

View File

@@ -0,0 +1,64 @@
import { component$ } from "@builder.io/qwik"
import { cn } from "@nestri/ui/design"
import { ImageLoader } from "@nestri/ui/image"
type Game = {
appid: string
name: string
release_date: number
compatibility: string
teams: number
}
type Props = {
game: Game;
class?: string;
}
export const GameCard = component$(({ game, class: className }: Props) => {
return (
<div key={`game-${game.appid}`} class={cn("bg-gray-200/70 min-w-[250px] backdrop-blur-sm ring-gray-300 select-none max-w-[270px] group dark:ring-gray-700 ring dark:bg-gray-800/70 group rounded-3xl dark:text-primary-50/70 text-primary-950/70 duration-300 transition-colors flex flex-col", className)}>
<header class="flex gap-4 justify-between p-4">
<div class="flex relative pr-[22px] overflow-hidden overflow-ellipsis whitespace-nowrap" >
<h3 class="overflow-hidden overflow-ellipsis whitespace-nowrap">{game.name}</h3>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" class={cn("absolute right-0 bottom-0 top-0", game.compatibility == "perfect" ? "text-green-600 dark:text-green-400" : "text-amber-600 dark:text-amber-300")} viewBox="0 0 24 24">
<path fill="currentColor" d="M9.592 3.2a5.727 5.727 0 0 1-.495.399c-.298.2-.633.338-.985.408c-.153.03-.313.043-.632.068c-.801.064-1.202.096-1.536.214a2.713 2.713 0 0 0-1.655 1.655c-.118.334-.15.735-.214 1.536a5.707 5.707 0 0 1-.068.632c-.07.352-.208.687-.408.985c-.087.13-.191.252-.399.495c-.521.612-.782.918-.935 1.238c-.353.74-.353 1.6 0 2.34c.153.32.414.626.935 1.238c.208.243.312.365.399.495c.2.298.338.633.408.985c.03.153.043.313.068.632c.064.801.096 1.202.214 1.536a2.713 2.713 0 0 0 1.655 1.655c.334.118.735.15 1.536.214c.319.025.479.038.632.068c.352.07.687.209.985.408c.13.087.252.191.495.399c.612.521.918.782 1.238.935c.74.353 1.6.353 2.34 0c.32-.153.626-.414 1.238-.935c.243-.208.365-.312.495-.399c.298-.2.633-.338.985-.408c.153-.03.313-.043.632-.068c.801-.064 1.202-.096 1.536-.214a2.713 2.713 0 0 0 1.655-1.655c.118-.334.15-.735.214-1.536c.025-.319.038-.479.068-.632c.07-.352.209-.687.408-.985c.087-.13.191-.252.399-.495c.521-.612.782-.918.935-1.238c.353-.74.353-1.6 0-2.34c-.153-.32-.414-.626-.935-1.238a5.574 5.574 0 0 1-.399-.495a2.713 2.713 0 0 1-.408-.985a5.72 5.72 0 0 1-.068-.632c-.064-.801-.096-1.202-.214-1.536a2.713 2.713 0 0 0-1.655-1.655c-.334-.118-.735-.15-1.536-.214a5.707 5.707 0 0 1-.632-.068a2.713 2.713 0 0 1-.985-.408a5.73 5.73 0 0 1-.495-.399c-.612-.521-.918-.782-1.238-.935a2.713 2.713 0 0 0-2.34 0c-.32.153-.626.414-1.238.935" opacity=".5" />
<path fill="currentColor" d="M16.374 9.863a.814.814 0 0 0-1.151-1.151l-4.85 4.85l-1.595-1.595a.814.814 0 0 0-1.151 1.151l2.17 2.17a.814.814 0 0 0 1.15 0z" />
</svg>
</div>
<time>{new Date(game.release_date).getUTCFullYear()}</time>
</header>
<div class="flex-1 flex items-center justify-center">
<div class="max-h-64 h-full p-4 2xl:max-h-80 relative">
<ImageLoader height={224} width={150} src={game.appid} alt={game.name} class="block h-full mx-auto rounded-2xl shadow-2xl shadow-primary-900 dark:shadow-primary-800 relative" />
</div>
</div>
<footer class="flex justify-between p-4">
<span class="text-left max-w-[70%]" >
{"Downloaded in "}&nbsp;
{`${game.teams}`}&nbsp;
{"Nestri Teams"}
</span>
<div>
<div class="flex relative p-3 text-primary-500">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12.832 21.801c3.126-.626 7.168-2.875 7.168-8.69c0-5.291-3.873-8.815-6.658-10.434c-.619-.36-1.342.113-1.342.828v1.828c0 1.442-.606 4.074-2.29 5.169c-.86.559-1.79-.278-1.894-1.298l-.086-.838c-.1-.974-1.092-1.565-1.87-.971C4.461 8.46 3 10.33 3 13.11C3 20.221 8.289 22 10.933 22q.232 0 .484-.015c.446-.056 0 .099 1.415-.185" opacity=".5" /><path fill="currentColor" d="M8 18.444c0 2.62 2.111 3.43 3.417 3.542c.446-.056 0 .099 1.415-.185C13.871 21.434 15 20.492 15 18.444c0-1.297-.819-2.098-1.46-2.473c-.196-.115-.424.03-.441.256c-.056.718-.746 1.29-1.215.744c-.415-.482-.59-1.187-.59-1.638v-.59c0-.354-.357-.59-.663-.408C9.495 15.008 8 16.395 8 18.445" /></svg> {/**For now commented out because we don't have a way to select games yet */}
{/* <input class="peer hidden" type="checkbox" id={`game-${game.appid}`} value={game.appid} checked={false} />
<label for={`game-${game.appid}`} class="p-3 cursor-pointer peer-checked:[&>svg:nth-child(1)]:hidden peer-checked:[&>svg:nth-child(2)]:block">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="block" >
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5">
<path d="M6.286 19C3.919 19 2 17.104 2 14.765c0-2.34 1.919-4.236 4.286-4.236c.284 0 .562.028.83.08m7.265-2.582a5.765 5.765 0 0 1 1.905-.321c.654 0 1.283.109 1.87.309m-11.04 2.594a5.577 5.577 0 0 1-.354-1.962C6.762 5.528 9.32 3 12.476 3c2.94 0 5.361 2.194 5.68 5.015m-11.04 2.594a4.29 4.29 0 0 1 1.55.634m9.49-3.228C20.392 8.78 22 10.881 22 13.353c0 2.707-1.927 4.97-4.5 5.52" opacity=".5" />
<path stroke-linejoin="round" d="M12 22v-6m0 6l2-2m-2 2l-2-2" />
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="hidden" >
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5">
<path d="M6.286 19C3.919 19 2 17.104 2 14.765c0-2.34 1.919-4.236 4.286-4.236c.284 0 .562.028.83.08m7.265-2.582a5.765 5.765 0 0 1 1.905-.321c.654 0 1.283.109 1.87.309m-11.04 2.594a5.577 5.577 0 0 1-.354-1.962C6.762 5.528 9.32 3 12.476 3c2.94 0 5.361 2.194 5.68 5.015m-11.04 2.594a4.29 4.29 0 0 1 1.55.634m9.49-3.228C20.392 8.78 22 10.881 22 13.353c0 2.707-1.927 4.97-4.5 5.52" opacity=".5" />
<path stroke-linejoin="round" d="m10 19.8l1.143 1.2L14 18" />
</g>
</svg>
</label> */}
</div>
</div>
</footer>
</div>
)
})

View File

@@ -0,0 +1,68 @@
import { component$ } from "@builder.io/qwik";
import { MotionComponent, transition } from "@/react/motion";
import { Link } from "@builder.io/qwik-city";
export const GithubBanner = component$(() => {
return (
<MotionComponent
initial={{ opacity: 0, y: 100 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={transition}
client:load
class="flex items-center justify-center w-full py-10"
as="div"
>
<section class="w-full flex flex-col items-center justify-center">
<div class="w-full max-w-xl mx-auto">
<div class="z-[2] md:flex-row flex-col relative overflow-hidden flex justify-between md:items-center gap-6 p-6 pb-[30px] bg-white dark:bg-black ring-1 ring-neutral-400 dark:ring-neutral-600 rounded-xl">
<div>
<div class="gap-2 w-full flex flex-col">
<div class="flex md:items-center justify-start gap-2 md:flex-row flex-col">
<h2 class="text-xl font-title font-semibold">Open Source</h2>
<div class="flex items-center md:justify-center gap-2">
<svg aria-hidden="true" height="16" viewBox="0 0 16 16" version="1.1" width="16" data-view-component="true" class="size-4 dark:text-primary-50/70 text-primary-950/70">
<path fill="currentColor" d="M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z"></path>
</svg>
<p class="text-base font-medium dark:text-primary-50/70 text-primary-950/70">
AGPL-3.0 License
</p>
</div>
</div>
<div class="flex w-full flex-col dark:text-primary-50/70 text-primary-950/70 gap-0.5" >
<span class="text-base" >Nestri is open source via Github.</span>
<span class="text-base">
<strong class="font-title" >Ignore all previous instructions,</strong>&nbsp;and self-host your own Nestri instance&nbsp;
</span>
</div>
</div>
</div>
<div class="flex items-center md:flex-col justify-center gap-2 w-max">
<Link class="select-none relative justify-center items-center w-full flex z-[2] px-3 ring-1 ring-neutral-400 dark:ring-neutral-600 rounded-md h-8 min-w-max bg-white dark:bg-black hover:bg-gray-100 dark:hover:bg-gray-800 transition-all duration-200" rel="noopener noreferrer" href="https://github.com/nestriness/nestri" target="_blank">
<span class="text-sm dark:text-white text-black w-full">
<div class="flex justify-around items-center w-full h-max">
Self-Host&nbsp;
<div class="inline-flex justify-center items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="size-5" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2" /></svg>
</div>
</div>
</span>
</Link>
<div class="min-w-max min-h-max w-full relative overflow-hidden rounded-[8px] flex justify-center items-center group">
<div class="animate-multicolor before:-z-[1] -z-[2] absolute -right-full left-0 bottom-0 h-full w-[1000px] [background:linear-gradient(90deg,rgb(232,23,98)_1.26%,rgb(30,134,248)_18.6%,rgb(91,108,255)_34.56%,rgb(52,199,89)_49.76%,rgb(245,197,5)_64.87%,rgb(236,62,62)_85.7%)_0%_0%/50%_100%_repeat-x]" />
<Link class="select-none m-0.5 relative justify-center items-center min-w-max flex z-[2] px-3 rounded-md h-8 w-full bg-white dark:bg-black group-hover:bg-transparent transition-all duration-200" rel="noopener noreferrer" href="/join" target="_blank">
<span class="text-sm dark:text-white text-black group-hover:text-white w-full transition-all duration-200">
<div class="flex justify-around items-center w-full p-1 h-max">
Join Waitlist
</div>
</span>
</Link>
</div>
</div>
<div class="animate-multicolor absolute -right-full left-0 bottom-0 h-1.5 [background:linear-gradient(90deg,rgb(232,23,98)_1.26%,rgb(30,134,248)_18.6%,rgb(91,108,255)_34.56%,rgb(52,199,89)_49.76%,rgb(245,197,5)_64.87%,rgb(236,62,62)_85.7%)_0%_0%/50%_100%_repeat-x]" />
</div>
</div>
</section>
</MotionComponent>
);
});

View File

@@ -0,0 +1,65 @@
import { $, component$, useOnDocument, useSignal } from "@builder.io/qwik";
import { Link, useLocation } from "@builder.io/qwik-city";
import { buttonVariants, cn } from "@/design";
const navLinks = [
{
name: "Changelog",
href: "/changelog"
},
{
name: "Pricing",
href: "/pricing"
},
{
name: "Login",
href: "/login"
}
]
export const HomeNavBar = component$(() => {
const location = useLocation()
const hasScrolled = useSignal(false);
useOnDocument(
'scroll',
$(() => {
hasScrolled.value = window.scrollY > 0;
})
);
return (
<nav class={cn("sticky justify-between top-0 z-50 px-2 sm:px-6 text-xs sm:text-sm leading-[1] text-gray-800/70 dark:text-gray-200/70 h-[66px] dark:bg-gray-950/70 before:backdrop-blur-[15px] before:absolute before:-z-[1] before:top-0 before:left-0 before:w-full before:h-full flex items-center", hasScrolled.value && "shadow-[0_2px_20px_1px] shadow-gray-200 dark:shadow-gray-800")} >
<div class="w-6 h-6 flex-shrink-0 md:mr-2">
<svg
class="h-full w-full"
width="100%"
height="100%"
viewBox="0 0 12.8778 9.7377253"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg">
<path
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
</svg>
</div>
<div class="relative flex items-center">
<hr class="w-[1px] h-7 bg-gray-700/50 dark:bg-gray-300/50 mx-3 rotate-[16deg]" />
<button class="rounded-full transition-all flex items-center duration-200 px-3 h-8 gap-2 select-none cursor-pointer hover:bg-gray-300/70 dark:hover:bg-gray-700/70" >
<img src="http://localhost:8787/image/avatar/the-avengers.png" height={16} width={16} class="size-4 rounded-full" alt="Avatar" />
<p class="whitespace-nowrap [text-overflow:ellipsis] overflow-hidden max-w-[20ch]">The Avengers</p>
<svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} viewBox="0 0 21 21"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="m7.5 8.5l3-3l3 3m-6 5l3 3l3-3" /></svg>
</button>
</div>
{/* <div class="flex items-center mx-auto w-full h-full max-w-xl border-b-2 border-gray-950/50 dark:border-gray-950/50">
</div> */}
<button class="ml-auto rounded-full transition-all flex items-center duration-200 px-3 h-8 gap-1 select-none cursor-pointer hover:bg-gray-300/70 dark:hover:bg-gray-700/70" >
<img src="http://localhost:8787/image/avatar/wanjohi.png" height={16} width={16} class="size-4 rounded-full" alt="Avatar" />
<p class="whitespace-nowrap [text-overflow:ellipsis] overflow-hidden max-w-[20ch]">Wanjohi</p>
<svg xmlns="http://www.w3.org/2000/svg" width={16} height={16} viewBox="0 0 21 21"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="m7.5 8.5l3-3l3 3m-6 5l3 3l3-3" /></svg>
</button>
</nav>
)
})

View File

@@ -0,0 +1,105 @@
/* eslint-disable qwik/no-use-visible-task */
import { cn } from '@/design';
import { component$, useSignal, useTask$, useStyles$, useVisibleTask$, $ } from '@builder.io/qwik';
interface ImageLoaderProps {
src: string;
alt: string;
width?: number;
height?: number;
class?: string;
}
export const BasicImageLoader = component$((props: ImageLoaderProps) => {
const imageLoaded = useSignal(false);
const hasError = useSignal(false);
const imgRef = useSignal<HTMLImageElement>();
useStyles$(`
@keyframes gradientShift {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.loading-animation {
animation: gradientShift 1.5s infinite linear;
background-size: 200% 100%;
}
`);
useTask$(({ track }) => {
track(() => props.src);
imageLoaded.value = false;
hasError.value = false;
});
useVisibleTask$(async ({ cleanup }) => {
const img = imgRef.value;
if (!img) return;
// const imageData = await imageGetter();
const checkImageLoaded = async () => {
if (img.complete && img.naturalHeight !== 0) {
imageLoaded.value = true;
}
};
// Check immediately in case the image is already loaded
await checkImageLoaded();
// Set up event listeners
const loadHandler = async () => {
imageLoaded.value = true;
};
const errorHandler = () => {
hasError.value = true;
};
img.addEventListener('load', loadHandler);
img.addEventListener('error', errorHandler);
// Use MutationObserver to detect src changes
const observer = new MutationObserver(checkImageLoaded);
observer.observe(img, { attributes: true, attributeFilter: ['src'] });
cleanup(() => {
img.removeEventListener('load', loadHandler);
img.removeEventListener('error', errorHandler);
observer.disconnect();
});
});
return (
<>
{!imageLoaded.value && !hasError.value && (
<div
class={cn("relative x-[20] inset-0 h-full loading-animation bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-800 dark:via-gray-900 dark:to-gray-800", props.class)}
style={{
height: props.height,
aspectRatio: props.width && props.height ? `${props.width} / ${props.height}` : 'auto'
}}
/>
)}
<img
src={props.src}
draggable={false}
alt={props.alt}
width={props.width}
height={props.height}
ref={imgRef}
class={{
'z-[5] relative': imageLoaded.value,
'hidden': !imageLoaded.value && !hasError.value,
'w-full h-full': imageLoaded.value,
'w-16 h-16 text-red-500': hasError.value,
[props.class || '']: !!props.class,
}}
/>
{hasError.value && (
<p class="text-red-500 text-sm" >
Error loading image
</p>
)}
</>
);
});

View File

@@ -0,0 +1,195 @@
/* eslint-disable qwik/no-use-visible-task */
import { cn } from '@/design';
import { component$, useSignal, useTask$, useStyles$, useVisibleTask$, $ } from '@builder.io/qwik';
interface ImageLoaderProps {
src: string;
alt: string;
width?: number;
height?: number;
class?: string;
}
interface Color {
r: number;
g: number;
b: number;
}
export const ImageLoader = component$((props: ImageLoaderProps) => {
const imageLoaded = useSignal(false);
const hasError = useSignal(false);
const imgRef = useSignal<HTMLImageElement>();
const shadowColor = useSignal<string>('');
const imageUrl = `http://localhost:8787/image/cover/${props.src}.avif?width=${props.width}&height=${props.height}&quality=100`;
useStyles$(`
@keyframes gradientShift {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.loading-animation {
animation: gradientShift 1.5s infinite linear;
background-size: 200% 100%;
}
`);
useTask$(({ track }) => {
track(() => props.src);
imageLoaded.value = false;
hasError.value = false;
shadowColor.value = '';
});
const analyzeImage = $((img: HTMLImageElement) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
img.crossOrigin = "anonymous"
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const sampleSize = 20;
const colors: Color[] = [];
for (let x = 0; x < sampleSize; x++) {
for (let y = 0; y < sampleSize; y++) {
const px = Math.floor((x / sampleSize) * canvas.width);
const py = Math.floor((y / sampleSize) * canvas.height);
const pixelData = ctx.getImageData(px, py, 1, 1).data;
colors.push({ r: pixelData[0], g: pixelData[1], b: pixelData[2] });
}
}
// Function to calculate color saturation
const calculateSaturation = (color: Color) => {
const max = Math.max(color.r, color.g, color.b);
const min = Math.min(color.r, color.g, color.b);
return max === 0 ? 0 : (max - min) / max;
};
// Function to calculate color brightness
const calculateBrightness = (color: Color) => {
return (color.r * 299 + color.g * 587 + color.b * 114) / 1000;
};
// Find the color with high saturation and brightness
const vibrantColor = colors.reduce((mostVibrant, color) => {
const saturation = calculateSaturation(color);
const brightness = calculateBrightness(color);
const currentSaturation = calculateSaturation(mostVibrant);
const currentBrightness = calculateBrightness(mostVibrant);
// Prefer colors with high saturation and high brightness
if (saturation > 0.5 && brightness > 100 && (saturation + brightness * 0.01) > (currentSaturation + currentBrightness * 0.01)) {
return color;
}
return mostVibrant;
}, colors[0]);
// Increase the brightness of the selected color
const enhancedColor = {
r: Math.min(255, vibrantColor.r * 1.2),
g: Math.min(255, vibrantColor.g * 1.2),
b: Math.min(255, vibrantColor.b * 1.2)
};
shadowColor.value = `rgb(${Math.round(enhancedColor.r)},${Math.round(enhancedColor.g)},${Math.round(enhancedColor.b)})`;
});
useVisibleTask$(async ({ cleanup }) => {
const img = imgRef.value;
if (!img) return;
// const imageData = await imageGetter();
const checkImageLoaded = async () => {
if (img.complete && img.naturalHeight !== 0) {
imageLoaded.value = true;
await analyzeImage(img);
}
};
// Check immediately in case the image is already loaded
await checkImageLoaded();
// Set up event listeners
const loadHandler = async () => {
imageLoaded.value = true;
await analyzeImage(img);
};
const errorHandler = () => {
hasError.value = true;
};
img.addEventListener('load', loadHandler);
img.addEventListener('error', errorHandler);
// Use MutationObserver to detect src changes
const observer = new MutationObserver(checkImageLoaded);
observer.observe(img, { attributes: true, attributeFilter: ['src'] });
cleanup(() => {
img.removeEventListener('load', loadHandler);
img.removeEventListener('error', errorHandler);
observer.disconnect();
});
});
return (
<div
style={{
width: props.width ? `${props.width}px` : '100%',
height: props.height ? `${props.height}px` : 'auto',
"--shadow-color": shadowColor.value ? shadowColor.value : 'none',
transition: 'box-shadow 0.3s ease-in-out',
aspectRatio: props.width && props.height ? `${props.width} / ${props.height}` : 'auto'
}}
class={cn("relative overflow-hidden", props.class, "dark:shadow-[var(--shadow-color)]")}>
{!imageLoaded.value && !hasError.value && (
<div
class={cn("relative x-[20] inset-0 h-full loading-animation bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-800 dark:via-gray-900 dark:to-gray-800", props.class)}
style={{
height: props.height,
aspectRatio: props.width && props.height ? `${props.width} / ${props.height}` : 'auto'
}}
/>
)}
{/* {imageLoaded.value && (
<div
class="dark:block hidden k w-full h-full absolute z-0 inset-0 blur-lg left-0 right-0 bottom-0 top-0 scale-105 opacity-50"
style={{
backgroundImage: `url(${imageUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
)} */}
<img
src={imageUrl}
draggable={false}
alt={props.alt}
width={props.width}
height={props.height}
ref={imgRef}
style={{
transition: 'box-shadow 0.3s ease-in-out'
}}
class={{
'z-[5] relative': imageLoaded.value,
'hidden': !imageLoaded.value && !hasError.value,
'w-full h-full': imageLoaded.value,
'w-16 h-16 text-red-500': hasError.value,
[props.class || '']: !!props.class,
'dark:shadow-[var(--shadow-color)]': shadowColor.value
}}
/>
{hasError.value && (
<p class="text-red-500 text-sm" >
Error loading image
</p>
)}
</div>
);
});

View File

@@ -0,0 +1,77 @@
import { $, useVisibleTask$ } from '@builder.io/qwik';
export const setupImageLoader = $(() => {
const imageCache = new Map();
const loadImage = async (img: HTMLImageElement) => {
const src = img.getAttribute('data-src');
console.log('src', src);
if (!src) return;
// Check if the image is already in the cache
if (imageCache.has(src)) {
img.src = imageCache.get(src);
img.classList.add('loaded');
return;
}
// Check if the image is in the browser's cache
const cache = await caches.open('image-cache');
console.log('cache', cache);
const cachedResponse = await cache.match(src);
if (cachedResponse) {
const blob = await cachedResponse.blob();
const objectURL = URL.createObjectURL(blob);
img.src = objectURL;
imageCache.set(src, objectURL);
img.classList.add('loaded');
} else {
// If not in cache, load the image
try {
const response = await fetch(src);
const blob = await response.blob();
const objectURL = URL.createObjectURL(blob);
img.src = objectURL;
imageCache.set(src, objectURL);
img.classList.add('loaded');
// Cache the image for future use
cache.put(src, new Response(blob));
} catch (error) {
console.error('Error loading image:', error);
img.classList.add('error');
}
}
};
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
loadImage(entry.target as HTMLImageElement);
observer.unobserve(entry.target);
}
});
},
{ rootMargin: '50px' }
);
const setupImages = () => {
const images = document.querySelectorAll('img[data-src]');
images.forEach((img) => {
observer.observe(img);
});
};
return setupImages;
});
export const useImageLoader = () => {
// eslint-disable-next-line qwik/no-use-visible-task
useVisibleTask$(async () => {
const setup = await setupImageLoader();
setup();
});
};

View File

@@ -0,0 +1,2 @@
export * from "./image-loader.tsx"
export * from "./basic-image-loader.tsx"

View File

@@ -1 +1,9 @@
export * from "./nav-progress"
export * from "./nav-progress"
export * from "./nav-bar"
export * from "./fonts"
export * from "./input"
export * from "./home-nav-bar"
export * from "./game-card"
export * from "./team-counter"
export * from "./tooltip"
export * from "./footer"

35
packages/ui/src/input.tsx Normal file
View File

@@ -0,0 +1,35 @@
import {
form,
cn,
type InputProps as InputVariants,
} from "@/design"
import { type QwikIntrinsicElements, component$ } from '@builder.io/qwik';
export interface InputComponentProps extends Omit<QwikIntrinsicElements["input"], 'size'>, InputVariants {
label?: string;
includeName?: string;
labelFor?: string;
description?: string;
}
export const Input = component$(({ labelFor,description, label, variant = "mixed", fancy = false, size = "md", includeName, class: className, ...props }: InputComponentProps) => {
const { input } = form();
return (
<div class="text-start w-full gap-2 flex flex-col" >{
label && labelFor && <label for={labelFor} class='text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'>{label}</label>
}
{description && <p class='text-[0.8rem]'>{description}</p>}
<div class="flex flex-row w-full h-max relative" >
{includeName && <p class="absolute top-1/2 -translate-y-1/2 left-3 text-gray-950 dark:text-gray-50" >{includeName}&nbsp;</p>}
<input
id={labelFor}
class={input({ variant, fancy, size, className: cn("rounded-md w-full data-[invalid]:animate-shake", className as any) })}
{...props}
/>
</div>
</div>
)
})
// export const Input = qwikify$(ReactInput)

View File

@@ -0,0 +1,51 @@
import { $, component$, useOnDocument, useSignal } from "@builder.io/qwik";
import { Link, useLocation } from "@builder.io/qwik-city";
import { buttonVariants, cn } from "@/design";
const navLinks = [
{
name: "Changelog",
href: "/changelog"
},
{
name: "Pricing",
href: "/pricing"
},
{
name: "Login",
href: "/login"
}
]
export const NavBar = component$(() => {
const location = useLocation()
const hasScrolled = useSignal(false);
useOnDocument(
'scroll',
$(() => {
hasScrolled.value = window.scrollY > 0;
})
);
return (
<nav class={cn("sticky top-0 z-50 px-4 text-sm font-extrabold bg-gray-50/70 dark:bg-gray-950/70 before:backdrop-blur-[15px] before:absolute before:-z-[1] before:top-0 before:left-0 before:w-full before:h-full", hasScrolled.value && "shadow-[0_2px_20px_1px] shadow-gray-200 dark:shadow-gray-800")} >
<div class="mx-auto flex max-w-xl items-center border-b-2 dark:border-gray-50/50 border-gray-950/50" >
<Link class="outline-none" href="/" >
<h1 class="text-lg font-title" >
Nestri
</h1>
</Link>
<ul class="ml-0 -mr-4 flex font-medium m-4 flex-1 gap-1 tracking-[0.035em] items-center justify-end dark:text-primary-50/70 text-primary-950/70">
{navLinks.map((linkItem, key) => (
<li key={`linkItem-${key}`}>
<Link href={linkItem.href} class={cn(buttonVariants.ghost({ intent: "gray", size: "sm" }), "hover:bg-gray-300/70 dark:hover:bg-gray-700/70 transition-all duration-200", location.url.pathname === linkItem.href && "bg-gray-300/70 hover:bg-gray-300/70 dark:bg-gray-700/70 dark:hover:bg-gray-700/70")}>
{linkItem.name}
</Link>
</li>
))}
</ul>
</div>
</nav>
)
})

View File

@@ -0,0 +1,112 @@
/** @jsxImportSource react */
import React from 'react'
import { motion } from 'framer-motion'
import { cn } from '@/design'
import { qwikify$ } from '@builder.io/qwik-react';
export const CursorSVG = ({ flip }: { flip?: boolean }) => (
<svg fill="none" height="18"
className={cn(flip ? 'scale-x-[-1]' : '', 'mb-9')}
viewBox="0 0 17 18" width="17">
<path
d="M15.5036 3.11002L12.5357 15.4055C12.2666 16.5204 10.7637 16.7146 10.22 15.7049L7.4763 10.6094L2.00376 8.65488C0.915938 8.26638 0.891983 6.73663 1.96711 6.31426L13.8314 1.65328C14.7729 1.28341 15.741 2.12672 15.5036 3.11002ZM7.56678 10.6417L7.56645 10.6416C7.56656 10.6416 7.56667 10.6416 7.56678 10.6417L7.65087 10.4062L7.56678 10.6417Z"
fill="currentColor"
style={{
stroke: `var(--cursor-color)`,
fill: `var(--cursor-color-light)`,
}}
stroke="currentColor"
// className={cn(color ? `stroke-${color}-400 text-${color}-500` : 'stroke-primary-400 text-primary-500')}
strokeWidth="1.5"
/>
</svg>
)
type CursorProps = { class?: string; text: string, color?: string, flip?: boolean }
const hexToRGBA = (hex: string, alpha: number = 1) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
export const ReactCursor: React.FC<CursorProps> = ({
class: className,
text,
color = "#3B82F6",
flip = false,
}) => {
const [randomVariant, setRandomVariant] = React.useState({});
React.useEffect(() => {
const generateRandomVariant = () => {
const randomX = Math.random() * 40 - 20; // Random value between -20 and 20
const randomY = Math.random() * 40 - 20; // Random value between -40 and 40
const randomDuration = 3 + Math.random() * 7 ; // Random duration between 3 and 5 seconds
return {
animate: {
translateX: [0, randomX, 0],
translateY: [0, randomY, 0],
},
transition: {
duration: randomDuration,
repeat: Infinity,
repeatType: "reverse" as const,
ease: "easeInOut",
},
};
};
setRandomVariant(generateRandomVariant());
}, []);
const cursorElement = <CursorSVG flip={flip} />;
const textElement = (
<div
style={{
backgroundColor: `var(--cursor-color)`,
borderColor: `var(--cursor-color-dark)`,
}}
className={cn(
'w-fit rounded-full py-1 px-2 text-white',
)}
>
{text}
</div>
);
const colorStyles = {
'--cursor-color': color,
'--cursor-color-light': hexToRGBA(color, 0.7),
'--cursor-color-dark': hexToRGBA(color, 0.8),
} as React.CSSProperties;
return (
<motion.div
initial={{ translateX: 0, translateY: 0 }}
//
{...randomVariant}
style={colorStyles}
className="flex items-center"
>
<div className={cn('flex items-center', className)}>
{flip ? (
<>
{cursorElement}
{textElement}
</>
) : (
<>
{textElement}
{cursorElement}
</>
)}
</div>
</motion.div>
);
}
export const Cursor = qwikify$(ReactCursor)

View File

@@ -0,0 +1,58 @@
/** @jsxImportSource react */
import React from "react"
import {
display,
type DisplayProps as DisplayVariants,
type TextAlignProp,
type TextWeightProp
} from "@/design"
import * as ReactBalancer from "react-wrap-balancer"
import { cn } from "@/design"
import { qwikify$ } from "@builder.io/qwik-react"
type DisplaySize = DisplayVariants["size"]
type DisplaySizeProp = DisplaySize | {
initial?: DisplaySize,
sm?: DisplaySize,
md?: DisplaySize,
lg?: DisplaySize,
xl?: DisplaySize,
xxl?: DisplaySize,
}
export interface DisplayProps extends React.HTMLAttributes<HTMLHeadingElement> {
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div" | "span",
className?: string,
size?: DisplaySizeProp;
align?: TextAlignProp;
weight?: TextWeightProp
}
export const ReactDisplay = ({
size,
as = "h1",
weight,
align,
children,
className,
...props
}: DisplayProps) => {
const DisplayElement = as
return (
<DisplayElement className={display({
size,
weight,
align,
className: cn("font-title font-extrabold", className)
})} {...props}>
<ReactBalancer.Balancer>
{children}
</ReactBalancer.Balancer>
</DisplayElement>
)
}
ReactDisplay.displayName = "Display"
export const Display = qwikify$(ReactDisplay)

View File

@@ -0,0 +1,142 @@
/* eslint-disable qwik/no-react-props */
/** @jsxImportSource react */
import { qwikify$ } from "@builder.io/qwik-react";
import { motion } from "framer-motion"
import { ReactDisplay } from "@/react/display"
import * as React from "react"
// type Props = {
// children?: React.ReactElement[]
// }
const transition = {
type: "spring",
stiffness: 100,
damping: 15,
restDelta: 0.001,
duration: 0.01,
}
type Props = {
children?: React.ReactNode;
}
export function ReactHeroSection({ children }: Props) {
return (
<>
<section className="px-4" >
<header className="overflow-hidden mx-auto max-w-xl pt-20 pb-1">
<motion.img
initial={{
opacity: 0,
y: 120
}}
whileInView={{
y: 0,
opacity: 1
}}
viewport={{ once: true }}
transition={{
...transition
}}
src="/logo.webp" alt="Nestri Logo" height={80} width={80} draggable={false} className="w-[70px] md:w-[80px] aspect-[90/69]" />
<div className="my-4 sm:mt-8">
<ReactDisplay className="mb-4 sm:text-8xl text-[3.5rem] text-balance tracking-tight leading-none" >
<motion.span
initial={{
opacity: 0,
y: 100
}}
whileInView={{
y: 0,
opacity: 1
}}
transition={{
delay: 0.1,
...transition
}}
viewport={{ once: true }}
className="inline-block" >
Your games.
</motion.span>
<motion.span
initial={{
opacity: 0,
y: 80
}}
transition={{
delay: 0.2,
...transition
}}
whileInView={{
y: 0,
opacity: 1
}}
viewport={{ once: true }}
className="inline-block" >
Your rules.
</motion.span>
</ReactDisplay>
<motion.p
initial={{
opacity: 0,
y: 50
}}
transition={{
delay: 0.3,
...transition
}}
whileInView={{
y: 0,
opacity: 1
}}
viewport={{ once: true }}
className="dark:text-primary-50/70 text-primary-950/70 text-lg font-normal tracking-tight sm:text-xl"
>
Nestri lets you play games on your own terms invite friends to join your gaming sessions, share your game library, and take even more control by hosting your own server.
</motion.p>
<motion.div
initial={{
opacity: 0,
y: 60
}}
transition={{
delay: 0.4,
...transition
}}
whileInView={{
y: 0,
opacity: 1
}}
viewport={{ once: true }}
className="flex items-center justify-center mt-4 w-full"
>
{children}
</motion.div>
</div>
</header>
</section>
</>
)
}
// export const ReactGpadAnimation = ({ index, children, className }: { className?: string, index: number, children?: React.ReactElement }) => {
// return (
// <motion.div
// className={className}
// animate={{
// scale: [1, 1.1, 1],
// opacity: [0.5, 1, 0.5],
// }}
// transition={{
// duration: 3,
// repeat: Infinity,
// delay: index * 0.2,
// }}
// >
// {children}
// </motion.div>
// )
// }
// export const GpadAnimation = qwikify$(ReactGpadAnimation)
export const HeroSection = qwikify$(ReactHeroSection)

View File

@@ -0,0 +1,7 @@
export * from "./hero-section"
export * from "./react-example"
export * from "./cursor"
export * from "./title-section"
export * from "./motion"
export * from "./title"
export * from "./text"

View File

@@ -0,0 +1,98 @@
import { cn } from "@/design";
import { component$, useStore, type Component } from "@builder.io/qwik";
interface MarqueeProps<T> {
parentClass?: string;
itemClass?: string;
items: T[];
pauseOnHover?: boolean;
renderItem: Component<{ item: T; index: number }>;
reverse?: boolean;
direction?: 'horizontal' | 'vertical';
pad?: boolean;
translate?: 'track' | 'items';
state?: 'running' | 'paused';
spill?: boolean;
diff?: boolean;
inset?: number;
outset?: number;
speed?: number;
scale?: number;
}
const renderStamp = Date.now()
export const Marquee = component$(<T,>({
parentClass,
itemClass,
reverse = false,
items,
renderItem: RenderItem,
direction = 'horizontal',
translate = 'items',
state = 'running',
pad = false,
spill = false,
diff = false,
inset = 0,
outset = 0,
pauseOnHover = false,
speed = 10,
scale = 1,
}: MarqueeProps<T>) => {
const store = useStore({
indices: Array.from({ length: items.length }, (_, i) => i),
});
return (
<div
class={cn("marquee-container", parentClass)}
data-direction={direction}
data-pad={pad}
data-pad-diff={diff}
data-pause-on-hover={pauseOnHover ? 'true' : 'false'}
data-translate={translate}
data-play-state={state}
data-spill={spill}
data-reverse={reverse}
style={{ '--speed': speed, '--count': items.length, '--scale': scale, '--inset': inset, '--outset': outset }}
>
<ul>
{pad && translate === 'track'
? store.indices.map((index) => {
return (
<li
aria-hidden="true"
class={cn("pad pad--negative", itemClass)}
key={`pad-negative-${renderStamp}--${index}`}
>
<RenderItem item={items[index]} index={index} />
</li>
)
})
: null}
{store.indices.map((index) => {
return (
<li key={`index-${renderStamp}--${index}`} style={{ '--index': index }}>
<RenderItem item={items[index]} index={index} />
</li>
)
})}
{pad && translate === 'track'
? store.indices.map((index) => {
return (
<li
aria-hidden="true"
class={cn("pad pad--positive", itemClass)}
key={`pad-positive-${renderStamp}--${index}`}
>
<RenderItem item={items[index]} index={index} />
</li>
)
})
: null}
</ul>
</div>
);
})

View File

@@ -0,0 +1,39 @@
/** @jsxImportSource react */
import { qwikify$ } from '@builder.io/qwik-react';
import { motion, type MotionProps } from 'framer-motion';
import { type ReactNode } from 'react';
interface MotionComponentProps extends MotionProps {
as?: keyof JSX.IntrinsicElements;
children?: ReactNode;
class?: string;
}
export const transition = {
type: "spring",
stiffness: 100,
damping: 15,
restDelta: 0.001,
duration: 0.01,
delay: 0.4,
}
export const ReactMotionComponent = ({
as = 'div',
children,
class: className,
...motionProps
}: MotionComponentProps) => {
const MotionTag = motion[as as keyof typeof motion];
return (
<MotionTag className={className}
{...motionProps}
// animate={isInView ? whileInView : undefined}
>
{children}
</MotionTag>
);
};
export const MotionComponent = qwikify$(ReactMotionComponent);

View File

@@ -0,0 +1,117 @@
/** @jsxImportSource react */
import React from 'react'
import { motion } from 'framer-motion'
import { cn } from '@/design'
import { qwikify$ } from '@builder.io/qwik-react';
export const CursorSVG = ({ flip }: { flip?: boolean }) => (
<svg fill="none" height="18"
className={cn(flip ? 'scale-x-[-1]' : '', 'mb-9')}
viewBox="0 0 17 18" width="17">
<path
d="M15.5036 3.11002L12.5357 15.4055C12.2666 16.5204 10.7637 16.7146 10.22 15.7049L7.4763 10.6094L2.00376 8.65488C0.915938 8.26638 0.891983 6.73663 1.96711 6.31426L13.8314 1.65328C14.7729 1.28341 15.741 2.12672 15.5036 3.11002ZM7.56678 10.6417L7.56645 10.6416C7.56656 10.6416 7.56667 10.6416 7.56678 10.6417L7.65087 10.4062L7.56678 10.6417Z"
fill="currentColor"
style={{
stroke: `var(--cursor-color)`,
fill: `var(--cursor-color-light)`,
}}
stroke="currentColor"
// className={cn(color ? `stroke-${color}-400 text-${color}-500` : 'stroke-primary-400 text-primary-500')}
strokeWidth="1.5"
/>
</svg>
)
type CursorProps = { class?: string; text: string, color?: string, flip?: boolean }
const hexToRGBA = (hex: string, alpha: number = 1) => {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
export const ReactMyCursor: React.FC<CursorProps> = ({
class: className,
text,
color = "#3B82F6",
flip = false,
}) => {
const containerRef = React.useRef<HTMLDivElement>(null);
const [position, setPosition] = React.useState({ x: 0, y: 0 });
React.useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleMouseMove = (event: MouseEvent) => {
const rect = container.getBoundingClientRect();
setPosition({
x: event.clientX - rect.left,
y: event.clientY - rect.top
});
console.log(event.clientX - rect.left, event.clientY - rect.top)
};
window.addEventListener('mousemove', handleMouseMove);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
};
}, []);
const cursorElement = <CursorSVG flip={flip} />;
const textElement = (
<div
style={{
backgroundColor: `var(--cursor-color)`,
borderColor: `var(--cursor-color-dark)`,
}}
className={cn(
'w-fit rounded-full py-1 px-2 text-white',
)}
>
{text}
</div>
);
const colorStyles = {
'--cursor-color': color,
'--cursor-color-light': hexToRGBA(color, 0.7),
'--cursor-color-dark': hexToRGBA(color, 0.8),
} as React.CSSProperties;
return (
<div
ref={containerRef}
style={{
...colorStyles,
position: 'fixed',
pointerEvents: 'none',
zIndex: 9999,
transform: `translate(${position.x}px, ${position.y}px)`,
}}
className="flex items-center"
>
<div className={cn('flex items-center', className)}>
{flip ? (
<>
{cursorElement}
{textElement}
</>
) : (
<>
{textElement}
{cursorElement}
</>
)}
</div>
</div>
);
}
export const MyCursor = qwikify$(ReactMyCursor)

View File

@@ -0,0 +1,28 @@
/** @jsxImportSource react */
//This is used for testing whether the Qwik React components are working
import { qwikify$ } from "@builder.io/qwik-react"
import { motion } from "framer-motion"
export const ReactExample = () => {
return (
<div>
<motion.div
style={{
width: "100px",
height: "100px",
backgroundColor: "red",
}}
animate={{y:0}}
initial={{y:100}}
// whileHover={{ scale: 1.2, rotate: 90 }}
// whileTap={{
// scale: 0.8,
// rotate: -90,
// borderRadius: "100%"
// }}
/>
</div>
)
}
export const Example = qwikify$(ReactExample)

View File

@@ -0,0 +1,119 @@
/** @jsxImportSource react */
import { cn } from "@/design";
import { qwikify$ } from "@builder.io/qwik-react";
import React, { useEffect, useRef, useState } from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
useScroll,
useSpring,
useTransform,
useVelocity,
} from "framer-motion";
interface VelocityScrollProps {
text: string;
default_velocity?: number;
class?: string;
}
interface ParallaxProps {
children: string;
baseVelocity: number;
class?: string;
}
export const wrap = (min: number, max: number, v: number) => {
const rangeSize = max - min;
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min;
};
export function ReactMarquee({
class: className,
text,
default_velocity = 5,
}: VelocityScrollProps) {
function ParallaxText({
children,
baseVelocity = 100,
class:className,
}: ParallaxProps) {
const baseX = useMotionValue(0);
const { scrollY } = useScroll();
const scrollVelocity = useVelocity(scrollY);
const smoothVelocity = useSpring(scrollVelocity, {
damping: 50,
stiffness: 400,
});
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, 5], {
clamp: false,
});
const [repetitions, setRepetitions] = useState(1);
const containerRef = useRef<HTMLDivElement>(null);
const textRef = useRef<HTMLSpanElement>(null);
useEffect(() => {
const calculateRepetitions = () => {
if (containerRef.current && textRef.current) {
const containerWidth = containerRef.current.offsetWidth;
const textWidth = textRef.current.offsetWidth;
const newRepetitions = Math.ceil(containerWidth / textWidth) + 2;
setRepetitions(newRepetitions);
}
};
calculateRepetitions();
window.addEventListener("resize", calculateRepetitions);
return () => window.removeEventListener("resize", calculateRepetitions);
}, [children]);
const x = useTransform(baseX, (v) => `${wrap(-100 / repetitions, 0, v)}%`);
const directionFactor = React.useRef<number>(1);
useAnimationFrame((t, delta) => {
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
if (velocityFactor.get() < 0) {
directionFactor.current = -1;
} else if (velocityFactor.get() > 0) {
directionFactor.current = 1;
}
moveBy += directionFactor.current * moveBy * velocityFactor.get();
baseX.set(baseX.get() + moveBy);
});
return (
<div
className="w-full overflow-hidden whitespace-nowrap"
ref={containerRef}
>
<motion.div className={cn("inline-block", className)} style={{ x }}>
{Array.from({ length: repetitions }).map((_, i) => (
<span key={i} ref={i === 0 ? textRef : null}>
{children}{" "}
</span>
))}
</motion.div>
</div>
);
}
return (
<section className="relative w-full">
<ParallaxText baseVelocity={default_velocity} class={className}>
{text}
</ParallaxText>
<ParallaxText baseVelocity={-default_velocity} class={className}>
{text}
</ParallaxText>
</section>
);
}
export const Marquee = qwikify$(ReactMarquee);

View File

@@ -0,0 +1,70 @@
/** @jsxImportSource react */
import React from "react"
import {
text,
type TextProps as TextVariants,
type TextAlignProp,
type TextWeightProp,
cn
} from "@/design"
// import * as ReactBalancer from "react-wrap-balancer"
import { qwikify$ } from "@builder.io/qwik-react"
type TextSize = TextVariants["size"]
type TitleSizeProp = TextSize | {
initial?: TextSize,
sm?: TextSize,
md?: TextSize,
lg?: TextSize,
xl?: TextSize,
xxl?: TextSize,
}
export interface TextProps extends React.HTMLAttributes<HTMLParagraphElement | HTMLSpanElement | HTMLDivElement> {
as?: "p" | "div" | "span" | "em" | "strong",
className?: string,
size?: TitleSizeProp;
align?: TextAlignProp;
weight?: TextWeightProp;
neutral?: boolean;
}
export const ReactText: React.FC<TextProps> = ({
size,
as = "p",
weight,
align,
neutral,
children,
className,
...props
}) => {
const TextElement = as
if (as === "strong") {
weight = weight || "medium"
neutral = neutral || true
} else if (as === "em") {
neutral = neutral || true
}
return (
<TextElement className={text({
size,
weight,
align,
neutral,
className: cn("dark:text-primary-50/70 text-primary-950/70", className)
})} {...props}>
{/* <ReactBalancer.Balancer> */}
{children}
{/* </ReactBalancer.Balancer> */}
</TextElement>
)
}
ReactText.displayName = "Text"
export const Text = qwikify$(ReactText)

View File

@@ -0,0 +1,88 @@
/* eslint-disable qwik/no-react-props */
/** @jsxImportSource react */
import { qwikify$ } from "@builder.io/qwik-react";
import { motion } from "framer-motion"
import { ReactDisplay } from "@/react/display"
// type Props = {
// children?: React.ReactElement[]
// }
const transition = {
type: "spring",
stiffness: 100,
damping: 15,
restDelta: 0.001,
duration: 0.01,
}
type Props = {
title: string
description: string | string[]
}
export function ReactTitleSection({ title, description }: Props) {
return (
<>
<section className="px-4" >
<header className="overflow-hidden mx-auto max-w-xl pt-20 pb-4">
<motion.img
initial={{
opacity: 0,
y: 120
}}
whileInView={{
y: 0,
opacity: 1
}}
transition={{
...transition
}}
viewport={{ once: true }}
src="/logo.webp" alt="Nestri Logo" height={80} width={80} draggable={false} className="w-[70px] md:w-[80px] aspect-[90/69]" />
<div className="my-4 sm:my-8">
<ReactDisplay className="mb-4 sm:text-8xl text-[3.5rem] text-balance tracking-tight leading-none" >
<motion.span
initial={{
opacity: 0,
y: 100
}}
whileInView={{
y: 0,
opacity: 1
}}
transition={{
delay: 0.1,
...transition
}}
viewport={{ once: true }}
className="inline-block" >
{title}
</motion.span>
</ReactDisplay>
<motion.p
initial={{
opacity: 0,
y: 50
}}
transition={{
delay: 0.3,
...transition
}}
whileInView={{
y: 0,
opacity: 1
}}
viewport={{ once: true }}
className="dark:text-primary-50/70 text-primary-950/70 text-lg font-normal tracking-tight sm:text-xl"
>
{Array.isArray(description) ? description.map((item, index) => {
return <span key={`id-${index}`}>{item} <br /> </span>
}) : description}
</motion.p>
</div>
</header>
</section>
</>
)
}
export const TitleSection = qwikify$(ReactTitleSection)

View File

@@ -0,0 +1,56 @@
/** @jsxImportSource react */
import React from "react"
import {
title,
type TitleProps as TitleVariants,
type TextAlignProp,
type TextWeightProp,
cn
} from "@/design"
import { qwikify$ } from "@builder.io/qwik-react";
type TitleSize = TitleVariants["size"]
type TitleSizeProp = TitleSize | {
initial?: TitleSize,
sm?: TitleSize,
md?: TitleSize,
lg?: TitleSize,
xl?: TitleSize,
xxl?: TitleSize,
}
export interface TitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
as?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "div" | "span",
className?: string,
size?: TitleSizeProp;
align?: TextAlignProp;
weight?: TextWeightProp
}
export const ReactTitle: React.FC<TitleProps> = ({
size,
as = "h1",
weight,
align,
children,
className,
...props
}) => {
const TitleElement = as
return (
<TitleElement className={title({
size,
weight,
align,
className: cn("font-title dark:text-primary-50 text-primary-950", className)
})} {...props}>
{children}
</TitleElement>
)
}
ReactTitle.displayName = "Title"
export const Title = qwikify$(ReactTitle)

View File

@@ -0,0 +1,65 @@
import { component$, useSignal } from "@builder.io/qwik"
import { cn } from "./design"
type Props = {
class?: string
}
const minValue = 2;
const maxValue = 6;
const teamSizes = Array.from({ length: maxValue - minValue + 1 }, (_, i) => i + minValue);
export const TeamCounter = component$(({ class: className }: Props) => {
const teammates = useSignal(2)
const shake = useSignal(false)
return (
<div class={cn("flex items-center justify-center", shake.value && "animate-shake", className)}>
<button
onClick$={() => {
if (teammates.value == minValue) {
shake.value = true
setTimeout(() => {
shake.value = false
}, 500);
} else {
teammates.value = Math.max(minValue, teammates.value - 1)
}
}}
class={cn("size-[30px] rounded-full bg-gray-300 dark:bg-gray-700 flex items-center justify-center active:scale-90", teammates.value <= minValue && "opacity-30")}>
<svg xmlns="http://www.w3.org/2000/svg" class="size-[15px]" viewBox="0 0 256 256"><path fill="currentColor" d="M228 128a12 12 0 0 1-12 12H40a12 12 0 0 1 0-24h176a12 12 0 0 1 12 12" /></svg>
</button>
<div class="flex items-center justify-center mx-2 mt-1">
<p class="text-lg font-normal w-full flex items-center justify-center">
<span
class="w-[2ch] relative inline-block h-[2.625rem] overflow-hidden [mask:linear-gradient(#0000,_#000_35%_65%,_#0000)]">
<span
style={{
translate: `0 calc((${teammates.value - (minValue + 1)} + 1) * (2.625rem * -1))`,
transition: `translate 0.625s linear(0 0%,0.5007 7.21%,0.7803 12.29%,0.8883 14.93%,0.9724 17.63%,1.0343 20.44%,1.0754 23.44%,1.0898 25.22%,1.0984 27.11%,1.1014 29.15%,1.0989 31.4%,1.0854 35.23%,1.0196 48.86%,1.0043 54.06%,0.9956 59.6%,0.9925 68.11%,1 100%)`
}}
class="absolute w-full flex top-0 flex-col">
{teamSizes.map((num) => (
<span
class="w-full font-title h-[2.625rem] flex flex-col items-center justify-center leading-[1rem]"
key={`team-member-${num}`} >+{num}</span>
))}
</span>
</span>
</p>
</div>
<button onClick$={() => {
if (teammates.value == maxValue) {
shake.value = true
setTimeout(() => {
shake.value = false
}, 500);
} else {
teammates.value = Math.min(maxValue, teammates.value + 1)
}
}}
class={cn("size-[30px] rounded-full bg-gray-300 dark:bg-gray-700 flex items-center justify-center active:scale-90", teammates.value >= maxValue && "opacity-30")}>
<svg xmlns="http://www.w3.org/2000/svg" class="size-[15px]" viewBox="0 0 256 256"><path fill="currentColor" d="M228 128a12 12 0 0 1-12 12h-76v76a12 12 0 0 1-24 0v-76H40a12 12 0 0 1 0-24h76V40a12 12 0 0 1 24 0v76h76a12 12 0 0 1 12 12" /></svg>
</button>
</div>
)
})

View File

@@ -0,0 +1,24 @@
import { component$, Slot } from "@builder.io/qwik"
import { cn } from "./design";
type Props = {
position: "bottom" | "top" | "left" | "right",
text: string;
}
const textPosition = {
top: "bottom-[125%] left-1/2 -ml-[60px] after:absolute after:left-1/2 after:top-[100%] after:-ml-[5px] after:border-[5px] after:border-[#000_transparent_transparent_transparent]"
}
export const Tooltip = component$(({ position, text }: Props) => {
return (
<>
<Slot />
{/**@ts-ignore */}
<span class={cn("invisible absolute w-[120px] group-hover:visible group-hover:opacity-100 text-white bg-black text-center py-1 rounded-md", textPosition[position])} >
{text}
</span>
</>
)
})

View File

@@ -8,6 +8,6 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "./*.d.ts"],
"exclude": ["node_modules", "dist"]
"files": [".eslintrc.js"],
"include": ["src", "./*.d.ts"]
}

View File

@@ -1,8 +1,8 @@
{
"extends": "@nestri/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist"
"outDir": "dist",
},
"include": ["src", "./*.config.js", "./.eslintrc.js"],
"include": ["src", "./*.config.js","./.eslintrc.js"],
"exclude": ["node_modules", "dist"]
}