mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
⭐ feat(www): Finish up on the onboarding (#210)
Merging this prematurely to make sure the team is on the same boat... like dang! We need to find a better way to do this. Plus it has become too big
This commit is contained in:
@@ -6,13 +6,18 @@ import '@fontsource/geist-sans/600.css';
|
||||
import '@fontsource/geist-sans/700.css';
|
||||
import '@fontsource/geist-sans/800.css';
|
||||
import '@fontsource/geist-sans/900.css';
|
||||
import { Text } from '@nestri/www/ui/text';
|
||||
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 { Screen as FullScreen } from '@nestri/www/ui/layout';
|
||||
import { TeamRoute } from '@nestri/www/pages/team';
|
||||
import { OpenAuthProvider } from "@openauthjs/solid";
|
||||
import { NotFound } from '@nestri/www/pages/not-found';
|
||||
import { Navigate, Route, Router } from "@solidjs/router";
|
||||
import { globalStyle, macaron$ } from "@macaron-css/core";
|
||||
import { useStorage } from '@nestri/www/providers/account';
|
||||
import { CreateTeamComponent } from '@nestri/www/pages/new';
|
||||
import { darkClass, lightClass, theme } from '@nestri/www/ui/theme';
|
||||
import { AccountProvider, useAccount } from '@nestri/www/providers/account';
|
||||
import { Component, createSignal, Match, onCleanup, Switch } from 'solid-js';
|
||||
|
||||
const Root = styled("div", {
|
||||
@@ -34,14 +39,19 @@ globalStyle("html", {
|
||||
// Hardcode colors
|
||||
"@media": {
|
||||
"(prefers-color-scheme: light)": {
|
||||
backgroundColor: "#f5f5f5",
|
||||
backgroundColor: "rgba(255,255,255,0.8)",
|
||||
},
|
||||
"(prefers-color-scheme: dark)": {
|
||||
backgroundColor: "#1e1e1e",
|
||||
backgroundColor: "rgb(19,21,23)",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle("dialog:modal", {
|
||||
maxHeight: "unset",
|
||||
maxWidth: "unset"
|
||||
})
|
||||
|
||||
globalStyle("h1, h2, h3, h4, h5, h6, p", {
|
||||
margin: 0,
|
||||
});
|
||||
@@ -82,44 +92,54 @@ export const App: Component = () => {
|
||||
const storage = useStorage();
|
||||
|
||||
return (
|
||||
<Root class={theme() === "light" ? lightClass : darkClass} id="styled">
|
||||
<Router>
|
||||
<Route
|
||||
path="*"
|
||||
component={(props) => (
|
||||
<AuthProvider>
|
||||
{props.children}
|
||||
</AuthProvider>
|
||||
// props.children
|
||||
)}
|
||||
>
|
||||
<Route path="new" component={CreateTeamComponent} />
|
||||
<OpenAuthProvider
|
||||
issuer={import.meta.env.VITE_AUTH_URL}
|
||||
clientID="web"
|
||||
>
|
||||
<Root class={theme() === "light" ? lightClass : darkClass} id="styled">
|
||||
<Router>
|
||||
<Route
|
||||
path="/"
|
||||
component={() => {
|
||||
const auth = useAuth();
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={auth.current.teams.length > 0}>
|
||||
<Navigate
|
||||
href={`/${(
|
||||
auth.current.teams.find(
|
||||
(w) => w.id === storage.value.team,
|
||||
) || auth.current.teams[0]
|
||||
).slug
|
||||
}`}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Navigate href={`/new`} />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* <Route path="*" component={() => <NotFound />} /> */}
|
||||
</Route>
|
||||
</Router>
|
||||
</Root>
|
||||
path="*"
|
||||
component={(props) => (
|
||||
<AccountProvider
|
||||
loadingUI={
|
||||
<FullScreen>
|
||||
<Text weight='semibold' spacing='xs' size="3xl" font="heading" >Confirming your identity…</Text>
|
||||
</FullScreen>
|
||||
}>
|
||||
{props.children}
|
||||
</AccountProvider>
|
||||
)}
|
||||
>
|
||||
<Route path=":teamSlug">{TeamRoute}</Route>
|
||||
<Route path="new" component={CreateTeamComponent} />
|
||||
<Route
|
||||
path="/"
|
||||
component={() => {
|
||||
const account = useAccount();
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={account.current.teams.length > 0}>
|
||||
<Navigate
|
||||
href={`/${(
|
||||
account.current.teams.find(
|
||||
(w) => w.id === storage.value.team,
|
||||
) || account.current.teams[0]
|
||||
).slug
|
||||
}`}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Navigate href={`/new`} />
|
||||
</Match>
|
||||
</Switch>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Route path="*" component={() => <NotFound />} />
|
||||
</Route>
|
||||
</Router>
|
||||
</Root>
|
||||
</OpenAuthProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ParentProps, Show, createContext, useContext } from "solid-js";
|
||||
import { JSX, ParentProps, Show, createContext, useContext } from "solid-js";
|
||||
|
||||
export function createInitializedContext<
|
||||
Name extends string,
|
||||
@@ -12,10 +12,12 @@ export function createInitializedContext<
|
||||
if (!context) throw new Error(`No ${name} context`);
|
||||
return context;
|
||||
},
|
||||
provider: (props: ParentProps) => {
|
||||
provider: (props: ParentProps & { loadingUI?: JSX.Element }) => {
|
||||
const value = cb();
|
||||
return (
|
||||
<Show when={value.ready}>
|
||||
<Show
|
||||
fallback={props.loadingUI}
|
||||
when={value.ready}>
|
||||
<ctx.Provider value={value} {...props}>
|
||||
{props.children}
|
||||
</ctx.Provider>
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
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 { Show } from "solid-js";
|
||||
import { Button } from "@nestri/www/ui";
|
||||
import { Text } from "@nestri/www/ui/text";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui/theme";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { utility } from "@nestri/www/ui/utility";
|
||||
import { useAccount } from "../providers/account";
|
||||
import { Container, FullScreen } from "@nestri/www/ui/layout";
|
||||
import { FormField, Input, Select } from "@nestri/www/ui/form";
|
||||
import { createForm, getValue, setError, valiForm } from "@modular-forms/solid";
|
||||
|
||||
// const nameRegex = /^[a-z]+$/
|
||||
const nameRegex = /^[a-z0-9\-]+$/
|
||||
|
||||
const FieldList = styled("div", {
|
||||
base: {
|
||||
@@ -33,19 +37,19 @@ const Plan = {
|
||||
} as const;
|
||||
|
||||
const schema = v.object({
|
||||
plan: v.pipe(
|
||||
v.enum(Plan),
|
||||
v.minLength(2,"Please choose a plan"),
|
||||
planType: v.pipe(
|
||||
v.enum(Plan, "Choose a valid plan"),
|
||||
),
|
||||
display_name: v.pipe(
|
||||
name: v.pipe(
|
||||
v.string(),
|
||||
v.maxLength(32, 'Please use 32 characters at maximum.'),
|
||||
v.minLength(2, 'Use 2 characters at minimum.'),
|
||||
v.maxLength(32, '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.'),
|
||||
v.regex(nameRegex, "Use a URL friendly name."),
|
||||
v.minLength(2, 'Use 2 characters at minimum.'),
|
||||
v.maxLength(48, 'Use 48 characters at maximum.'),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -82,11 +86,39 @@ const schema = v.object({
|
||||
// }
|
||||
// })
|
||||
|
||||
const UrlParent = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
}
|
||||
})
|
||||
|
||||
const UrlTitle = styled("span", {
|
||||
base: {
|
||||
borderWidth: 1,
|
||||
borderRight: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderStyle: "solid",
|
||||
color: theme.color.gray.d900,
|
||||
fontSize: theme.font.size.sm,
|
||||
padding: `0 ${theme.space[3]}`,
|
||||
height: theme.input.size.base,
|
||||
borderColor: theme.color.gray.d400,
|
||||
borderTopLeftRadius: theme.borderRadius,
|
||||
borderBottomLeftRadius: theme.borderRadius,
|
||||
}
|
||||
})
|
||||
|
||||
export function CreateTeamComponent() {
|
||||
const [form, { Form, Field }] = createForm({
|
||||
validate: valiForm(schema),
|
||||
});
|
||||
|
||||
const nav = useNavigate();
|
||||
const auth = useOpenAuth();
|
||||
const account = useAccount();
|
||||
|
||||
return (
|
||||
<FullScreen>
|
||||
<Container horizontal="center" style={{ width: "100%", padding: "1rem", }} space="1" >
|
||||
@@ -95,20 +127,41 @@ export function CreateTeamComponent() {
|
||||
Create a Team
|
||||
</Text>
|
||||
<Text style={{ color: theme.color.gray.d900 }} size="sm">
|
||||
Choose something that your teammates will recognize
|
||||
Choose something that your team mates will recognize
|
||||
</Text>
|
||||
<Hr />
|
||||
</Container>
|
||||
<Form style={{ width: "100%", "max-width": "380px" }}>
|
||||
<Form style={{ width: "100%", "max-width": "380px" }}
|
||||
onSubmit={async (data) => {
|
||||
console.log("submitting");
|
||||
const result = await fetch(
|
||||
import.meta.env.VITE_API_URL + "/team",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${await auth.access()}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
},
|
||||
);
|
||||
if (!result.ok) {
|
||||
setError(form, "slug", "Team slug is already taken.");
|
||||
return;
|
||||
}
|
||||
await account.refresh(account.current.email);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
nav(`/${data.slug}`);
|
||||
}}
|
||||
>
|
||||
<FieldList>
|
||||
<Field type="string" name="slug">
|
||||
<Field type="string" name="name">
|
||||
{(field, props) => (
|
||||
<FormField
|
||||
label="Team Name"
|
||||
hint={
|
||||
field.error
|
||||
&& field.error
|
||||
// : "Needs to be lowercase, unique, and URL friendly."
|
||||
}
|
||||
color={field.error ? "danger" : "primary"}
|
||||
>
|
||||
@@ -120,19 +173,47 @@ export function CreateTeamComponent() {
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
<Field type="string" name="plan">
|
||||
<Field type="string" name="slug">
|
||||
{(field, props) => (
|
||||
<FormField
|
||||
label="Team Slug"
|
||||
hint={
|
||||
field.error
|
||||
&& field.error
|
||||
}
|
||||
color={field.error ? "danger" : "primary"}
|
||||
>
|
||||
<UrlParent
|
||||
data-type='url'
|
||||
>
|
||||
<UrlTitle>
|
||||
nestri.io/
|
||||
</UrlTitle>
|
||||
<Input
|
||||
{...props}
|
||||
autofocus
|
||||
placeholder={
|
||||
getValue(form, "name")?.toString()
|
||||
.split(" ").join("-")
|
||||
.toLowerCase() || "janes-team"}
|
||||
/>
|
||||
</UrlParent>
|
||||
</FormField>
|
||||
)}
|
||||
</Field>
|
||||
<Field type="string" name="planType">
|
||||
{(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}
|
||||
required
|
||||
value={field.value}
|
||||
badges={[
|
||||
{ label: "BYOG", color: "purple" },
|
||||
@@ -156,8 +237,10 @@ export function CreateTeamComponent() {
|
||||
</div>
|
||||
</Summary>
|
||||
</Details> */}
|
||||
<Button color="brand">
|
||||
Continue
|
||||
<Button color="brand" disabled={form.submitting} >
|
||||
<Show when={form.submitting} fallback="Create">
|
||||
Creating…
|
||||
</Show>
|
||||
</Button>
|
||||
</FieldList>
|
||||
</Form>
|
||||
|
||||
70
packages/www/src/pages/not-found.tsx
Normal file
70
packages/www/src/pages/not-found.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Show } from "solid-js";
|
||||
import { A } from "@solidjs/router";
|
||||
import { Text } from "@nestri/www/ui/text";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui/theme";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
import { FullScreen, Container } from "@nestri/www/ui/layout";
|
||||
|
||||
const NotAllowedDesc = styled("div", {
|
||||
base: {
|
||||
fontSize: theme.font.size.base,
|
||||
color: theme.color.gray.d900,
|
||||
},
|
||||
});
|
||||
|
||||
const HomeLink = styled(A, {
|
||||
base: {
|
||||
fontSize: theme.font.size.base,
|
||||
textUnderlineOffset: 1,
|
||||
color: theme.color.blue.d900
|
||||
},
|
||||
});
|
||||
|
||||
interface ErrorScreenProps {
|
||||
inset?: "none" | "header";
|
||||
message?: string;
|
||||
header?: boolean;
|
||||
}
|
||||
|
||||
export function NotFound(props: ErrorScreenProps) {
|
||||
return (
|
||||
<>
|
||||
<Show when={props.header}>
|
||||
<Header />
|
||||
</Show>
|
||||
<FullScreen
|
||||
inset={props.inset ? props.inset : props.header ? "header" : "none"}
|
||||
>
|
||||
<Container space="2.5" horizontal="center">
|
||||
<Text weight="semibold" spacing="xs" size="3xl">{props.message || "Page not found"}</Text>
|
||||
<HomeLink href="/">Go back home</HomeLink>
|
||||
</Container>
|
||||
</FullScreen>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotAllowed(props: ErrorScreenProps) {
|
||||
return (
|
||||
<>
|
||||
<Show when={props.header}>
|
||||
<Header />
|
||||
</Show>
|
||||
<FullScreen
|
||||
inset={props.inset ? props.inset : props.header ? "header" : "none"}
|
||||
>
|
||||
<Container space="2.5" horizontal="center">
|
||||
<Text weight="semibold" spacing="xs" size="3xl">Access not allowed</Text>
|
||||
<NotAllowedDesc>
|
||||
You don't have access to this page,
|
||||
<HomeLink href="/">go back home</HomeLink>.
|
||||
</NotAllowedDesc>
|
||||
<NotAllowedDesc>
|
||||
Public profiles are coming soon
|
||||
</NotAllowedDesc>
|
||||
</Container>
|
||||
</FullScreen>
|
||||
</>
|
||||
);
|
||||
}
|
||||
322
packages/www/src/pages/team/header.tsx
Normal file
322
packages/www/src/pages/team/header.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
import { A } from "@solidjs/router";
|
||||
import { Container } from "@nestri/www/ui";
|
||||
import Avatar from "@nestri/www/ui/avatar";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { theme } from "@nestri/www/ui/theme";
|
||||
import { useAccount } from "@nestri/www/providers/account";
|
||||
import { TeamContext } from "@nestri/www/providers/context";
|
||||
import { Match, ParentProps, Show, Switch, useContext } from "solid-js";
|
||||
|
||||
const PageWrapper = styled("div", {
|
||||
base: {
|
||||
minHeight: "100dvh",
|
||||
// paddingBottom: "4rem",
|
||||
backgroundColor: theme.color.background.d200
|
||||
}
|
||||
})
|
||||
|
||||
const NestriLogo = styled("svg", {
|
||||
base: {
|
||||
height: 28,
|
||||
width: 28,
|
||||
}
|
||||
})
|
||||
|
||||
const NestriLogoBig = styled("svg", {
|
||||
base: {
|
||||
height: 38,
|
||||
width: 38,
|
||||
}
|
||||
})
|
||||
|
||||
const LineSvg = styled("svg", {
|
||||
base: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
color: theme.color.grayAlpha.d300
|
||||
}
|
||||
})
|
||||
|
||||
const LogoName = styled("svg", {
|
||||
base: {
|
||||
height: 18,
|
||||
color: theme.color.d1000.grayAlpha
|
||||
}
|
||||
})
|
||||
|
||||
const Link = styled(A, {
|
||||
base: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2
|
||||
}
|
||||
})
|
||||
|
||||
const TeamRoot = styled("div", {
|
||||
base: {
|
||||
height: 32,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8
|
||||
}
|
||||
})
|
||||
|
||||
const LogoRoot = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const TeamLabel = styled("span", {
|
||||
base: {
|
||||
letterSpacing: -0.5,
|
||||
fontSize: theme.font.size.base,
|
||||
fontFamily: theme.font.family.heading,
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
color: theme.color.gray.d900
|
||||
}
|
||||
})
|
||||
|
||||
const Badge = styled("div", {
|
||||
base: {
|
||||
height: 20,
|
||||
fontSize: 11,
|
||||
lineHeight: 1,
|
||||
color: "#FFF",
|
||||
padding: "0 6px",
|
||||
letterSpacing: 0.2,
|
||||
borderRadius: 9999,
|
||||
alignItems: "center",
|
||||
display: "inline-flex",
|
||||
whiteSpace: "pre-wrap",
|
||||
justifyContent: "center",
|
||||
fontFeatureSettings: `"tnum"`,
|
||||
fontVariantNumeric: "tabular-nums",
|
||||
}
|
||||
})
|
||||
|
||||
const DropIcon = styled("svg", {
|
||||
base: {
|
||||
height: 14,
|
||||
width: 14,
|
||||
marginLeft: -4,
|
||||
color: theme.color.grayAlpha.d800
|
||||
}
|
||||
})
|
||||
|
||||
const AvatarImg = styled("img", {
|
||||
base: {
|
||||
height: 32,
|
||||
width: 32,
|
||||
borderRadius: 9999
|
||||
}
|
||||
})
|
||||
|
||||
const RightRoot = styled("div", {
|
||||
base: {
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
gap: theme.space["4"],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}
|
||||
})
|
||||
|
||||
const NavRoot = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.space["4"],
|
||||
}
|
||||
})
|
||||
|
||||
const NavLink = styled(A, {
|
||||
base: {
|
||||
color: "#FFF",
|
||||
textDecoration: "none",
|
||||
height: 32,
|
||||
padding: "0 8px",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
borderRadius: 8,
|
||||
gap: theme.space["2"],
|
||||
lineHeight: 1.5,
|
||||
fontSize: theme.font.size.sm,
|
||||
fontWeight: theme.font.weight.regular,
|
||||
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
|
||||
// ":hover": {
|
||||
// color: theme.color.d1000.gray
|
||||
// }
|
||||
}
|
||||
})
|
||||
|
||||
const NavWrapper = styled("div", {
|
||||
base: {
|
||||
// borderBottom: "1px solid white",
|
||||
zIndex: 10,
|
||||
position: "fixed",
|
||||
// backdropFilter: "saturate(60%) blur(3px)",
|
||||
height: theme.headerHeight.root,
|
||||
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
|
||||
width: "100%",
|
||||
backgroundColor: "transparent"
|
||||
}
|
||||
})
|
||||
|
||||
const Background = styled("div", {
|
||||
base: {
|
||||
background: theme.color.headerGradient,
|
||||
zIndex: 1,
|
||||
height: 180,
|
||||
width: "100%",
|
||||
position: "fixed",
|
||||
pointerEvents: "none"
|
||||
}
|
||||
})
|
||||
|
||||
const Nav = styled("nav", {
|
||||
base: {
|
||||
position: "relative",
|
||||
padding: "0.75rem 1rem",
|
||||
zIndex: 200,
|
||||
width: "100%",
|
||||
gap: "1.5rem",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center"
|
||||
}
|
||||
})
|
||||
|
||||
export function Header(props: { whiteColor?: boolean } & ParentProps) {
|
||||
const team = useContext(TeamContext)
|
||||
const account = useAccount()
|
||||
return (
|
||||
<PageWrapper>
|
||||
<NavWrapper style={{ color: props.whiteColor ? "#FFF" : theme.color.d1000.gray }} >
|
||||
{/* <Background /> */}
|
||||
<Nav>
|
||||
<Container space="4" vertical="center">
|
||||
<Show when={team}
|
||||
fallback={
|
||||
<Link href="/">
|
||||
<NestriLogoBig
|
||||
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" />
|
||||
</NestriLogoBig>
|
||||
<LogoName viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height="100%" width="100%" >
|
||||
<g stroke-line-cap="round" fill-rule="evenodd" font-size="9pt" fill="currentColor">
|
||||
<path
|
||||
fill="currentColor"
|
||||
pathLength="1"
|
||||
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>
|
||||
</LogoName>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<LogoRoot>
|
||||
<A href={`/${team!().slug}`} >
|
||||
<NestriLogo
|
||||
width={32}
|
||||
height={32}
|
||||
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" />
|
||||
</NestriLogo>
|
||||
</A>
|
||||
<LineSvg
|
||||
height="16"
|
||||
stroke-linejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="16">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z" fill="currentColor"></path>
|
||||
</LineSvg>
|
||||
<TeamRoot>
|
||||
<Avatar size={21} name={team!().slug} />
|
||||
<TeamLabel style={{ color: props.whiteColor ? "#FFF" : theme.color.d1000.gray }}>{team!().name}</TeamLabel>
|
||||
<Switch>
|
||||
<Match when={team!().planType === "BYOG"}>
|
||||
<Badge style={{ "background-color": theme.color.purple.d700 }}>
|
||||
<span style={{ "line-height": 0 }} >BYOG</span>
|
||||
</Badge>
|
||||
</Match>
|
||||
<Match when={team!().planType === "Hosted"}>
|
||||
<Badge style={{ "background-color": theme.color.blue.d700 }}>
|
||||
<span style={{ "line-height": 0 }}>Hosted</span>
|
||||
</Badge>
|
||||
</Match>
|
||||
</Switch>
|
||||
<DropIcon
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 256 256">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" />
|
||||
</DropIcon>
|
||||
</TeamRoot>
|
||||
</LogoRoot>
|
||||
</Show>
|
||||
</Container>
|
||||
<RightRoot>
|
||||
<Show when={team}>
|
||||
<NavRoot>
|
||||
<NavLink href={`/${team!().slug}/machines`}>
|
||||
{/* <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17.5 17.5L22 22m-2-11a9 9 0 1 0-18 0a9 9 0 0 0 18 0" color="currentColor" />
|
||||
</svg> */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M3.441 9.956a4.926 4.926 0 0 0 6.233 7.571l4.256 4.257a.773.773 0 0 0 1.169-1.007l-.075-.087l-4.217-4.218A4.927 4.927 0 0 0 3.44 9.956m13.213 6.545c-.225 1.287-.548 2.456-.952 3.454l.03.028l.124.14c.22.295.344.624.378.952a10.03 10.03 0 0 0 4.726-4.574zM12.25 16.5l2.284 2.287c.202-.6.381-1.268.53-1.992l.057-.294zm-2.936-5.45a3.38 3.38 0 1 1-4.78 4.779a3.38 3.38 0 0 1 4.78-4.78M15.45 10h-3.7a5.94 5.94 0 0 1 .892 5h2.71a26 26 0 0 0 .132-4.512zm1.507 0a28 28 0 0 1-.033 4.42l-.057.58h4.703a10.05 10.05 0 0 0 .258-5zm-2.095-7.593c.881 1.35 1.536 3.329 1.883 5.654l.062.44h4.59a10.03 10.03 0 0 0-6.109-5.958l-.304-.1zm-2.836-.405c-1.277 0-2.561 2.382-3.158 5.839c.465.16.912.38 1.331.658l5.088.001c-.54-3.809-1.905-6.498-3.261-6.498m-2.837.405A10.03 10.03 0 0 0 2.654 8.5h.995a5.92 5.92 0 0 1 3.743-.968c.322-1.858.846-3.47 1.527-4.68l.162-.275z" />
|
||||
</svg>
|
||||
{/* Machines */}
|
||||
</NavLink>
|
||||
<NavLink href={`/${team!().slug}/machines`}>
|
||||
<svg style={{ "margin-bottom": "1px" }} xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 16 16">
|
||||
<g fill="currentColor"><path d="M4 8a1.5 1.5 0 1 1 3 0a1.5 1.5 0 0 1-3 0m7.5-1.5a1.5 1.5 0 1 0 0 3a1.5 1.5 0 0 0 0-3" />
|
||||
<path d="M0 1.5A.5.5 0 0 1 .5 1h1a.5.5 0 0 1 .5.5V4h13.5a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5H2v2.5a.5.5 0 0 1-1 0V2H.5a.5.5 0 0 1-.5-.5m5.5 4a2.5 2.5 0 1 0 0 5a2.5 2.5 0 0 0 0-5M9 8a2.5 2.5 0 1 0 5 0a2.5 2.5 0 0 0-5 0" />
|
||||
<path d="M3 12.5h3.5v1a.5.5 0 0 1-.5.5H3.5a.5.5 0 0 1-.5-.5zm4 1v-1h4v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5" />
|
||||
</g>
|
||||
</svg>
|
||||
</NavLink>
|
||||
</NavRoot>
|
||||
</Show>
|
||||
<div style={{ "margin-bottom": "2px" }} >
|
||||
<Switch>
|
||||
<Match when={account.current.avatarUrl} >
|
||||
<AvatarImg src={account.current.avatarUrl} alt={`${account.current.name}'s avatar`} />
|
||||
</Match>
|
||||
<Match when={!account.current.avatarUrl}>
|
||||
<Avatar size={32} name={`${account.current.name}#${account.current.discriminator}`} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</RightRoot>
|
||||
</Nav>
|
||||
</NavWrapper>
|
||||
{props.children}
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
414
packages/www/src/pages/team/home.tsx
Normal file
414
packages/www/src/pages/team/home.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { FullScreen, theme } from "@nestri/www/ui";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { Header } from "@nestri/www/pages/team/header";
|
||||
import { useSteam } from "@nestri/www/providers/steam";
|
||||
import { Modal } from "@nestri/www/ui/modal";
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js";
|
||||
import { Text } from "@nestri/www/ui/text"
|
||||
import { QRCode } from "@nestri/www/ui/custom-qr";
|
||||
import { globalStyle, keyframes } from "@macaron-css/core";
|
||||
import { A } from "@solidjs/router";
|
||||
|
||||
const EmptyState = styled("div", {
|
||||
base: {
|
||||
padding: "0 40px",
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
margin: "auto"
|
||||
}
|
||||
})
|
||||
|
||||
const EmptyStateHeader = styled("h2", {
|
||||
base: {
|
||||
textAlign: "center",
|
||||
fontSize: theme.font.size["2xl"],
|
||||
fontFamily: theme.font.family.heading,
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
letterSpacing: -0.5,
|
||||
}
|
||||
})
|
||||
|
||||
const EmptyStateSubHeader = styled("p", {
|
||||
base: {
|
||||
fontWeight: theme.font.weight.regular,
|
||||
color: theme.color.gray.d900,
|
||||
fontSize: theme.font.size["lg"],
|
||||
textAlign: "center",
|
||||
maxWidth: 380,
|
||||
letterSpacing: -0.4,
|
||||
lineHeight: 1.1,
|
||||
}
|
||||
})
|
||||
|
||||
const QRWrapper = styled("div", {
|
||||
base: {
|
||||
backgroundColor: theme.color.background.d100,
|
||||
position: "relative",
|
||||
marginBottom: 20,
|
||||
textWrap: "balance",
|
||||
border: `1px solid ${theme.color.gray.d400}`,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
overflow: "hidden",
|
||||
borderRadius: 22,
|
||||
padding: 20,
|
||||
}
|
||||
})
|
||||
|
||||
const SteamMobileLink = styled(A, {
|
||||
base: {
|
||||
textUnderlineOffset: 2,
|
||||
textDecoration: "none",
|
||||
color: theme.color.blue.d900,
|
||||
display: "inline-flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: 1,
|
||||
width: "max-content",
|
||||
textTransform: "capitalize",
|
||||
":hover": {
|
||||
textDecoration: "underline"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const LogoContainer = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}
|
||||
})
|
||||
|
||||
const LogoIcon = styled("svg", {
|
||||
base: {
|
||||
zIndex: 6,
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
transform: "translate(-50%,-50%)",
|
||||
overflow: "hidden",
|
||||
// width: "21%",
|
||||
// height: "21%",
|
||||
borderRadius: 17,
|
||||
// ":before": {
|
||||
// pointerEvents: "none",
|
||||
// zIndex: 2,
|
||||
// content: '',
|
||||
// position: "absolute",
|
||||
// inset: 0,
|
||||
// borderRadius: "inherit",
|
||||
// boxShadow: "inset 0 0 0 1px rgba(0, 0, 0, 0.02)",
|
||||
// }
|
||||
}
|
||||
})
|
||||
|
||||
const LastPlayedWrapper = styled("div", {
|
||||
base: {
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
justifyContent: "center",
|
||||
minHeight: 700,
|
||||
height: "50vw",
|
||||
maxHeight: 800,
|
||||
WebkitBoxPack: "center",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
":after": {
|
||||
content: "",
|
||||
pointerEvents: "none",
|
||||
userSelect: "none",
|
||||
background: `linear-gradient(to bottom,transparent,${theme.color.background.d200})`,
|
||||
width: "100%",
|
||||
left: 0,
|
||||
position: "absolute",
|
||||
bottom: -1,
|
||||
zIndex: 3,
|
||||
height: 320,
|
||||
backdropFilter: "blur(2px)",
|
||||
WebkitBackdropFilter: "blur(1px)",
|
||||
WebkitMaskImage: `linear-gradient(to top,${theme.color.background.d200} 25%,transparent)`,
|
||||
maskImage: `linear-gradient(to top,${theme.color.background.d200} 25%,transparent)`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const LastPlayedFader = styled("div", {
|
||||
base: {
|
||||
position: "absolute",
|
||||
width: "100%",
|
||||
height: "3rem",
|
||||
backgroundColor: "rgba(0,0,0,.08)",
|
||||
mixBlendMode: "multiply",
|
||||
backdropFilter: "saturate(160%) blur(60px)",
|
||||
WebkitBackdropFilter: "saturate(160%) blur(60px)",
|
||||
maskImage: "linear-gradient(to top,rgba(0,0,0,.15) 0%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)",
|
||||
// background: "linear-gradient(rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0.3) 50%, rgba(10, 0, 0, 0.15) 65%, rgba(0, 0, 0, 0.075) 75.5%, rgba(0, 0, 0, 0.035) 82.85%, rgba(0, 0, 0, 0.02) 88%, rgba(0, 0, 0, 0) 100%)",
|
||||
opacity: 0.6,
|
||||
// backdropFilter: "blur(16px)",
|
||||
pointerEvents: "none",
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const BackgroundImage = styled("div", {
|
||||
base: {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
backgroundColor: theme.color.background.d200,
|
||||
backgroundSize: "cover",
|
||||
zIndex: 0,
|
||||
transitionDuration: "0.2s",
|
||||
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
|
||||
transitionProperty: "opacity",
|
||||
backgroundImage: "url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1203190/ss_97ea9b0b5a6adf3436b31d389cd18d3a647ee4bf.jpg)"
|
||||
// backgroundImage: "url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/3373660/c4993923f605b608939536b5f2521913850b028a/ss_c4993923f605b608939536b5f2521913850b028a.jpg)"
|
||||
}
|
||||
})
|
||||
|
||||
const LogoBackgroundImage = styled("div", {
|
||||
base: {
|
||||
position: "fixed",
|
||||
top: "2rem",
|
||||
height: 240,
|
||||
// width: 320,
|
||||
aspectRatio: "16 / 9",
|
||||
left: "50%",
|
||||
transform: "translate(-50%,0%)",
|
||||
backgroundSize: "cover",
|
||||
zIndex: 1,
|
||||
transitionDuration: "0.2s",
|
||||
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
|
||||
transitionProperty: "opacity",
|
||||
backgroundImage: "url(https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/1203190/logo_2x.png)"
|
||||
}
|
||||
})
|
||||
|
||||
const Material = styled("div", {
|
||||
base: {
|
||||
backdropFilter: "saturate(160%) blur(60px)",
|
||||
WebkitBackdropFilter: "saturate(160%) blur(60px)",
|
||||
backgroundSize: "cover",
|
||||
backgroundRepeat: "no-repeat",
|
||||
position: "absolute",
|
||||
borderRadius: 6,
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
maskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)",
|
||||
WebkitMaskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)"
|
||||
}
|
||||
})
|
||||
|
||||
const JoeColor = styled("div", {
|
||||
base: {
|
||||
backgroundColor: "rgba(0,0,0,.08)",
|
||||
mixBlendMode: "multiply",
|
||||
position: "absolute",
|
||||
borderRadius: 6,
|
||||
left: 0,
|
||||
top: 0,
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
maskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)",
|
||||
WebkitMaskImage: "linear-gradient(180deg,rgba(0,0,0,0) 0,rgba(0,0,0,0) 40.82%,rgba(0,0,0,.15) 50%,rgba(0,0,0,.65) 57.14%,rgba(0,0,0,.9) 67.86%,#000 79.08%)"
|
||||
}
|
||||
})
|
||||
|
||||
const GamesContainer = styled("div", {
|
||||
base: {
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
flexDirection: "column",
|
||||
zIndex: 3,
|
||||
backgroundColor: theme.color.background.d200,
|
||||
}
|
||||
})
|
||||
|
||||
const GamesWrapper = styled("div", {
|
||||
base: {
|
||||
maxWidth: "70vw",
|
||||
width: "100%",
|
||||
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
|
||||
margin: "0 auto",
|
||||
display: "grid",
|
||||
marginTop: -80,
|
||||
columnGap: 12,
|
||||
rowGap: 10
|
||||
}
|
||||
})
|
||||
|
||||
const GameImage = styled("img", {
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
aspectRatio: "460/215",
|
||||
borderRadius: 10,
|
||||
}
|
||||
})
|
||||
|
||||
const GameSquareImage = styled("img", {
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
aspectRatio: "1/1",
|
||||
borderRadius: 10,
|
||||
transitionDuration: "0.2s",
|
||||
transitionTimingFunction: "cubic-bezier(0.4,0,0.2,1)",
|
||||
transitionProperty: "all",
|
||||
cursor: "pointer",
|
||||
border: `2px solid transparent`,
|
||||
":hover": {
|
||||
transform: "scale(1.05)",
|
||||
outline: `2px solid ${theme.color.brand}`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const GameImageCapsule = styled("img", {
|
||||
base: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
aspectRatio: "374/448",
|
||||
borderRadius: 10,
|
||||
}
|
||||
})
|
||||
|
||||
const SteamLibrary = styled("div", {
|
||||
base: {
|
||||
borderTop: `1px solid ${theme.color.gray.d400}`,
|
||||
padding: "20px 0",
|
||||
margin: "20px auto",
|
||||
width: "100%",
|
||||
display: "grid",
|
||||
// backgroundColor: "red",
|
||||
maxWidth: "70vw",
|
||||
gridTemplateColumns: "repeat(4, minmax(0, 1fr))",
|
||||
columnGap: 12,
|
||||
rowGap: 10,
|
||||
}
|
||||
})
|
||||
|
||||
const SteamLibraryTitle = styled("h3", {
|
||||
base: {
|
||||
textAlign: "left",
|
||||
fontFamily: theme.font.family.heading,
|
||||
fontWeight: theme.font.weight.medium,
|
||||
fontSize: theme.font.size["2xl"],
|
||||
letterSpacing: -0.7,
|
||||
gridColumn: "1/-1",
|
||||
marginBottom: 20,
|
||||
}
|
||||
})
|
||||
|
||||
export function HomeRoute() {
|
||||
|
||||
// const steam = useSteam();
|
||||
// const [loginUrl, setLoginUrl] = createSignal<string | null>(null);
|
||||
// const [loginStatus, setLoginStatus] = createSignal<string | null>("Not connected");
|
||||
// const [userData, setUserData] = createSignal<{ username?: string, steamId?: string } | null>(null);
|
||||
|
||||
// createEffect(async () => {
|
||||
// // Connect to the Steam login stream
|
||||
// const steamConnection = await steam.client.login.connect();
|
||||
|
||||
// // Set up event listeners for different event types
|
||||
// const urlUnsubscribe = steamConnection.addEventListener('url', (url) => {
|
||||
// setLoginUrl(url);
|
||||
// setLoginStatus('Scan QR code with Steam mobile app');
|
||||
// });
|
||||
|
||||
// const loginAttemptUnsubscribe = steamConnection.addEventListener('login-attempt', (data) => {
|
||||
// setLoginStatus(`Logging in as ${data.username}...`);
|
||||
// });
|
||||
|
||||
// const loginSuccessUnsubscribe = steamConnection.addEventListener('login-success', (data) => {
|
||||
// setUserData(data);
|
||||
// setLoginStatus(`Successfully logged in as ${data.username}`);
|
||||
// });
|
||||
|
||||
// const loginUnsuccessfulUnsubscribe = steamConnection.addEventListener('login-unsuccessful', (data) => {
|
||||
// setLoginStatus(`Login failed: ${data.error}`);
|
||||
// });
|
||||
|
||||
// const loggedOffUnsubscribe = steamConnection.addEventListener('logged-off', (data) => {
|
||||
// setLoginStatus(`Logged out of Steam: ${data.reason}`);
|
||||
// setUserData(null);
|
||||
// });
|
||||
|
||||
// onCleanup(() => {
|
||||
// urlUnsubscribe();
|
||||
// loginAttemptUnsubscribe();
|
||||
// loginSuccessUnsubscribe();
|
||||
// loginUnsuccessfulUnsubscribe();
|
||||
// loggedOffUnsubscribe();
|
||||
// steamConnection.disconnect();
|
||||
// });
|
||||
// })
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header whiteColor>
|
||||
<FullScreen >
|
||||
<EmptyState
|
||||
style={{
|
||||
"--nestri-qr-dot-color": theme.color.d1000.gray,
|
||||
"--nestri-body-background": theme.color.gray.d100
|
||||
}}
|
||||
>
|
||||
<QRWrapper>
|
||||
<LogoContainer>
|
||||
<LogoIcon
|
||||
xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16">
|
||||
<g fill="currentColor">
|
||||
<path d="M.329 10.333A8.01 8.01 0 0 0 7.99 16C12.414 16 16 12.418 16 8s-3.586-8-8.009-8A8.006 8.006 0 0 0 0 7.468l.003.006l4.304 1.769A2.2 2.2 0 0 1 5.62 8.88l1.96-2.844l-.001-.04a3.046 3.046 0 0 1 3.042-3.043a3.046 3.046 0 0 1 3.042 3.043a3.047 3.047 0 0 1-3.111 3.044l-2.804 2a2.223 2.223 0 0 1-3.075 2.11a2.22 2.22 0 0 1-1.312-1.568L.33 10.333Z" /><path d="M4.868 12.683a1.715 1.715 0 0 0 1.318-3.165a1.7 1.7 0 0 0-1.263-.02l1.023.424a1.261 1.261 0 1 1-.97 2.33l-.99-.41a1.7 1.7 0 0 0 .882.84Zm3.726-6.687a2.03 2.03 0 0 0 2.027 2.029a2.03 2.03 0 0 0 2.027-2.029a2.03 2.03 0 0 0-2.027-2.027a2.03 2.03 0 0 0-2.027 2.027m2.03-1.527a1.524 1.524 0 1 1-.002 3.048a1.524 1.524 0 0 1 .002-3.048" />
|
||||
</g>
|
||||
</LogoIcon>
|
||||
</LogoContainer>
|
||||
<QRCode
|
||||
uri={"https://github.com/family/connectkit/blob/9a3c16c781d8a60853eff0c4988e22926a3f91ce"}
|
||||
size={180}
|
||||
ecl="M"
|
||||
clearArea={true}
|
||||
/>
|
||||
</QRWrapper>
|
||||
<EmptyStateHeader>Sign in to your Steam account</EmptyStateHeader>
|
||||
<EmptyStateSubHeader>Use your Steam Mobile App to sign in via QR code. <SteamMobileLink href="https://store.steampowered.com/mobile" target="_blank">Learn More<svg data-testid="geist-icon" height="20" stroke-linejoin="round" viewBox="0 0 16 16" width="20" style="color: currentcolor;"><path fill-rule="evenodd" clip-rule="evenodd" d="M11.5 9.75V11.25C11.5 11.3881 11.3881 11.5 11.25 11.5H4.75C4.61193 11.5 4.5 11.3881 4.5 11.25L4.5 4.75C4.5 4.61193 4.61193 4.5 4.75 4.5H6.25H7V3H6.25H4.75C3.7835 3 3 3.7835 3 4.75V11.25C3 12.2165 3.7835 13 4.75 13H11.25C12.2165 13 13 12.2165 13 11.25V9.75V9H11.5V9.75ZM8.5 3H9.25H12.2495C12.6637 3 12.9995 3.33579 12.9995 3.75V6.75V7.5H11.4995V6.75V5.56066L8.53033 8.52978L8 9.06011L6.93934 7.99945L7.46967 7.46912L10.4388 4.5H9.25H8.5V3Z" fill="currentColor"></path></svg></SteamMobileLink></EmptyStateSubHeader>
|
||||
</EmptyState>
|
||||
{/* <LastPlayedWrapper>
|
||||
<LastPlayedFader />
|
||||
<LogoBackgroundImage />
|
||||
<BackgroundImage />
|
||||
<Material />
|
||||
<JoeColor />
|
||||
</LastPlayedWrapper> */}
|
||||
{/* <GamesContainer>
|
||||
<GamesWrapper>
|
||||
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/05/15/acshadows-1715789601294.jpg" />
|
||||
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2022/09/22/slime-rancher-2-button-02-1663890048548.jpg" />
|
||||
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2023/05/19/cataclismo-button-1684532710313.jpg" />
|
||||
<GameSquareImage alt="Assasin's Creed Shadows" src="https://assets-prd.ignimgs.com/2024/03/27/marvelrivals-1711557092104.jpg" />
|
||||
</GamesWrapper>
|
||||
<SteamLibrary>
|
||||
<SteamLibraryTitle>Games we think you will like</SteamLibraryTitle>
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2625420/hero_capsule.jpg?t=1742853642" />
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2486740/hero_capsule.jpg?t=1742596243" />
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/870780/hero_capsule.jpg?t=1737800535" />
|
||||
<GameImageCapsule alt="Assasin's Creed Shadows" src="https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/2050650/hero_capsule.jpg?t=1737800535" />
|
||||
</SteamLibrary>
|
||||
</GamesContainer> */}
|
||||
</FullScreen>
|
||||
</Header>
|
||||
</>
|
||||
)
|
||||
}
|
||||
69
packages/www/src/pages/team/index.tsx
Normal file
69
packages/www/src/pages/team/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { HomeRoute } from "./home";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { Route, useParams } from "@solidjs/router";
|
||||
import { ApiProvider } from "@nestri/www/providers/api";
|
||||
import { SteamRoute } from "@nestri/www/pages/team/steam";
|
||||
import { ZeroProvider } from "@nestri/www/providers/zero";
|
||||
import { TeamContext } from "@nestri/www/providers/context";
|
||||
import { SteamProvider } from "@nestri/www/providers/steam";
|
||||
import { createEffect, createMemo, Match, Switch } from "solid-js";
|
||||
import { NotAllowed, NotFound } from "@nestri/www/pages/not-found";
|
||||
import { useAccount, useStorage } from "@nestri/www/providers/account";
|
||||
|
||||
export const TeamRoute = (
|
||||
<Route
|
||||
component={(props) => {
|
||||
const params = useParams();
|
||||
const account = useAccount();
|
||||
const storage = useStorage();
|
||||
const openauth = useOpenAuth();
|
||||
|
||||
const team = createMemo(() =>
|
||||
account.current.teams.find(
|
||||
(item) => item.slug === params.teamSlug,
|
||||
),
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
const t = team();
|
||||
if (!t) return;
|
||||
storage.set("team", t.id);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const teamSlug = params.teamSlug;
|
||||
for (const item of Object.values(account.all)) {
|
||||
for (const team of item.teams) {
|
||||
if (team.slug === teamSlug && item.id !== openauth.subject!.id) {
|
||||
openauth.switch(item.email);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={!team()}>
|
||||
TODO: Add a public page for (other) teams
|
||||
<NotAllowed header />
|
||||
</Match>
|
||||
<Match when={team()}>
|
||||
<TeamContext.Provider value={() => team()!}>
|
||||
<ZeroProvider>
|
||||
<ApiProvider>
|
||||
<SteamProvider>
|
||||
{props.children}
|
||||
</SteamProvider>
|
||||
</ApiProvider>
|
||||
</ZeroProvider>
|
||||
</TeamContext.Provider>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
>
|
||||
<Route path="" component={HomeRoute} />
|
||||
<Route path="steam" component={SteamRoute} />
|
||||
<Route path="*" component={() => <NotFound header />} />
|
||||
</Route>
|
||||
)
|
||||
238
packages/www/src/pages/team/steam.tsx
Normal file
238
packages/www/src/pages/team/steam.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Header } from "./header"
|
||||
import { theme } from "@nestri/www/ui";
|
||||
import { Text } from "@nestri/www/ui";
|
||||
import { styled } from "@macaron-css/solid";
|
||||
import { useSteam } from "@nestri/www/providers/steam";
|
||||
import { createEffect, onCleanup } from "solid-js";
|
||||
|
||||
// FIXME: Remove this route, or move it to machines
|
||||
|
||||
// The idea has changed, let the user login to Steam from the / route
|
||||
// Let the machines route remain different from the main page
|
||||
// Why? It becomes much simpler for routing and onboarding, plus how often will you move to the machines route?
|
||||
// Now it will be the home page's problem with making sure the user can download and install games on whatever machine they need/want
|
||||
|
||||
const Root = styled("div", {
|
||||
base: {
|
||||
display: "grid",
|
||||
gridAutoRows: "1fr",
|
||||
position: "relative",
|
||||
gridTemplateRows: "0 auto",
|
||||
backgroundColor: theme.color.background.d200,
|
||||
minHeight: `calc(100vh - ${theme.headerHeight.root})`,
|
||||
gridTemplateColumns: "minmax(24px,1fr) minmax(0,1000px) minmax(24px,1fr)"
|
||||
},
|
||||
});
|
||||
|
||||
const Section = styled("section", {
|
||||
base: {
|
||||
gridColumn: "1/-1",
|
||||
}
|
||||
})
|
||||
|
||||
const TitleHeader = styled("header", {
|
||||
base: {
|
||||
borderBottom: `1px solid ${theme.color.gray.d400}`,
|
||||
color: theme.color.d1000.gray
|
||||
}
|
||||
})
|
||||
|
||||
const TitleWrapper = styled("div", {
|
||||
base: {
|
||||
width: "calc(1000px + calc(2 * 24px))",
|
||||
paddingLeft: "24px",
|
||||
display: "flex",
|
||||
paddingRight: "24px",
|
||||
marginLeft: "auto",
|
||||
marginRight: "auto",
|
||||
maxWidth: "100%"
|
||||
}
|
||||
})
|
||||
|
||||
const TitleContainer = styled("div", {
|
||||
base: {
|
||||
margin: "40px 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
width: "100%",
|
||||
minWidth: 0
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonContainer = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: 16,
|
||||
margin: "40px 0",
|
||||
}
|
||||
})
|
||||
|
||||
const Title = styled("h1", {
|
||||
base: {
|
||||
lineHeight: "2.5rem",
|
||||
fontWeight: theme.font.weight.semibold,
|
||||
letterSpacing: "-0.069375rem",
|
||||
fontSize: theme.font.size["4xl"],
|
||||
textTransform: "capitalize"
|
||||
}
|
||||
})
|
||||
|
||||
const Description = styled("p", {
|
||||
base: {
|
||||
fontSize: theme.font.size.sm,
|
||||
lineHeight: "1.25rem",
|
||||
fontWeight: theme.font.weight.regular,
|
||||
letterSpacing: "initial",
|
||||
color: theme.color.gray.d900
|
||||
}
|
||||
})
|
||||
|
||||
const QRButton = styled("button", {
|
||||
base: {
|
||||
height: 40,
|
||||
borderRadius: theme.borderRadius,
|
||||
backgroundColor: theme.color.d1000.gray,
|
||||
color: theme.color.gray.d100,
|
||||
fontSize: theme.font.size.sm,
|
||||
textWrap: "nowrap",
|
||||
border: "1px solid transparent",
|
||||
padding: `${theme.space[2]} ${theme.space[4]}`,
|
||||
letterSpacing: 0.1,
|
||||
lineHeight: "1.25rem",
|
||||
fontFamily: theme.font.family.body,
|
||||
fontWeight: theme.font.weight.medium,
|
||||
cursor: "pointer",
|
||||
transitionDelay: "0s, 0s",
|
||||
transitionDuration: "0.2s, 0.2s",
|
||||
transitionProperty: "background-color, border",
|
||||
transitionTimingFunction: "ease-out, ease-out",
|
||||
display: "inline-flex",
|
||||
gap: theme.space[2],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
":disabled": {
|
||||
pointerEvents: "none",
|
||||
},
|
||||
":hover": {
|
||||
background: theme.color.hoverColor
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ButtonText = styled("span", {
|
||||
base: {
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
}
|
||||
})
|
||||
|
||||
const Body = styled("div", {
|
||||
base: {
|
||||
padding: "0 24px",
|
||||
width: "calc(1000px + calc(2 * 24px))",
|
||||
minWidth: "calc(100vh - 273px)",
|
||||
margin: "24px auto"
|
||||
}
|
||||
})
|
||||
|
||||
const GamesContainer = styled("div", {
|
||||
base: {
|
||||
background: theme.color.background.d200,
|
||||
padding: "32px 16px",
|
||||
borderRadius: 5,
|
||||
border: `1px solid ${theme.color.gray.d400}`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
height: "calc(100vh - 300px)",
|
||||
}
|
||||
})
|
||||
|
||||
const EmptyState = styled("div", {
|
||||
base: {
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
gap: theme.space[8],
|
||||
flexDirection: "column"
|
||||
}
|
||||
})
|
||||
|
||||
const SteamLogoContainer = styled("div", {
|
||||
base: {
|
||||
height: 60,
|
||||
width: 60,
|
||||
padding: 4,
|
||||
borderRadius: 8,
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: theme.color.background.d200,
|
||||
border: `1px solid ${theme.color.gray.d400}`,
|
||||
}
|
||||
})
|
||||
export function SteamRoute() {
|
||||
const steam = useSteam();
|
||||
|
||||
createEffect(() => {
|
||||
// steam.client.loginStream.connect();
|
||||
|
||||
// Clean up on component unmount
|
||||
// onCleanup(() => {
|
||||
// steam.client.loginStream.disconnect();
|
||||
// });
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Root>
|
||||
<Section>
|
||||
<TitleHeader>
|
||||
<TitleWrapper>
|
||||
<TitleContainer>
|
||||
<Title>
|
||||
Steam Library
|
||||
</Title>
|
||||
<Description>
|
||||
{/* Read and write directly to databases and stores from your projects. */}
|
||||
Install games directly from your Steam account to your Nestri Machine
|
||||
</Description>
|
||||
</TitleContainer>
|
||||
<ButtonContainer>
|
||||
<QRButton>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="M15.974 0C7.573 0 .682 6.479.031 14.714l8.573 3.547a4.5 4.5 0 0 1 2.552-.786c.083 0 .167.005.25.005l3.813-5.521v-.078c0-3.328 2.703-6.031 6.031-6.031s6.036 2.708 6.036 6.036a6.04 6.04 0 0 1-6.036 6.031h-.135l-5.438 3.88c0 .073.005.141.005.214c0 2.5-2.021 4.526-4.521 4.526c-2.177 0-4.021-1.563-4.443-3.635L.583 20.36c1.901 6.719 8.063 11.641 15.391 11.641c8.833 0 15.995-7.161 15.995-16s-7.161-16-15.995-16zm-5.922 24.281l-1.964-.813a3.4 3.4 0 0 0 1.755 1.667a3.404 3.404 0 0 0 4.443-1.833a3.38 3.38 0 0 0 .005-2.599a3.36 3.36 0 0 0-1.839-1.844a3.38 3.38 0 0 0-2.5-.042l2.026.839c1.276.536 1.88 2 1.349 3.276s-2 1.88-3.276 1.349zm15.219-12.406a4.025 4.025 0 0 0-4.016-4.021a4.02 4.02 0 1 0 0 8.042a4.02 4.02 0 0 0 4.016-4.021m-7.026-.005c0-1.672 1.349-3.021 3.016-3.021s3.026 1.349 3.026 3.021c0 1.667-1.359 3.021-3.026 3.021s-3.016-1.354-3.016-3.021" />
|
||||
</svg>
|
||||
<ButtonText>
|
||||
Connect Steam
|
||||
</ButtonText>
|
||||
</QRButton>
|
||||
</ButtonContainer>
|
||||
</TitleWrapper>
|
||||
</TitleHeader>
|
||||
<Body>
|
||||
<GamesContainer>
|
||||
<EmptyState>
|
||||
<SteamLogoContainer>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="M15.974 0C7.573 0 .682 6.479.031 14.714l8.573 3.547a4.5 4.5 0 0 1 2.552-.786c.083 0 .167.005.25.005l3.813-5.521v-.078c0-3.328 2.703-6.031 6.031-6.031s6.036 2.708 6.036 6.036a6.04 6.04 0 0 1-6.036 6.031h-.135l-5.438 3.88c0 .073.005.141.005.214c0 2.5-2.021 4.526-4.521 4.526c-2.177 0-4.021-1.563-4.443-3.635L.583 20.36c1.901 6.719 8.063 11.641 15.391 11.641c8.833 0 15.995-7.161 15.995-16s-7.161-16-15.995-16zm-5.922 24.281l-1.964-.813a3.4 3.4 0 0 0 1.755 1.667a3.404 3.404 0 0 0 4.443-1.833a3.38 3.38 0 0 0 .005-2.599a3.36 3.36 0 0 0-1.839-1.844a3.38 3.38 0 0 0-2.5-.042l2.026.839c1.276.536 1.88 2 1.349 3.276s-2 1.88-3.276 1.349zm15.219-12.406a4.025 4.025 0 0 0-4.016-4.021a4.02 4.02 0 1 0 0 8.042a4.02 4.02 0 0 0 4.016-4.021m-7.026-.005c0-1.672 1.349-3.021 3.016-3.021s3.026 1.349 3.026 3.021c0 1.667-1.359 3.021-3.026 3.021s-3.016-1.354-3.016-3.021" />
|
||||
</svg>
|
||||
</SteamLogoContainer>
|
||||
<Text align="center" style={{ "letter-spacing": "-0.3px" }} size="base" >
|
||||
{/* After connecting your Steam account, your games will appear here */}
|
||||
{/* URL: {steam.client.loginStream.loginUrl()} */}
|
||||
</Text>
|
||||
</EmptyState>
|
||||
</GamesContainer>
|
||||
</Body>
|
||||
</Section>
|
||||
</Root>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createStore } from "solid-js/store";
|
||||
import { createStore, reconcile } from "solid-js/store";
|
||||
import { makePersisted } from "@solid-primitives/storage";
|
||||
import { ParentProps, createContext, useContext } from "solid-js";
|
||||
|
||||
@@ -10,7 +10,6 @@ function init() {
|
||||
createStore({
|
||||
account: "",
|
||||
team: "",
|
||||
dummy: "",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -31,4 +30,74 @@ export function useStorage() {
|
||||
throw new Error("No storage context");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
}
|
||||
|
||||
import { createEffect } from "solid-js";
|
||||
import { useOpenAuth } from "@openauthjs/solid"
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { createInitializedContext } from "../common/context";
|
||||
|
||||
type Storage = {
|
||||
accounts: Record<string, {
|
||||
id: string
|
||||
email: string
|
||||
avatarUrl?: string
|
||||
discriminator: number
|
||||
name: string;
|
||||
polarCustomerID: string;
|
||||
teams: Team.Info[];
|
||||
}>
|
||||
}
|
||||
|
||||
export const { use: useAccount, provider: AccountProvider } = createInitializedContext("AccountContext", () => {
|
||||
const auth = useOpenAuth()
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore<Storage>({
|
||||
accounts: {},
|
||||
}),
|
||||
{
|
||||
name: "nestri.account",
|
||||
},
|
||||
);
|
||||
|
||||
async function refresh(id: string) {
|
||||
const access = await auth.access(id).catch(() => { })
|
||||
if (!access) {
|
||||
auth.authorize()
|
||||
return
|
||||
}
|
||||
return await fetch(import.meta.env.VITE_API_URL + "/account", {
|
||||
headers: {
|
||||
authorization: `Bearer ${access}`,
|
||||
},
|
||||
})
|
||||
.then(val => val.json())
|
||||
.then(val => setStore("accounts", id, reconcile(val.data)))
|
||||
}
|
||||
|
||||
createEffect((previous: string[]) => {
|
||||
if (!Object.values(auth.all).length) {
|
||||
auth.authorize()
|
||||
return []
|
||||
}
|
||||
for (const item of Object.values(auth.all)) {
|
||||
if (previous.includes(item.id)) continue
|
||||
refresh(item.id)
|
||||
}
|
||||
return Object.keys(auth.all)
|
||||
}, [] as string[])
|
||||
|
||||
return {
|
||||
get all() {
|
||||
return store.accounts
|
||||
},
|
||||
get current() {
|
||||
return store.accounts[auth.subject!.id]
|
||||
},
|
||||
refresh,
|
||||
get ready() {
|
||||
if (!auth.subject) return false
|
||||
return store.accounts[auth.subject.id] !== undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
36
packages/www/src/providers/api.tsx
Normal file
36
packages/www/src/providers/api.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { hc } from "hono/client";
|
||||
import { useTeam } from "./context";
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { type app } from "@nestri/functions/api/index";
|
||||
import { createInitializedContext } from "@nestri/www/common/context";
|
||||
|
||||
|
||||
export const { use: useApi, provider: ApiProvider } = createInitializedContext(
|
||||
"Api",
|
||||
() => {
|
||||
const team = useTeam();
|
||||
const auth = useOpenAuth();
|
||||
|
||||
const client = hc<typeof app>(import.meta.env.VITE_API_URL, {
|
||||
async fetch(...args: Parameters<typeof fetch>): Promise<Response> {
|
||||
const [input, init] = args;
|
||||
const request =
|
||||
input instanceof Request ? input : new Request(input, init);
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("authorization", `Bearer ${await auth.access()}`);
|
||||
headers.set("x-nestri-team", team().id);
|
||||
|
||||
return fetch(
|
||||
new Request(request, {
|
||||
...init,
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
return {
|
||||
client,
|
||||
ready: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -1,226 +0,0 @@
|
||||
import { type Team } from "@nestri/core/team/index";
|
||||
import { makePersisted } from "@solid-primitives/storage";
|
||||
import { useLocation, useNavigate } from "@solidjs/router";
|
||||
import { createClient } from "@openauthjs/openauth/client";
|
||||
import { createInitializedContext } from "../common/context";
|
||||
import { createEffect, createMemo, onMount } from "solid-js";
|
||||
import { createStore, produce, reconcile } from "solid-js/store";
|
||||
|
||||
interface AccountInfo {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
access: string;
|
||||
refresh: string;
|
||||
avatarUrl: string;
|
||||
teams: Team.Info[];
|
||||
discriminator: number;
|
||||
polarCustomerID: string | null;
|
||||
}
|
||||
|
||||
interface Storage {
|
||||
accounts: Record<string, AccountInfo>;
|
||||
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",
|
||||
});
|
||||
|
||||
export const { use: useAuth, provider: AuthProvider } =
|
||||
createInitializedContext("AuthContext", () => {
|
||||
const [store, setStore] = makePersisted(
|
||||
createStore<Storage>({
|
||||
accounts: {},
|
||||
}),
|
||||
{
|
||||
name: "radiant.auth",
|
||||
},
|
||||
);
|
||||
const location = useLocation();
|
||||
const params = createMemo(
|
||||
() => new URLSearchParams(location.hash.substring(1)),
|
||||
);
|
||||
const accessToken = createMemo(() => params().get("access_token"));
|
||||
const refreshToken = createMemo(() => params().get("refresh_token"));
|
||||
|
||||
|
||||
createEffect(async () => {
|
||||
// if (!result.current && Object.keys(store.accounts).length) {
|
||||
// result.switch(Object.keys(store.accounts)[0])
|
||||
// navigate("/")
|
||||
// }
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
if (accessToken()) return;
|
||||
if (Object.keys(store.accounts).length) return;
|
||||
const redirect = await client.authorize(window.location.origin, "token");
|
||||
window.location.href = redirect.url
|
||||
});
|
||||
|
||||
createEffect(async () => {
|
||||
const current = store.current;
|
||||
const accounts = store.accounts;
|
||||
if (!current) return;
|
||||
const match = accounts[current];
|
||||
if (match) return;
|
||||
const keys = Object.keys(accounts);
|
||||
if (keys.length) {
|
||||
setStore("current", keys[0]);
|
||||
navigate("/");
|
||||
return
|
||||
}
|
||||
const redirect = await client.authorize(window.location.origin, "token");
|
||||
window.location.href = redirect.url
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
for (const account of [...Object.values(store.accounts)]) {
|
||||
if (!account.refresh) continue;
|
||||
const result = await client.refresh(account.refresh, {
|
||||
access: account.access,
|
||||
})
|
||||
if (result.err) {
|
||||
console.log("error", result.err)
|
||||
if ("id" in account)
|
||||
setStore(produce((state) => {
|
||||
delete state.accounts[account.id];
|
||||
}))
|
||||
continue
|
||||
};
|
||||
const tokens = result.tokens || {
|
||||
access: account.access,
|
||||
refresh: account.refresh,
|
||||
}
|
||||
fetch(import.meta.env.VITE_API_URL + "/account", {
|
||||
headers: {
|
||||
authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
}).then(async (response) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
const info = await result.data;
|
||||
|
||||
setStore(
|
||||
"accounts",
|
||||
info.id,
|
||||
reconcile({
|
||||
...info,
|
||||
...tokens,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok)
|
||||
console.log("error from account", response.json())
|
||||
setStore(
|
||||
produce((state) => {
|
||||
delete state.accounts[account.id];
|
||||
}),
|
||||
);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (refreshToken() && accessToken()) {
|
||||
const result = await fetch(import.meta.env.VITE_API_URL + "/account", {
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken()}`,
|
||||
},
|
||||
}).catch(() => { })
|
||||
if (result?.ok) {
|
||||
const response = await result.json();
|
||||
const info = await response.data;
|
||||
setStore(
|
||||
"accounts",
|
||||
info.id,
|
||||
reconcile({
|
||||
...info,
|
||||
access: accessToken(),
|
||||
refresh: refreshToken(),
|
||||
}),
|
||||
);
|
||||
setStore("current", info.id);
|
||||
}
|
||||
window.location.hash = "";
|
||||
}
|
||||
|
||||
await refresh();
|
||||
})
|
||||
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// const bar = useCommandBar()
|
||||
|
||||
// bar.register("auth", async () => {
|
||||
// return [
|
||||
// {
|
||||
// category: "Account",
|
||||
// title: "Logout",
|
||||
// icon: IconLogout,
|
||||
// run: async (bar) => {
|
||||
// result.logout();
|
||||
// setStore("current", undefined);
|
||||
// navigate("/");
|
||||
// bar.hide()
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// category: "Add Account",
|
||||
// title: "Add Account",
|
||||
// icon: IconUserAdd,
|
||||
// run: async () => {
|
||||
// const redir = await client.authorize(window.location.origin, "token");
|
||||
// window.location.href = redir.url
|
||||
// bar.hide()
|
||||
// },
|
||||
// },
|
||||
// ...result.all()
|
||||
// .filter((item) => item.id !== result.current.id)
|
||||
// .map((item) => ({
|
||||
// category: "Account",
|
||||
// title: "Switch to " + item.email,
|
||||
// icon: IconUser,
|
||||
// run: async () => {
|
||||
// result.switch(item.id);
|
||||
// navigate("/");
|
||||
// bar.hide()
|
||||
// },
|
||||
// })),
|
||||
// ]
|
||||
// })
|
||||
|
||||
const result = {
|
||||
get current() {
|
||||
return store.accounts[store.current!]!;
|
||||
},
|
||||
switch(accountID: string) {
|
||||
setStore("current", accountID);
|
||||
},
|
||||
all() {
|
||||
return Object.values(store.accounts);
|
||||
},
|
||||
refresh,
|
||||
logout() {
|
||||
setStore(
|
||||
produce((state) => {
|
||||
if (!state.current) return;
|
||||
delete state.accounts[state.current];
|
||||
state.current = Object.keys(state.accounts)[0];
|
||||
}),
|
||||
);
|
||||
},
|
||||
get ready() {
|
||||
return Boolean(!accessToken() && store.current);
|
||||
},
|
||||
};
|
||||
return result;
|
||||
});
|
||||
10
packages/www/src/providers/context.tsx
Normal file
10
packages/www/src/providers/context.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Team } from "@nestri/core/team/index";
|
||||
import { Accessor, createContext, useContext } from "solid-js";
|
||||
|
||||
export const TeamContext = createContext<Accessor<Team.Info>>();
|
||||
|
||||
export function useTeam() {
|
||||
const context = useContext(TeamContext);
|
||||
if (!context) throw new Error("No team context");
|
||||
return context;
|
||||
}
|
||||
223
packages/www/src/providers/steam.tsx
Normal file
223
packages/www/src/providers/steam.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import { useTeam } from "./context";
|
||||
import { EventSource } from 'eventsource'
|
||||
import { useOpenAuth } from "@openauthjs/solid";
|
||||
import { createSignal, onCleanup } from "solid-js";
|
||||
import { createInitializedContext } from "../common/context";
|
||||
|
||||
// Type definitions for the events
|
||||
interface SteamEventTypes {
|
||||
'url': string;
|
||||
'login-attempt': { username: string };
|
||||
'login-success': { username: string; steamId: string };
|
||||
'login-unsuccessful': { error: string };
|
||||
'logged-off': { reason: string };
|
||||
}
|
||||
|
||||
// Type for the connection
|
||||
type SteamConnection = {
|
||||
addEventListener: <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => () => void;
|
||||
removeEventListener: <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => void;
|
||||
disconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
}
|
||||
|
||||
interface SteamContext {
|
||||
ready: boolean;
|
||||
client: {
|
||||
// Regular API endpoints
|
||||
whoami: () => Promise<any>;
|
||||
games: () => Promise<any>;
|
||||
// SSE connection for login
|
||||
login: {
|
||||
connect: () => SteamConnection;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Create the initialized context
|
||||
export const { use: useSteam, provider: SteamProvider } = createInitializedContext(
|
||||
"Steam",
|
||||
() => {
|
||||
const team = useTeam();
|
||||
const auth = useOpenAuth();
|
||||
|
||||
// Create the HTTP client for regular endpoints
|
||||
const client = {
|
||||
// Regular HTTP endpoints
|
||||
whoami: async () => {
|
||||
const token = await auth.access();
|
||||
const response = await fetch(`${import.meta.env.VITE_STEAM_URL}/whoami`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'x-nestri-team': team().id
|
||||
}
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
games: async () => {
|
||||
const token = await auth.access();
|
||||
const response = await fetch(`${import.meta.env.VITE_STEAM_URL}/games`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'x-nestri-team': team().id
|
||||
}
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
// SSE connection factory for login
|
||||
login: {
|
||||
connect: async (): Promise<SteamConnection> => {
|
||||
let eventSource: EventSource | null = null;
|
||||
const [isConnected, setIsConnected] = createSignal(false);
|
||||
|
||||
// Store event listeners
|
||||
const listeners: Record<string, Array<(data: any) => void>> = {
|
||||
'url': [],
|
||||
'login-attempt': [],
|
||||
'login-success': [],
|
||||
'login-unsuccessful': [],
|
||||
'logged-off': []
|
||||
};
|
||||
|
||||
// Method to add event listeners
|
||||
const addEventListener = <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => {
|
||||
if (!listeners[event]) {
|
||||
listeners[event] = [];
|
||||
}
|
||||
|
||||
listeners[event].push(callback as any);
|
||||
|
||||
// Return a function to remove this specific listener
|
||||
return () => {
|
||||
removeEventListener(event, callback);
|
||||
};
|
||||
};
|
||||
|
||||
// Method to remove event listeners
|
||||
const removeEventListener = <T extends keyof SteamEventTypes>(
|
||||
event: T,
|
||||
callback: (data: SteamEventTypes[T]) => void
|
||||
) => {
|
||||
if (listeners[event]) {
|
||||
const index = listeners[event].indexOf(callback as any);
|
||||
if (index !== -1) {
|
||||
listeners[event].splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize connection
|
||||
const initConnection = async () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await auth.access();
|
||||
|
||||
eventSource = new EventSource(`${import.meta.env.VITE_STEAM_URL}/login`, {
|
||||
fetch: (input, init) =>
|
||||
fetch(input, {
|
||||
...init,
|
||||
headers: {
|
||||
...init?.headers,
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'x-nestri-team': team().id
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
eventSource.onopen = () => {
|
||||
console.log('Connected to Steam login stream');
|
||||
setIsConnected(true);
|
||||
};
|
||||
|
||||
// Set up event handlers for all specific events
|
||||
['url', 'login-attempt', 'login-success', 'login-unsuccessful', 'logged-off'].forEach((eventType) => {
|
||||
eventSource!.addEventListener(eventType, (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(`Received ${eventType} event:`, data);
|
||||
|
||||
// Notify all registered listeners for this event type
|
||||
if (listeners[eventType]) {
|
||||
listeners[eventType].forEach(callback => {
|
||||
callback(data);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing ${eventType} event data:`, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle generic messages (fallback)
|
||||
eventSource.onmessage = (event) => {
|
||||
console.log('Received generic message:', event.data);
|
||||
};
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('Steam login stream error:', error);
|
||||
setIsConnected(false);
|
||||
// Attempt to reconnect after a delay
|
||||
setTimeout(initConnection, 5000);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to Steam login stream:', error);
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Disconnection function
|
||||
const disconnect = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
setIsConnected(false);
|
||||
console.log('Disconnected from Steam login stream');
|
||||
|
||||
// Clear all listeners
|
||||
Object.keys(listeners).forEach(key => {
|
||||
listeners[key] = [];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Start the connection immediately
|
||||
await initConnection();
|
||||
|
||||
// Create the connection interface
|
||||
const connection: SteamConnection = {
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
disconnect,
|
||||
isConnected: () => isConnected()
|
||||
};
|
||||
|
||||
// Clean up on context destruction
|
||||
onCleanup(() => {
|
||||
disconnect();
|
||||
});
|
||||
|
||||
return connection;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
client,
|
||||
ready: true
|
||||
};
|
||||
}
|
||||
);
|
||||
39
packages/www/src/providers/zero.tsx
Normal file
39
packages/www/src/providers/zero.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useTeam } from "./context"
|
||||
import { createEffect } from "solid-js"
|
||||
import { schema } from "@nestri/zero/schema"
|
||||
import { useQuery } from "@rocicorp/zero/solid"
|
||||
import { useOpenAuth } from "@openauthjs/solid"
|
||||
import { Query, Schema, Zero } from "@rocicorp/zero"
|
||||
import { useAccount } from "@nestri/www/providers/account"
|
||||
import { createInitializedContext } from "@nestri/www/common/context"
|
||||
|
||||
export const { use: useZero, provider: ZeroProvider } =
|
||||
createInitializedContext("ZeroContext", () => {
|
||||
const auth = useOpenAuth()
|
||||
const account = useAccount()
|
||||
const team = useTeam()
|
||||
const zero = new Zero({
|
||||
schema: schema,
|
||||
auth: () => auth.access(),
|
||||
userID: account.current.email,
|
||||
storageKey: team().id,
|
||||
server: import.meta.env.VITE_ZERO_URL,
|
||||
})
|
||||
|
||||
return {
|
||||
mutate: zero.mutate,
|
||||
query: zero.query,
|
||||
client: zero,
|
||||
ready: true,
|
||||
};
|
||||
});
|
||||
|
||||
export function usePersistentQuery<TSchema extends Schema, TTable extends keyof TSchema['tables'] & string, TReturn>(querySignal: () => Query<TSchema, TTable, TReturn>) {
|
||||
const team = useTeam()
|
||||
//@ts-ignore
|
||||
const q = () => querySignal().where("team_id", "=", team().id).where("time_deleted", "IS", null)
|
||||
createEffect(() => {
|
||||
q().preload()
|
||||
})
|
||||
return useQuery<TSchema, TTable, TReturn>(q)
|
||||
}
|
||||
4
packages/www/src/sst-env.d.ts
vendored
4
packages/www/src/sst-env.d.ts
vendored
@@ -4,8 +4,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
readonly VITE_AUTH_URL: string
|
||||
readonly VITE_STAGE: string
|
||||
readonly VITE_AUTH_URL: string
|
||||
readonly VITE_ZERO_URL: string
|
||||
readonly VITE_STEAM_URL: string
|
||||
}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
|
||||
97
packages/www/src/ui/avatar.tsx
Normal file
97
packages/www/src/ui/avatar.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
const DEFAULT_COLORS = ['#6A5ACD', '#E63525', '#20B2AA', '#E87D58'];
|
||||
|
||||
const getModulo = (value: number, divisor: number, useEvenCheck?: number) => {
|
||||
const remainder = value % divisor;
|
||||
if (useEvenCheck && Math.floor(value / Math.pow(10, useEvenCheck) % 10) % 2 === 0) {
|
||||
return -remainder;
|
||||
}
|
||||
return remainder;
|
||||
};
|
||||
|
||||
const generateColors = (name: string, colors = DEFAULT_COLORS) => {
|
||||
const hashCode = name.split('').reduce((acc, char) => {
|
||||
acc = ((acc << 5) - acc) + char.charCodeAt(0);
|
||||
return acc & acc;
|
||||
}, 0);
|
||||
|
||||
const hash = Math.abs(hashCode);
|
||||
const numColors = colors.length;
|
||||
|
||||
return Array.from({ length: 3 }, (_, index) => ({
|
||||
color: colors[(hash + index) % numColors],
|
||||
translateX: getModulo(hash * (index + 1), 4, 1),
|
||||
translateY: getModulo(hash * (index + 1), 4, 2),
|
||||
scale: 1.2 + getModulo(hash * (index + 1), 2) / 10,
|
||||
rotate: getModulo(hash * (index + 1), 360, 1)
|
||||
}));
|
||||
};
|
||||
type Props = {
|
||||
name: string;
|
||||
size?: number;
|
||||
class?: string;
|
||||
colors?: string[]
|
||||
}
|
||||
|
||||
export default function Avatar({ class: className, name, size = 80, colors = DEFAULT_COLORS }: Props) {
|
||||
const colorData = generateColors(name, colors);
|
||||
|
||||
const blurValue = Math.max(1, Math.min(7, size / 10));
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
role="img"
|
||||
class={className}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
aria-describedby={name}
|
||||
width={size}
|
||||
height={size}
|
||||
>
|
||||
<title id={name}>{`Fallback avatar for ${name}`}</title>
|
||||
<mask
|
||||
id="mask__marble"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x={0}
|
||||
y={0}
|
||||
width={80}
|
||||
height={80}
|
||||
>
|
||||
<rect width={80} height={80} rx={160} fill="#FFFFFF" />
|
||||
</mask>
|
||||
<g mask="url(#mask__marble)">
|
||||
<rect width={80} height={80} rx={160} fill={colorData[0].color} />
|
||||
<path
|
||||
filter="url(#prefix__filter0_f)"
|
||||
d="M32.414 59.35L50.376 70.5H72.5v-71H33.728L26.5 13.381l19.057 27.08L32.414 59.35z"
|
||||
fill={colorData[1].color}
|
||||
transform={
|
||||
`translate(${colorData[1].translateX} ${colorData[1].translateY})
|
||||
rotate(${colorData[1].rotate} 40 40)
|
||||
scale(${colorData[1].scale})`}
|
||||
/>
|
||||
<path
|
||||
filter="url(#prefix__filter0_f)"
|
||||
style={{ "mix-blend-mode": "overlay" }}
|
||||
d="M22.216 24L0 46.75l14.108 38.129L78 86l-3.081-59.276-22.378 4.005 12.972 20.186-23.35 27.395L22.215 24z"
|
||||
fill={colorData[2].color}
|
||||
transform={
|
||||
`translate(${colorData[2].translateX} ${colorData[2].translateY})
|
||||
rotate(${colorData[2].rotate} 40 40)
|
||||
scale(${colorData[2].scale})`}
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter
|
||||
id="prefix__filter0_f"
|
||||
filterUnits="userSpaceOnUse"
|
||||
color-interpolation-filters="s-rGB"
|
||||
>
|
||||
<feFlood flood-opacity={0} result="BackgroundImageFix" />
|
||||
<feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
|
||||
<feGaussianBlur stdDeviation={blurValue} result="effect1_foregroundBlur" />
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export const Button = styled("button", {
|
||||
lineHeight: "normal",
|
||||
fontFamily: theme.font.family.heading,
|
||||
textAlign: "center",
|
||||
cursor: "pointer",
|
||||
transitionDelay: "0s, 0s",
|
||||
transitionDuration: "0.2s, 0.2s",
|
||||
transitionProperty: "background-color, border",
|
||||
|
||||
160
packages/www/src/ui/custom-qr.tsx
Normal file
160
packages/www/src/ui/custom-qr.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import QRCodeUtil from 'qrcode';
|
||||
import { createMemo, JSXElement } from "solid-js"
|
||||
|
||||
const generateMatrix = (
|
||||
value: string,
|
||||
errorCorrectionLevel: QRCodeUtil.QRCodeErrorCorrectionLevel
|
||||
) => {
|
||||
const arr = Array.prototype.slice.call(
|
||||
QRCodeUtil.create(value, { errorCorrectionLevel }).modules.data,
|
||||
0
|
||||
);
|
||||
const sqrt = Math.sqrt(arr.length);
|
||||
return arr.reduce(
|
||||
(rows, key, index) =>
|
||||
(index % sqrt === 0
|
||||
? rows.push([key])
|
||||
: rows[rows.length - 1].push(key)) && rows,
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
ecl?: QRCodeUtil.QRCodeErrorCorrectionLevel;
|
||||
size?: number;
|
||||
uri: string;
|
||||
clearArea?: boolean;
|
||||
image?: HTMLImageElement;
|
||||
imageBackground?: string;
|
||||
};
|
||||
|
||||
export function QRCode({
|
||||
ecl = 'M',
|
||||
size: sizeProp = 200,
|
||||
uri,
|
||||
clearArea = false,
|
||||
image,
|
||||
imageBackground = 'transparent',
|
||||
}: Props) {
|
||||
const logoSize = clearArea ? 32 : 0;
|
||||
const size = sizeProp - 10 * 2;
|
||||
|
||||
const dots = createMemo(() => {
|
||||
const dots: JSXElement[] = [];
|
||||
const matrix = generateMatrix(uri, ecl);
|
||||
const cellSize = size / matrix.length;
|
||||
let qrList = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
];
|
||||
|
||||
qrList.forEach(({ x, y }) => {
|
||||
const x1 = (matrix.length - 7) * cellSize * x;
|
||||
const y1 = (matrix.length - 7) * cellSize * y;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
dots.push(
|
||||
<rect
|
||||
id={`${i}-${x}-${y}`}
|
||||
fill={
|
||||
i % 2 !== 0
|
||||
? 'var(--nestri-qr-background, var(--nestri-body-background))'
|
||||
: 'var(--nestri-qr-dot-color)'
|
||||
}
|
||||
rx={(i - 2) * -5 + (i === 0 ? 2 : 3)}
|
||||
ry={(i - 2) * -5 + (i === 0 ? 2 : 3)}
|
||||
width={cellSize * (7 - i * 2)}
|
||||
height={cellSize * (7 - i * 2)}
|
||||
x={x1 + cellSize * i}
|
||||
y={y1 + cellSize * i}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (image) {
|
||||
const x1 = (matrix.length - 7) * cellSize * 1;
|
||||
const y1 = (matrix.length - 7) * cellSize * 1;
|
||||
dots.push(
|
||||
<>
|
||||
<rect
|
||||
fill={imageBackground}
|
||||
rx={(0 - 2) * -5 + 2}
|
||||
ry={(0 - 2) * -5 + 2}
|
||||
width={cellSize * (7 - 0 * 2)}
|
||||
height={cellSize * (7 - 0 * 2)}
|
||||
x={x1 + cellSize * 0}
|
||||
y={y1 + cellSize * 0}
|
||||
/>
|
||||
<foreignObject
|
||||
width={cellSize * (7 - 0 * 2)}
|
||||
height={cellSize * (7 - 0 * 2)}
|
||||
x={x1 + cellSize * 0}
|
||||
y={y1 + cellSize * 0}
|
||||
>
|
||||
<div style={{ "border-radius": `${(0 - 2) * -5 + 2}px`, overflow: 'hidden' }}>
|
||||
{image}
|
||||
</div>
|
||||
</foreignObject>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const clearArenaSize = Math.floor((logoSize + 25) / cellSize);
|
||||
const matrixMiddleStart = matrix.length / 2 - clearArenaSize / 2;
|
||||
const matrixMiddleEnd = matrix.length / 2 + clearArenaSize / 2 - 1;
|
||||
|
||||
matrix.forEach((row: QRCodeUtil.QRCode[], i: number) => {
|
||||
row.forEach((_: any, j: number) => {
|
||||
if (matrix[i][j]) {
|
||||
// Do not render dots under position squares
|
||||
if (
|
||||
!(
|
||||
(i < 7 && j < 7) ||
|
||||
(i > matrix.length - 8 && j < 7) ||
|
||||
(i < 7 && j > matrix.length - 8)
|
||||
)
|
||||
) {
|
||||
//if (image && i > matrix.length - 9 && j > matrix.length - 9) return;
|
||||
if (
|
||||
image ||
|
||||
!(
|
||||
i > matrixMiddleStart &&
|
||||
i < matrixMiddleEnd &&
|
||||
j > matrixMiddleStart &&
|
||||
j < matrixMiddleEnd
|
||||
)
|
||||
) {
|
||||
dots.push(
|
||||
<circle
|
||||
id={`circle-${i}-${j}`}
|
||||
cx={i * cellSize + cellSize / 2}
|
||||
cy={j * cellSize + cellSize / 2}
|
||||
fill="var(--nestri-qr-dot-color)"
|
||||
r={cellSize / 3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return dots;
|
||||
}, [ecl, size, uri]);
|
||||
|
||||
return (
|
||||
<svg
|
||||
height={size}
|
||||
width={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
style={{
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
}}
|
||||
>
|
||||
<rect fill="transparent" height={size} width={size} />
|
||||
{dots()}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { theme } from "./theme";
|
||||
import { utility } from "./utility";
|
||||
import { Container } from "./layout";
|
||||
import { styled } from "@macaron-css/solid"
|
||||
import { CSSProperties } from "@macaron-css/core";
|
||||
import { ComponentProps, createMemo, For, JSX, Show, splitProps } from "solid-js";
|
||||
import { Container } from "./layout";
|
||||
import { utility } from "./utility";
|
||||
import { ComponentProps, For, JSX, Show, splitProps } from "solid-js";
|
||||
|
||||
// FIXME: Make sure the focus ring goes to red when the input is invalid
|
||||
|
||||
export const inputStyles: CSSProperties = {
|
||||
lineHeight: theme.font.lineHeight,
|
||||
appearance: "none",
|
||||
width: "100%",
|
||||
fontSize: theme.font.size.sm,
|
||||
borderRadius: theme.borderRadius,
|
||||
padding: `0 ${theme.space[3]}`,
|
||||
@@ -57,12 +58,7 @@ export const Root = styled("label", {
|
||||
color: theme.color.gray.d900
|
||||
},
|
||||
danger: {
|
||||
color: theme.color.red.d900,
|
||||
// selectors: {
|
||||
// "&:has(input)": {
|
||||
// ...inputDangerFocusStyles
|
||||
// }
|
||||
// }
|
||||
color: theme.color.gray.d900,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -88,6 +84,12 @@ export const Input = styled("input", {
|
||||
"::placeholder": {
|
||||
color: theme.color.gray.d800
|
||||
},
|
||||
selectors: {
|
||||
"[data-type='url'] &": {
|
||||
borderTopLeftRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
}
|
||||
}
|
||||
// ":invalid":{
|
||||
// ...inputDangerFocusStyles
|
||||
// },
|
||||
@@ -121,11 +123,8 @@ export const Input = styled("input", {
|
||||
export const InputRadio = styled("input", {
|
||||
base: {
|
||||
padding: 0,
|
||||
// borderRadius: 0,
|
||||
WebkitAppearance: "none",
|
||||
appearance: "none",
|
||||
/* For iOS < 15 to remove gradient background */
|
||||
backgroundColor: theme.color.background.d100,
|
||||
/* Not removed via appearance */
|
||||
margin: 0,
|
||||
font: "inherit",
|
||||
@@ -179,6 +178,7 @@ const InputLabel = styled("label", {
|
||||
borderColor: theme.color.gray.d400,
|
||||
color: theme.color.gray.d800,
|
||||
backgroundColor: theme.color.background.d100,
|
||||
transition: "all 0.3s cubic-bezier(0.4,0,0.2,1)",
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -197,7 +197,8 @@ const InputLabel = styled("label", {
|
||||
borderBottomLeftRadius: theme.borderRadius,
|
||||
},
|
||||
":hover": {
|
||||
backgroundColor: theme.color.background.d200,
|
||||
backgroundColor: theme.color.grayAlpha.d200,
|
||||
color: theme.color.d1000.gray
|
||||
},
|
||||
selectors: {
|
||||
"&:has(input:checked)": {
|
||||
@@ -243,9 +244,9 @@ export function FormField(props: FormFieldProps) {
|
||||
}
|
||||
|
||||
type SelectProps = {
|
||||
ref: (element: HTMLInputElement) => void;
|
||||
name: string;
|
||||
value: any;
|
||||
ref: (element: HTMLInputElement) => void;
|
||||
onInput: JSX.EventHandler<HTMLInputElement, InputEvent>;
|
||||
onChange: JSX.EventHandler<HTMLInputElement, Event>;
|
||||
onBlur: JSX.EventHandler<HTMLInputElement, FocusEvent>;
|
||||
@@ -276,7 +277,6 @@ const Badge = styled("div", {
|
||||
padding: "0 6px",
|
||||
fontSize: theme.font.size.xs
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
export function Select(props: SelectProps) {
|
||||
|
||||
@@ -3,18 +3,41 @@ import { styled } from "@macaron-css/solid";
|
||||
|
||||
export const FullScreen = styled("div", {
|
||||
base: {
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
position: "fixed",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.color.background.d200,
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
justifyContent: "center"
|
||||
},
|
||||
variants: {
|
||||
inset: {
|
||||
none: {},
|
||||
header: {
|
||||
top: theme.headerHeight.root,
|
||||
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
|
||||
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const Screen = styled("div", {
|
||||
base: {
|
||||
display: "flex",
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
width: "100%",
|
||||
justifyContent: "center"
|
||||
},
|
||||
variants: {
|
||||
inset: {
|
||||
none: {},
|
||||
header: {
|
||||
paddingTop: `calc(1px + ${theme.headerHeight.root})`,
|
||||
minHeight: `calc(100dvh - ${theme.headerHeight.root})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
4
packages/www/src/ui/modal/index.ts
Normal file
4
packages/www/src/ui/modal/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { HModalRoot as Root } from './modal-root';
|
||||
export { HModalPanel as Panel } from './modal-panel';
|
||||
export { HModalTrigger as Trigger } from './modal-trigger';
|
||||
export * as Modal from "."
|
||||
20
packages/www/src/ui/modal/modal-context.tsx
Normal file
20
packages/www/src/ui/modal/modal-context.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Accessor, createContext, Setter, useContext } from "solid-js";
|
||||
|
||||
export const ModalContext = createContext<ModalContext>();
|
||||
|
||||
export function useModal() {
|
||||
const ctx = useContext(ModalContext);
|
||||
if (!ctx) throw new Error("No modal context");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export type ModalContext = {
|
||||
// core state
|
||||
localId: string;
|
||||
show: Accessor<boolean>;
|
||||
setShow: Setter<boolean>;
|
||||
onShow?: () => void;
|
||||
onClose?: () => void;
|
||||
closeOnBackdropClick?: boolean;
|
||||
alert?: boolean;
|
||||
};
|
||||
117
packages/www/src/ui/modal/modal-panel.tsx
Normal file
117
packages/www/src/ui/modal/modal-panel.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { useModal } from './use-modal';
|
||||
import { useModal as useModalContext } from './modal-context';
|
||||
import { Accessor, ComponentProps, createEffect, createSignal, onCleanup } from 'solid-js';
|
||||
|
||||
export type ModalProps = Omit<ComponentProps<'dialog'>, 'open'> & {
|
||||
onShow?: () => void;
|
||||
onClose?: () => void;
|
||||
onKeyDown?: () => void;
|
||||
'bind:show': Accessor<boolean>;
|
||||
closeOnBackdropClick?: boolean;
|
||||
alert?: boolean;
|
||||
};
|
||||
|
||||
export const HModalPanel = (props: ComponentProps<'dialog'>) => {
|
||||
const {
|
||||
activateFocusTrap,
|
||||
closeModal,
|
||||
deactivateFocusTrap,
|
||||
showModal,
|
||||
trapFocus,
|
||||
wasModalBackdropClicked,
|
||||
} = useModal();
|
||||
const context = useModalContext();
|
||||
|
||||
const [panelRef, setPanelRef] = createSignal<HTMLDialogElement>();
|
||||
let focusTrapRef: any = null;
|
||||
|
||||
createEffect(async () => {
|
||||
const dialog = panelRef();
|
||||
if (!dialog) return;
|
||||
|
||||
if (context.show()) {
|
||||
// Handle iOS scroll position issue
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
|
||||
let originalRAF;
|
||||
|
||||
if (isIOS) {
|
||||
originalRAF = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = () => 42;
|
||||
}
|
||||
|
||||
await showModal(dialog);
|
||||
|
||||
if (isIOS && originalRAF) {
|
||||
window.requestAnimationFrame = originalRAF;
|
||||
}
|
||||
|
||||
// Setup focus trap after showing modal
|
||||
focusTrapRef = await trapFocus(dialog);
|
||||
activateFocusTrap(focusTrapRef);
|
||||
|
||||
// Trigger show callback
|
||||
context.onShow?.();
|
||||
} else {
|
||||
await closeModal(dialog);
|
||||
// Trigger close callback
|
||||
context.onClose?.();
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
onCleanup(() => {
|
||||
if (focusTrapRef) {
|
||||
deactivateFocusTrap(focusTrapRef);
|
||||
}
|
||||
});
|
||||
|
||||
const handleBackdropClick = async (e: MouseEvent) => {
|
||||
if (context.alert === true || context.closeOnBackdropClick === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only close if the backdrop itself was clicked (not content)
|
||||
if (e.target instanceof HTMLDialogElement && await wasModalBackdropClicked(panelRef(), e)) {
|
||||
context.setShow(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Prevent spacebar/enter from triggering if dialog itself is focused
|
||||
if (e.target instanceof HTMLDialogElement && [' ', 'Enter'].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
// Handle escape key to close modal
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
context.setShow(false);
|
||||
}
|
||||
|
||||
// Allow other keydown handlers to run
|
||||
// props.onKeyDown?.(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<dialog
|
||||
{...props}
|
||||
id={`${context.localId}-root`}
|
||||
aria-labelledby={`${context.localId}-title`}
|
||||
aria-describedby={`${context.localId}-description`}
|
||||
data-state={context.show() ? 'open' : 'closed'}
|
||||
data-open={context.show() ? '' : undefined}
|
||||
data-closed={!context.show() ? '' : undefined}
|
||||
role={context.alert === true ? 'alertdialog' : 'dialog'}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={setPanelRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleBackdropClick(e);
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
34
packages/www/src/ui/modal/modal-root.tsx
Normal file
34
packages/www/src/ui/modal/modal-root.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
import { ModalContext } from './modal-context';
|
||||
import { Accessor, ComponentProps, createSignal, createUniqueId, splitProps } from 'solid-js';
|
||||
|
||||
type ModalRootProps = {
|
||||
onShow?: () => void;
|
||||
onClose?: () => void;
|
||||
'bind:show'?: Accessor<boolean>;
|
||||
closeOnBackdropClick?: boolean;
|
||||
alert?: boolean;
|
||||
} & ComponentProps<'div'>;
|
||||
|
||||
export const HModalRoot = (props: ModalRootProps) => {
|
||||
const localId = createUniqueId();
|
||||
|
||||
const [modalProps, divProps] = splitProps(props, [
|
||||
'bind:show',
|
||||
'closeOnBackdropClick',
|
||||
'onShow',
|
||||
'onClose',
|
||||
'alert',
|
||||
]);
|
||||
|
||||
const [defaultShowSig, setDefaultShowSig] = createSignal<boolean>(false);
|
||||
const show = props["bind:show"] ?? defaultShowSig;
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ ...modalProps, setShow: setDefaultShowSig, show, localId }} >
|
||||
<div {...divProps}>
|
||||
{props.children}
|
||||
</div>
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
};
|
||||
24
packages/www/src/ui/modal/modal-trigger.tsx
Normal file
24
packages/www/src/ui/modal/modal-trigger.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useModal } from './modal-context';
|
||||
import { ComponentProps } from 'solid-js';
|
||||
|
||||
export const HModalTrigger = (props: ComponentProps<"button">) => {
|
||||
const modal = useModal();
|
||||
|
||||
const handleClick = () => {
|
||||
modal.setShow((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Open Theme Customization Panel"
|
||||
aria-expanded={modal.show()}
|
||||
data-open={modal.show() ? '' : undefined}
|
||||
data-closed={!modal.show() ? '' : undefined}
|
||||
onClick={[handleClick, props.onClick]}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
131
packages/www/src/ui/modal/use-modal.tsx
Normal file
131
packages/www/src/ui/modal/use-modal.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { FocusTrap, createFocusTrap } from 'focus-trap';
|
||||
|
||||
export type WidthState = {
|
||||
width: number | null;
|
||||
};
|
||||
|
||||
import { enableBodyScroll, disableBodyScroll } from 'body-scroll-lock-upgrade';
|
||||
|
||||
export function useModal() {
|
||||
/**
|
||||
* Listens for animation/transition events in order to
|
||||
* remove Animation-CSS-Classes after animation/transition ended.
|
||||
*/
|
||||
const supportClosingAnimation = (modal: HTMLDialogElement) => {
|
||||
modal.dataset.closing = '';
|
||||
modal.classList.add('modal-closing');
|
||||
|
||||
const { animationDuration, transitionDuration } = getComputedStyle(modal);
|
||||
|
||||
if (animationDuration !== '0s') {
|
||||
modal.addEventListener(
|
||||
'animationend',
|
||||
(e) => {
|
||||
if (e.target === modal) {
|
||||
delete modal.dataset.closing;
|
||||
modal.classList.remove('modal-closing');
|
||||
enableBodyScroll(modal);
|
||||
modal.close();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
} else if (transitionDuration !== '0s') {
|
||||
modal.addEventListener(
|
||||
'transitionend',
|
||||
(e) => {
|
||||
if (e.target === modal) {
|
||||
delete modal.dataset.closing;
|
||||
modal.classList.remove('modal-closing');
|
||||
enableBodyScroll(modal);
|
||||
modal.close();
|
||||
}
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
} else if (animationDuration === '0s' && transitionDuration === '0s') {
|
||||
delete modal.dataset.closing;
|
||||
modal.classList.remove('modal-closing');
|
||||
enableBodyScroll(modal);
|
||||
modal.close();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Traps the focus of the given Modal
|
||||
* @returns FocusTrap
|
||||
*/
|
||||
const trapFocus = (modal: HTMLDialogElement): FocusTrap => {
|
||||
return createFocusTrap(modal, { escapeDeactivates: false });
|
||||
};
|
||||
|
||||
const activateFocusTrap = (focusTrap: FocusTrap | null) => {
|
||||
try {
|
||||
focusTrap?.activate();
|
||||
} catch {
|
||||
// Activating the focus trap throws if no tabbable elements are inside the container.
|
||||
// If this is the case we are fine with not activating the focus trap.
|
||||
// That's why we ignore the thrown error.
|
||||
}
|
||||
};
|
||||
|
||||
const deactivateFocusTrap = (focusTrap: FocusTrap | null) => {
|
||||
focusTrap?.deactivate();
|
||||
focusTrap = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the given Modal.
|
||||
* Applies a CSS-Class to animate the modal-showing.
|
||||
* Calls the given callback that is executed after the Modal has been opened.
|
||||
*/
|
||||
const showModal = async (modal: HTMLDialogElement) => {
|
||||
disableBodyScroll(modal, { reserveScrollBarGap: true });
|
||||
modal.showModal();
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes the given Modal.
|
||||
* Applies a CSS-Class to animate the Modal-closing.
|
||||
* Calls the given callback that is executed after the Modal has been closed.
|
||||
*/
|
||||
const closeModal = async (modal: HTMLDialogElement) => {
|
||||
await supportClosingAnimation(modal);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines if the backdrop of the Modal has been clicked.
|
||||
*/
|
||||
const wasModalBackdropClicked = (modal: HTMLDialogElement | undefined, clickEvent: MouseEvent): boolean => {
|
||||
if (!modal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = modal.getBoundingClientRect();
|
||||
|
||||
const wasBackdropClicked =
|
||||
rect.left > clickEvent.clientX ||
|
||||
rect.right < clickEvent.clientX ||
|
||||
rect.top > clickEvent.clientY ||
|
||||
rect.bottom < clickEvent.clientY;
|
||||
|
||||
/**
|
||||
* If the inside focusable elements are not prevented, such as a button it will also fire a click event.
|
||||
*
|
||||
* Hitting the enter or space keys on a button inside of the dialog for example, will fire a "pointer" event. In reality, it fires our onClick$ handler because we have not prevented the default behavior.
|
||||
*
|
||||
* This is why we check if the pointerId is -1.
|
||||
**/
|
||||
return (clickEvent as PointerEvent).pointerId === -1 ? false : wasBackdropClicked;
|
||||
};
|
||||
|
||||
return {
|
||||
trapFocus,
|
||||
activateFocusTrap,
|
||||
deactivateFocusTrap,
|
||||
showModal,
|
||||
closeModal,
|
||||
wasModalBackdropClicked,
|
||||
supportClosingAnimation,
|
||||
};
|
||||
}
|
||||
@@ -89,7 +89,7 @@ const font = {
|
||||
mono_2xl: "1.375rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "1.875rem",
|
||||
"4xl": "2.25rem",
|
||||
"4xl": "2rem",
|
||||
"5xl": "3rem",
|
||||
"6xl": "3.75rem",
|
||||
"7xl": "4.5rem",
|
||||
@@ -218,13 +218,16 @@ const light = (() => {
|
||||
const brand = "#FF4F01"
|
||||
|
||||
const background = {
|
||||
d100: '#f5f5f5',
|
||||
d200: 'oklch(from #f5f5f5 calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h)'
|
||||
d100: 'rgba(255,255,255,0.8)',
|
||||
d200: '#f4f5f6',
|
||||
};
|
||||
|
||||
const headerGradient = "linear-gradient(rgba(66, 144, 243, 0.2) 0%, rgba(206, 127, 243, 0.1) 52.58%, rgba(248, 236, 215, 0) 100%)"
|
||||
|
||||
const contrastFg = '#ffffff';
|
||||
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(0,0,0,0.16)`;
|
||||
const focusColor = blue.d700
|
||||
const hoverColor = "hsl(0,0%,22%)"
|
||||
|
||||
const text = {
|
||||
primary: {
|
||||
@@ -257,7 +260,9 @@ const light = (() => {
|
||||
focusColor,
|
||||
d1000,
|
||||
brand,
|
||||
text
|
||||
text,
|
||||
headerGradient,
|
||||
hoverColor
|
||||
};
|
||||
})()
|
||||
|
||||
@@ -380,13 +385,15 @@ const dark = (() => {
|
||||
const brand = "#FF4F01"
|
||||
|
||||
const background = {
|
||||
d200: '#171717',
|
||||
d100: "oklch(from #171717 calc(l + (-0.06 * clamp(0, calc((l - 0.714) * 1000), 1) + 0.03)) c h)"
|
||||
d100: "rgba(255,255,255,0.04)",
|
||||
d200: 'rgb(19,21,23)',
|
||||
};
|
||||
|
||||
const contrastFg = '#ffffff';
|
||||
const focusBorder = `0 0 0 1px ${grayAlpha.d600}, 0px 0px 0px 4px rgba(255,255,255,0.24)`;
|
||||
const focusColor = blue.d900
|
||||
const hoverColor = "hsl(0,0%,80%)"
|
||||
const headerGradient = "linear-gradient(rgba(66, 144, 243, 0.2) 0%, rgba(239, 148, 225, 0.1) 50%, rgba(191, 124, 7, 0) 100%)"
|
||||
|
||||
const text = {
|
||||
primary: {
|
||||
@@ -419,7 +426,9 @@ const dark = (() => {
|
||||
focusColor,
|
||||
d1000,
|
||||
text,
|
||||
brand
|
||||
brand,
|
||||
headerGradient,
|
||||
hoverColor
|
||||
};
|
||||
})()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user