This commit is contained in:
AquaWolf
2025-03-15 21:06:30 +01:00
parent b734892c55
commit 5189bf768a
23 changed files with 890 additions and 102 deletions

View File

@@ -1,13 +1,5 @@
{
"name": "nestri",
"private": true,
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"lint": "turbo lint",
"sso": "aws sso login --sso-session=nestri --no-browser --use-device-code"
},
"devDependencies": {
"@cloudflare/workers-types": "4.20240821.1",
"@pulumi/pulumi": "^3.134.0",
@@ -18,16 +10,21 @@
"engines": {
"node": ">=18"
},
"packageManager": "bun@1.1.18",
"workspaces": [
"apps/*",
"packages/*"
],
"packageManager": "bun@1.2.4",
"private": true,
"scripts": {
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"sso": "aws sso login --sso-session=nestri --no-browser --use-device-code"
},
"trustedDependencies": [
"core-js-pure",
"esbuild",
"workerd"
],
"workspaces": [
"apps/*",
"packages/*"
],
"dependencies": {
"sst": "3.9.1"
}

View File

@@ -31,6 +31,7 @@
"@aws-sdk/client-sesv2": "^3.753.0",
"@instantdb/admin": "^0.17.7",
"@neondatabase/serverless": "^0.10.4",
"@openauthjs/openauth": "0.4.3",
"@openauthjs/openevent": "^0.0.27",
"@polar-sh/sdk": "^0.26.1",
"drizzle-orm": "^0.39.3",

View File

@@ -1,4 +1,3 @@
import { teamID } from "./drizzle/types";
import { prefixes } from "./utils";
export module Examples {
export const Id = (prefix: keyof typeof prefixes) =>
@@ -18,7 +17,7 @@ export module Examples {
name: "John Does' Team",
slug: "john_doe",
}
export const Member = {
id: Id("member"),
email: "john@example.com",
@@ -26,4 +25,9 @@ export module Examples {
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
}
export const Polar = {
teamID: Id("team"),
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
}
}

View File

@@ -1,8 +0,0 @@
import { Resource } from "sst";
import { Polar as PolarSdk } from "@polar-sh/sdk";
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
export module Polar {
export const client = polar;
}

View File

@@ -0,0 +1,169 @@
import { z } from "zod";
import { fn } from "../utils";
import { Resource } from "sst";
import { eq, and } from "../drizzle";
import { useTeam } from "../actor";
import { createEvent } from "../event";
import { polarTable, Standing } from "./polar.sql";
import { Polar as PolarSdk } from "@polar-sh/sdk";
import { useTransaction } from "../drizzle/transaction";
const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" });
export module Polar {
export const client = polar;
export const Info = z.object({
teamID: z.string(),
customerID: z.string(),
subscriptionID: z.string().nullable(),
subscriptionItemID: z.string().nullable(),
standing: z.enum(Standing),
});
export type Info = z.infer<typeof Info>;
export const Checkout = z.object({
annual: z.boolean().optional(),
successUrl: z.string(),
cancelUrl: z.string(),
});
export const CheckoutSession = z.object({
url: z.string().nullable(),
});
export const CustomerSubscriptionEventType = [
"created",
"updated",
"deleted",
] as const;
export const Events = {
CustomerSubscriptionEvent: createEvent(
"polar.customer-subscription-event",
z.object({
type: z.enum(CustomerSubscriptionEventType),
status: z.string(),
teamID: z.string().min(1),
customerID: z.string().min(1),
subscriptionID: z.string().min(1),
subscriptionItemID: z.string().min(1),
}),
),
};
export function get() {
return useTransaction(async (tx) =>
tx
.select()
.from(polarTable)
.where(eq(polarTable.teamID, useTeam()))
.execute()
.then((rows) => rows.map(serialize).at(0)),
);
}
export const fromUserEmail = fn(z.string().min(1), async (email) => {
try {
const customers = await client.customers.list({ email })
if (customers.result.items.length === 0) {
return await client.customers.create({ email })
} else {
return customers.result.items[0]
}
} catch (err) {
//FIXME: This is the issue [Polar.sh/#5147](https://github.com/polarsource/polar/issues/5147)
// console.log("error", err)
return undefined
}
})
export const setCustomerID = fn(Info.shape.customerID, async (customerID) =>
useTransaction(async (tx) =>
tx
.insert(polarTable)
.values({
teamID: useTeam(),
customerID,
standing: "new",
})
.execute(),
),
);
export const setSubscription = fn(
Info.pick({
subscriptionID: true,
subscriptionItemID: true,
}),
(input) =>
useTransaction(async (tx) =>
tx
.update(polarTable)
.set({
subscriptionID: input.subscriptionID,
subscriptionItemID: input.subscriptionItemID,
})
.where(eq(polarTable.teamID, useTeam()))
.returning()
.execute()
.then((rows) => rows.map(serialize).at(0)),
),
);
export const removeSubscription = fn(
z.string().min(1),
(stripeSubscriptionID) =>
useTransaction((tx) =>
tx
.update(polarTable)
.set({
subscriptionItemID: null,
subscriptionID: null,
})
.where(and(eq(polarTable.subscriptionID, stripeSubscriptionID)))
.execute(),
),
);
export const setStanding = fn(
Info.pick({
subscriptionID: true,
standing: true,
}),
(input) =>
useTransaction((tx) =>
tx
.update(polarTable)
.set({ standing: input.standing })
.where(and(eq(polarTable.subscriptionID, input.subscriptionID!)))
.execute(),
),
);
export const fromCustomerID = fn(Info.shape.customerID, (customerID) =>
useTransaction((tx) =>
tx
.select()
.from(polarTable)
.where(and(eq(polarTable.customerID, customerID)))
.execute()
.then((rows) => rows.map(serialize).at(0)),
),
);
function serialize(
input: typeof polarTable.$inferSelect,
): z.infer<typeof Info> {
return {
teamID: input.teamID,
customerID: input.customerID,
subscriptionID: input.subscriptionID,
subscriptionItemID: input.subscriptionItemID,
standing: input.standing,
};
}
}

View File

@@ -0,0 +1,22 @@
import { timestamps, teamID } from "../drizzle/types";
import { teamIndexes, teamTable } from "../team/team.sql";
import { pgTable, text, varchar } from "drizzle-orm/pg-core";
export const Standing = ["new", "good", "overdue"] as const;
export const polarTable = pgTable(
"polar",
{
teamID: teamID.teamID.primaryKey().references(() => teamTable.id),
...timestamps,
customerID: varchar("customer_id", { length: 255 }).notNull(),
subscriptionID: varchar("subscription_id", { length: 255 }),
subscriptionItemID: varchar("subscription_item_id", {
length: 255,
}),
standing: text("standing", { enum: Standing }).notNull(),
},
(table) => ({
...teamIndexes(table),
})
)

View File

@@ -3,13 +3,13 @@ import { Resource } from "sst";
import { bus } from "sst/aws/bus";
import { Common } from "../common";
import { createID, fn } from "../utils";
import { VisibleError } from "../error";
import { Examples } from "../examples";
import { teamTable } from "./team.sql";
import { createEvent } from "../event";
import { assertActor } from "../actor";
import { assertActor, withActor } from "../actor";
import { and, eq, sql } from "../drizzle";
import { memberTable } from "../member/member.sql";
import { HTTPException } from 'hono/http-exception';
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
export module Team {
@@ -45,11 +45,11 @@ export module Team {
),
};
export class WorkspaceExistsError extends VisibleError {
export class TeamExistsError extends HTTPException {
constructor(slug: string) {
super(
"team.slug_exists",
`there is already a workspace named "${slug}"`,
400,
{ message: `There is already a team named "${slug}"`, }
);
}
}
@@ -65,15 +65,16 @@ export module Team {
slug: input.slug,
name: input.name
})
.onConflictDoNothing()
.returning({ insertedID: teamTable.id })
.onConflictDoNothing({ target: teamTable.slug })
if (result.length === 0) throw new WorkspaceExistsError(input.slug);
if (!result.rowCount) throw new TeamExistsError(input.slug);
await afterTx(() =>
bus.publish(Resource.Bus, Events.Created, {
teamID: id,
}),
withActor({ type: "system", properties: { teamID: id } }, () =>
bus.publish(Resource.Bus, Events.Created, {
teamID: id,
})
),
);
return id;
})

View File

@@ -1,4 +1,6 @@
import { z } from "zod";
import { Polar } from "../polar";
import { Team } from "../team";
import { bus } from "sst/aws/bus";
import { Common } from "../common";
import { createID, fn } from "../utils";
@@ -11,7 +13,6 @@ import { assertActor, withActor } from "../actor";
import { memberTable } from "../member/member.sql";
import { and, eq, isNull, asc, getTableColumns, sql } from "../drizzle";
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
import { Team } from "../team";
export module User {
@@ -106,12 +107,8 @@ export module User {
//FIXME: Do this much later, as Polar.sh has so many inconsistencies for fuck's sake
// const customer = await Polar.client.customers.create({
// email: input.email,
// metadata: {
// userID,
// },
// });
const customer = await Polar.fromUserEmail(input.email)
console.log("customer", customer)
const name = sanitizeUsername(input.name);
@@ -131,6 +128,7 @@ export module User {
avatarUrl: input.avatarUrl,
email: input.email,
discriminator: Number(discriminator),
polarCustomerID: customer?.id
})
await afterTx(() =>
withActor({

View File

@@ -14,7 +14,7 @@
"typescript": "^5.0.0"
},
"dependencies": {
"@openauthjs/openauth": "^0.3.9",
"@openauthjs/openauth": "0.4.3",
"hono": "^4.6.15",
"hono-openapi": "^0.3.1",
"partysocket": "1.0.3"

View File

@@ -42,8 +42,10 @@ export module AccountApi {
}),
async (c) => {
const actor = assertActor("user");
const currentUser = await User.fromID(actor.properties.userID)
if (!currentUser) return c.json({ error: "This account does not exist, it may have been deleted" }, 404)
const [currentUser, teams] = await Promise.all([User.fromID(actor.properties.userID), User.teams()])
if (!currentUser) return c.json({ error: "This account does not exist; it may have been deleted" }, 404)
const { id, email, name, polarCustomerID, avatarUrl, discriminator } = currentUser
return c.json({
@@ -51,10 +53,10 @@ export module AccountApi {
id,
email,
name,
teams,
avatarUrl,
discriminator,
polarCustomerID,
teams: await User.teams(),
}
}, 200);
},

View File

@@ -1876,7 +1876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34"
dependencies = [
"cfg-if",
"windows-targets 0.52.6",
"windows-targets 0.48.5",
]
[[package]]
@@ -2668,9 +2668,9 @@ dependencies = [
[[package]]
name = "ring"
version = "0.17.11"
version = "0.17.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da5349ae27d3887ca812fb375b45a4fbb36d8d12d2df394968cd86e35683fe73"
checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee"
dependencies = [
"cc",
"cfg-if",

View File

@@ -9,12 +9,12 @@
<meta
name="theme-color"
media="(prefers-color-scheme: light)"
content="hsla(0,0%,98%)"
content="#f5f5f5"
/>
<meta
name="theme-color"
media="(prefers-color-scheme: dark)"
content="hsla(0,0%,0%)"
content="#171717"
/>
<link
rel="apple-touch-icon"

View File

@@ -19,15 +19,19 @@
},
"dependencies": {
"@fontsource-variable/geist-mono": "^5.0.1",
"@nestri/core": "*",
"@fontsource-variable/mona-sans": "^5.0.1",
"@fontsource/geist-sans": "^5.1.0",
"@macaron-css/core": "^1.5.2",
"@macaron-css/solid": "^1.5.3",
"@modular-forms/solid": "^0.25.1",
"@nestri/core": "*",
"@openauthjs/openauth": "0.4.3",
"@solid-primitives/storage": "^4.3.1",
"@solidjs/router": "^0.15.3",
"modern-normalize": "^3.0.1",
"solid-js": "^1.9.5",
"solid-notifications": "^1.1.2"
"solid-notifications": "^1.1.2",
"valibot": "^1.0.0-rc.3",
"zod": "^3.24.2"
}
}

View File

@@ -9,6 +9,7 @@ import '@fontsource/geist-sans/900.css';
import { PlayComponent } from './pages/play';
import { styled } from "@macaron-css/solid";
import { useStorage } from './providers/account';
import { CreateTeamComponent } from './pages/new';
import { darkClass, lightClass, theme } from './ui/theme';
import { AuthProvider, useAuth } from './providers/auth';
import { Navigate, Route, Router } from "@solidjs/router";
@@ -88,14 +89,14 @@ export const App: Component = () => {
<Route
path="*"
component={(props) => (
// <AuthProvider>
// {props.children}
props.children
// </AuthProvider>
<AuthProvider>
{props.children}
</AuthProvider>
// props.children
)}
>
<Route path="play/:id" component={PlayComponent} />
<Route path="test" component={TestComponent} />
<Route path="new" component={CreateTeamComponent} />
<Route
path="/"
component={() => {

View File

@@ -1,7 +0,0 @@
export function DefaultState() {
return (
<div>
We are logging you in
</div>
)
}

View File

@@ -1,15 +1,168 @@
import * as v from "valibot"
import { styled } from "@macaron-css/solid";
import { Text } from "@nestri/www/ui/text";
import { utility } from "@nestri/www/ui/utility";
import { theme } from "@nestri/www/ui/theme";
import { FormField, Input, Select } from "@nestri/www/ui/form";
import { Container, FullScreen } from "@nestri/www/ui/layout";
import { createForm, required, email, valiForm } from "@modular-forms/solid";
import { Button } from "@nestri/www/ui";
// const nameRegex = /^[a-z]+$/
const FieldList = styled("div", {
base: {
width: "100%",
maxWidth: 380,
...utility.stack(5),
},
});
const Hr = styled("hr", {
base: {
border: 0,
backgroundColor: theme.color.gray.d400,
width: "100%",
height: 1,
}
})
const Plan = {
Pro: 'BYOG',
Basic: 'Hosted',
} as const;
const schema = v.object({
plan: v.pipe(
v.enum(Plan),
v.minLength(2,"Please choose a plan"),
),
display_name: v.pipe(
v.string(),
v.maxLength(32, 'Please use 32 characters at maximum.'),
),
slug: v.pipe(
v.string(),
v.minLength(2, 'Please use 2 characters at minimum.'),
// v.regex(nameRegex, "Use only small letters, no numbers or special characters"),
v.maxLength(48, 'Please use 48 characters at maximum.'),
)
})
// const Details = styled("details", {
// base: {
// overflow: "hidden",
// transition: "max-height .2s ease"
// }
// })
// const Summary = styled("summary", {
// base: {
// userSelect: "none",
// cursor: "pointer",
// listStyle: "none"
// }
// })
// const SVG = styled("svg", {
// base: {
// color: theme.color.gray.d900,
// width: 20,
// height: 20,
// marginRight: theme.space[2]
// }
// })
// const Subtitle = styled("p", {
// base: {
// color: theme.color.gray.d900,
// fontSize: theme.font.size.sm,
// fontWeight: theme.font.weight.regular,
// lineHeight: "1rem"
// }
// })
export function CreateTeamComponent() {
const [form, { Form, Field }] = createForm({
validate: valiForm(schema),
});
export function TeamCreate() {
return (
<FullScreen>
<Container flow="column" >
<Text align="center" spacing="lg" size="4xl" weight="semibold">
Your first deploy is just a sign-up away.
</Text>
<Container horizontal="center" style={{ width: "100%", padding: "1rem", }} space="1" >
<Container style={{ "width": "100%", "max-width": "380px" }} horizontal="start" space="3" >
<Text font="heading" spacing="none" size="3xl" weight="semibold">
Create a Team
</Text>
<Text style={{ color: theme.color.gray.d900 }} size="sm">
Choose something that your teammates will recognize
</Text>
<Hr />
</Container>
<Form style={{ width: "100%", "max-width": "380px" }}>
<FieldList>
<Field type="string" name="slug">
{(field, props) => (
<FormField
label="Team Name"
hint={
field.error
&& field.error
// : "Needs to be lowercase, unique, and URL friendly."
}
color={field.error ? "danger" : "primary"}
>
<Input
{...props}
autofocus
placeholder="Jane Doe's Team"
/>
</FormField>
)}
</Field>
<Field type="string" name="plan">
{(field, props) => (
<FormField
label="Plan Type"
hint={
field.error
&& field.error
// : "Needs to be lowercase, unique, and URL friendly."
}
color={field.error ? "danger" : "primary"}
>
<Select
{...props}
value={field.value}
badges={[
{ label: "BYOG", color: "purple" },
{ label: "Hosted", color: "blue" },
]}
options={[
{ label: "I'll be playing on my machine", value: 'BYOG' },
{ label: "I'll be playing on the cloud", value: 'Hosted' },
]}
/>
</FormField>
)}
</Field>
{/* <Details>
<Summary>
<div style={{ "display": "flex", "align-items": "center" }}>
<SVG xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M8.59 16.59L13.17 12L8.59 7.41L10 6l6 6l-6 6z" /></SVG>
<Subtitle>
Continuing will start a 14-day Pro plan trial.
</Subtitle>
</div>
</Summary>
</Details> */}
<Button color="brand">
Continue
</Button>
</FieldList>
</Form>
</Container>
</FullScreen>
)
}

View File

@@ -23,6 +23,8 @@ interface Storage {
current?: string;
}
//TODO: Fix bug where authenticator deletes auth state for no reason
export const client = createClient({
issuer: import.meta.env.VITE_AUTH_URL,
clientID: "web",
@@ -83,6 +85,7 @@ export const { use: useAuth, provider: AuthProvider } =
access: account.access,
})
if (result.err) {
console.log("error", result.err)
if ("id" in account)
setStore(produce((state) => {
delete state.accounts[account.id];
@@ -98,7 +101,7 @@ export const { use: useAuth, provider: AuthProvider } =
authorization: `Bearer ${tokens.access}`,
},
}).then(async (response) => {
await new Promise((resolve) => setTimeout(resolve, 5000));
await new Promise((resolve) => setTimeout(resolve, 10000));
if (response.ok) {
const result = await response.json();
@@ -115,6 +118,7 @@ export const { use: useAuth, provider: AuthProvider } =
}
if (!response.ok)
console.log("error from account", response.json())
setStore(
produce((state) => {
delete state.accounts[account.id];

View File

@@ -0,0 +1,34 @@
import { theme } from "./theme";
import { styled } from "@macaron-css/solid";
export const Button = styled("button", {
base: {
borderRadius: 6,
border: "1px solid transparent",
padding: `${theme.space[2]} ${theme.space[4]}`,
fontWeight: 500,
letterSpacing: 0.1,
lineHeight: "normal",
fontFamily: theme.font.family.heading,
textAlign: "center",
transitionDelay: "0s, 0s",
transitionDuration: "0.2s, 0.2s",
transitionProperty: "background-color, border",
transitionTimingFunction: "ease-out, ease-out",
display: "inline-flex",
gap: theme.space[1.5],
alignItems: "center",
justifyContent: "center",
":disabled": {
pointerEvents: "none",
},
},
variants: {
color: {
brand: {
backgroundColor: theme.color.brand,
color: "#FFF",
}
}
}
})

View File

@@ -0,0 +1,316 @@
import { theme } from "./theme";
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";
// FIXME: Make sure the focus ring goes to red when the input is invalid
export const inputStyles: CSSProperties = {
lineHeight: theme.font.lineHeight,
appearance: "none",
fontSize: theme.font.size.sm,
borderRadius: theme.borderRadius,
padding: `0 ${theme.space[3]}`,
height: theme.input.size.base,
borderWidth: 1,
borderStyle: "solid",
borderColor: theme.color.gray.d400,
color: theme.color.d1000.gray,
backgroundColor: theme.color.background.d100,
};
export const inputDisabledStyles: CSSProperties = {
opacity: 0.5,
backgroundColor: theme.color.background.d200,
color: theme.color.gray.d400,
cursor: "default",
// boxShadow: `0 0 0 1px inset ${theme.color.input.border}`,
};
export const inputFocusStyles: CSSProperties = {
outlineOffset: 3,
outline: `${theme.color.gray.d600} solid 2px`,
};
export const inputDangerTextStyles: CSSProperties = {
color: theme.color.red.d700,
};
export const inputDangerFocusStyles: CSSProperties = {
...inputDangerTextStyles,
outlineColor: theme.color.red.d700,
// boxShadow: `
// 0 0 1px 1px inset hsla(${theme.color.red.l2}, 100%),
// ${theme.color.input.shadow}
// `,
};
export const Root = styled("label", {
base: {
...utility.stack(2),
},
variants: {
color: {
primary: {
color: theme.color.gray.d900
},
danger: {
color: theme.color.red.d900,
// selectors: {
// "&:has(input)": {
// ...inputDangerFocusStyles
// }
// }
},
},
},
defaultVariants: {
color: "primary",
},
});
type FormFieldProps = ComponentProps<typeof Root> & {
hint?: JSX.Element;
label?: string;
};
export const Input = styled("input", {
base: {
...inputStyles,
":focus": {
...inputFocusStyles,
},
":disabled": {
...inputDisabledStyles,
},
"::placeholder": {
color: theme.color.gray.d800
},
// ":invalid":{
// ...inputDangerFocusStyles
// },
// selectors: {
// [`${Root.selector({ color: "danger" })} &`]: {
// ...inputDangerFocusStyles,
// },
// },
},
variants: {
color: {
primary: {},
danger: {
...inputDangerFocusStyles,
":focus": {
...inputDangerFocusStyles,
},
},
},
size: {
sm: {
height: theme.input.size.sm,
},
},
},
defaultVariants: {
color: "primary",
},
});
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",
color: "currentColor",
width: "1.15em",
height: "1.15em",
border: "0.15em solid currentColor",
borderRadius: "50%",
transform: "translateY(-0.075em)",
display: "grid",
position: "relative",
placeContent: "center",
":before": {
content: "",
width: "0.68em",
height: "0.68em",
borderRadius: "50%",
transform: " scale(0)",
transition: "120ms transform ease-in-out",
boxShadow: `inset 1em 1em ${theme.color.blue.d700}`
},
selectors: {
"&:checked::before": {
transform: "scale(1)"
}
}
}
});
const Label = styled("p", {
base: {
fontWeight: 500,
letterSpacing: -0.1,
fontSize: theme.font.size.mono_sm,
textTransform: "capitalize",
fontFamily: theme.font.family.heading,
},
});
const InputLabel = styled("label", {
base: {
letterSpacing: -0.1,
fontSize: theme.font.size.sm,
lineHeight: theme.font.lineHeight,
height: theme.input.size.base,
appearance: "none",
padding: `0 ${theme.space[3]}`,
borderWidth: 0,
borderBottomWidth: 1,
borderStyle: "solid",
borderColor: theme.color.gray.d400,
color: theme.color.gray.d800,
backgroundColor: theme.color.background.d100,
position: "relative",
display: "flex",
alignItems: "center",
cursor: "pointer",
gap: "1em",
":focus-within": {
color: theme.color.d1000.gray
},
":first-child": {
borderTopRightRadius: theme.borderRadius,
borderTopLeftRadius: theme.borderRadius,
},
":last-child": {
borderBottomWidth: 0,
borderBottomRightRadius: theme.borderRadius,
borderBottomLeftRadius: theme.borderRadius,
},
":hover": {
backgroundColor: theme.color.background.d200,
},
selectors: {
"&:has(input:checked)": {
color: theme.color.d1000.gray
}
}
},
});
const Hint = styled("p", {
base: {
fontSize: theme.font.size.xs,
lineHeight: theme.font.lineHeight,
color: theme.color.gray.d800,
},
variants: {
color: {
primary: {},
danger: {
color: theme.color.red.d700,
},
},
},
defaultVariants: {
color: "primary",
},
});
export function FormField(props: FormFieldProps) {
return (
<Root {...props}>
<Container space="2">
<Show when={props.label}>
<Label color={props.color}>{props.label}</Label>
</Show>
{props.children}
</Container>
<Show when={props.hint}>
<Hint color={props.color}>{props.hint!}</Hint>
</Show>
</Root>
);
}
type SelectProps = {
ref: (element: HTMLInputElement) => void;
name: string;
value: any;
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
onChange: JSX.EventHandler<HTMLInputElement, Event>;
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
options: { label: string; value: string }[];
badges?: { label: string; color: keyof typeof theme.color.d1000 }[];
required?: boolean;
class?: string;
};
const InputRadioContainer = styled("div", {
base: {
...inputStyles,
display: "flex",
userSelect: "none",
flexDirection: "column",
height: "auto",
position: "relative",
padding: 0,
}
})
const Badge = styled("div", {
base: {
color: "#FFF",
marginLeft: "auto",
borderRadius: 9999,
letterSpacing: 0.5,
padding: "0 6px",
fontSize: theme.font.size.xs
}
})
export function Select(props: SelectProps) {
// Split select element props
const [, inputProps] = splitProps(props, [
'class',
'value',
'options',
'badges',
]);
return (
<InputRadioContainer>
<For each={props.options}>
{({ label, value }, key) => (
<InputLabel for={label}>
<InputRadio
{...inputProps}
type="radio"
value={value}
id={label}
/>
{label}
<Show when={props.badges}>
{props.badges &&
<Badge style={{ "background-color": theme.color[props.badges[key()].color].d700 }}>
{props.badges[key()].label}
</Badge>
}
</Show>
</InputLabel>
)}
</For>
</InputRadioContainer >
);
}

View File

@@ -0,0 +1,6 @@
export * from "./form"
export * from "./layout"
export * from "./text"
export * from "./theme"
export * from "./utility"
export * from "./button"

View File

@@ -4,7 +4,6 @@ import { styled } from "@macaron-css/solid";
export const FullScreen = styled("div", {
base: {
inset: 0,
zIndex: 0,
display: "flex",
position: "fixed",
alignItems: "center",
@@ -21,28 +20,97 @@ export const FullScreen = styled("div", {
},
})
// export const Container = styled("div", {
// base: {
// backgroundColor: theme.color.background.d100,
// borderColor: theme.color.gray.d400,
// padding: "64px 80px 48px",
// justifyContent: "center",
// borderStyle: "solid",
// position: "relative",
// borderRadius: 12,
// alignItems: "center",
// maxWidth: 550,
// borderWidth: 1,
// display: "flex",
// },
// variants: {
// flow: {
// column: {
// flexDirection: "column"
// },
// row: {
// flexDirection: "row"
// }
// }
// }
// })
export const Container = styled("div", {
base: {
backgroundColor: theme.color.background.d100,
borderColor: theme.color.gray.d400,
padding: "64px 80px 48px",
justifyContent: "center",
borderStyle: "solid",
position: "relative",
borderRadius: 12,
alignItems: "center",
maxWidth: 550,
borderWidth: 1,
display: "flex",
flexDirection: "column",
},
variants: {
flow: {
column: {
flexDirection: "column"
},
row: {
flexDirection: "row"
space: (() => {
const result = {} as Record<`${keyof (typeof theme)["space"]}`, any>;
for (const key in theme.space) {
const value = theme.space[key as keyof typeof theme.space];
result[key as keyof typeof theme.space] = {
gap: value,
};
}
}
}
})
return result;
})(),
rounded: (() => {
const result = {} as Record<`${keyof (typeof theme)["space"]}`, any>;
for (const key in theme.space) {
const value = theme.space[key as keyof typeof theme.space];
result[key as keyof typeof theme.space] = {
borderRadius: value,
};
}
return result;
})(),
highlighted: {
true: {
borderColor: theme.color.gray.d400,
backgroundColor: theme.color.background.d100,
borderStyle: "solid",
borderWidth: 1,
padding: "64px 80px 48px",
maxWidth: 550,
}
},
flex: {
true: {
flex: "1 1 auto",
},
false: {
flex: "0 0 auto",
},
},
horizontal: {
center: {
alignItems: "center",
},
start: {
alignItems: "flex-start",
},
end: {
alignItems: "flex-end",
},
},
vertical: {
center: {
justifyContent: "center",
},
start: {
justifyContent: "flex-start",
},
end: {
justifyContent: "flex-end",
},
},
},
});

View File

@@ -4,9 +4,9 @@ import { utility } from "./utility";
import { CSSProperties } from "@macaron-css/core";
export const Text = styled("span", {
base: {
textWrap: "balance"
},
// base: {
// textWrap: "balance"
// },
variants: {
leading: {
base: {
@@ -122,6 +122,15 @@ export const Text = styled("span", {
}
return result;
})(),
font: (() => {
const result = {} as Record<`${keyof typeof theme.font.family}`, any>;
for (const [key, value] of Object.entries(theme.font.family)) {
result[key as keyof typeof theme.font.family] = {
fontFamily: value,
};
}
return result;
})(),
color: (() => {
const record = {} as Record<keyof typeof theme.color.text, CSSProperties>;
for (const [key, _value] of Object.entries(theme.color.text)) {

View File

@@ -2,7 +2,7 @@ import { createTheme } from "@macaron-css/core";
const constants = {
colorFadeDuration: "0.15s",
borderRadius: "4px",
borderRadius: "6px",
textBoldWeight: "600",
iconOpacity: "0.85",
modalWidth: {
@@ -16,6 +16,13 @@ const constants = {
},
};
const formInput = {
size: {
base: "40px",
sm: "32px",
},
};
const space = {
px: "1px",
0: "0px",
@@ -96,7 +103,7 @@ const light = (() => {
d100: 'hsla(0,0%,95%)',
d200: 'hsla(0,0%,92%)',
d300: 'hsla(0,0%,90%)',
d400: 'hsla(0,0%,92%)',
d400: 'hsla(0,0%,82%)',
d500: 'hsla(0,0%,79%)',
d600: 'hsla(0,0%,66%)',
d700: 'hsla(0,0%,56%)',
@@ -206,8 +213,9 @@ const light = (() => {
teal: "hsla(171,80%,13%)",
purple: "hsla(276,100%,15)",
pink: "hsla(333,74%,15%)",
grayAlpha: " hsla(0,0%,0%,0.91)"
grayAlpha: " hsla(0,0%,0%,0.91)",
}
const brand = "#FF4F01"
const background = {
d100: '#f5f5f5',
@@ -249,8 +257,9 @@ const light = (() => {
focusBorder,
focusColor,
d1000,
text,
boxShadow
boxShadow,
brand,
text
};
})()
@@ -367,14 +376,16 @@ const dark = (() => {
teal: "hsla(166,71%,93%)",
purple: "hsla(281,73%,96%)",
pink: "hsla( 333,90%,96%)",
grayAlpha: "hsla(0,0%,100%,0.92)"
grayAlpha: "hsla(0,0%,100%,0.92)",
}
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)"
};
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
@@ -412,7 +423,8 @@ const dark = (() => {
focusColor,
d1000,
text,
boxShadow
boxShadow,
brand
};
})()
@@ -421,6 +433,7 @@ export const [lightClass, theme] = createTheme({
space,
font,
color: light,
input: formInput
});
export const darkClass = createTheme(theme, {
@@ -429,4 +442,5 @@ export const darkClass = createTheme(theme, {
space,
font,
color: dark,
input: formInput
});