First Commit. Duplicate bunext-sqlite preset
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import { useContext, type ReactNode } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description?: string | ReactNode;
|
||||
buttons?: ReactNode;
|
||||
};
|
||||
|
||||
export default function AdminHero({ title, description, buttons }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<Section className="pb-0! pt-10! xl:pt-14!">
|
||||
<Stack className="w-full items-stretch gap-0!">
|
||||
<Breadcrumbs
|
||||
currentLinkProps={{
|
||||
className: "text-slate-400! pointer-events-none",
|
||||
}}
|
||||
pageUrl={pageProps?.url?.pathname}
|
||||
backButton
|
||||
skipHome
|
||||
/>
|
||||
<Row className="w-full justify-between items-end gap-6! mt-6 pb-8 border-b border-slate-200 dark:border-white/10">
|
||||
<Stack className="gap-3! max-w-2xl">
|
||||
<H1>{title}</H1>
|
||||
{description ? (
|
||||
typeof description == "string" ? (
|
||||
<Span className="text-slate-500 dark:text-slate-400 text-[15px] leading-relaxed">
|
||||
{description}
|
||||
</Span>
|
||||
) : (
|
||||
description
|
||||
)
|
||||
) : null}
|
||||
</Stack>
|
||||
{buttons ? <Row className="gap-2!">{buttons}</Row> : null}
|
||||
</Row>
|
||||
</Stack>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
export default function AuthDivider() {
|
||||
return (
|
||||
<Row className="w-full flex-nowrap gap-4 -my-2">
|
||||
<Divider />
|
||||
<Span className="text-sm font-semibold opacity-50">OR</Span>
|
||||
<Divider />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
|
||||
type Props = {
|
||||
prompt: string;
|
||||
title: string;
|
||||
url: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function AuthFooterLink({
|
||||
prompt,
|
||||
title,
|
||||
url,
|
||||
className,
|
||||
}: Props) {
|
||||
return (
|
||||
<Row className={twMerge("justify-center gap-1.5", className)}>
|
||||
<Span variant="faded">{prompt}</Span>
|
||||
<Link href={url}>{title}</Link>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
type Props = {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
};
|
||||
|
||||
export default function AuthHero({ eyebrow, title, description }: Props) {
|
||||
return (
|
||||
<Stack className="w-full items-center gap-4!">
|
||||
<Span className="htag">{eyebrow}</Span>
|
||||
<H1 className="text-center text-3xl! sm:text-4xl! tracking-[-0.035em]">
|
||||
{title}
|
||||
</H1>
|
||||
{description ? (
|
||||
<Span className="text-base text-slate-500 dark:text-slate-400 leading-snug text-center max-w-md">
|
||||
{description}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
opacity?: number;
|
||||
};
|
||||
|
||||
export default function BlurredImageBG({ src, opacity = 0.1 }: Props) {
|
||||
/**
|
||||
* Render component
|
||||
*/
|
||||
return (
|
||||
<Img
|
||||
alt="bg image"
|
||||
src={src}
|
||||
className={twMerge(
|
||||
`absolute top-0 left-0 w-[150vw] h-full blur-xs object-cover`,
|
||||
"z-0",
|
||||
)}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
||||
import type { TableType } from "@/src/types";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Card from "@moduletrace/twui/elements/Card";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import type { BUN_SQLITE_BSQLITE_ALL_TYPEDEFS } from "@/db/types/db";
|
||||
import useStatus from "@moduletrace/twui/hooks/useStatus";
|
||||
import H4 from "@moduletrace/twui/layout/H4";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import dayjs from "dayjs";
|
||||
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
|
||||
|
||||
export type CopyableRecord = {
|
||||
id?: number | "";
|
||||
title?: string;
|
||||
subject?: string;
|
||||
};
|
||||
|
||||
type Props<T extends {} = BUN_SQLITE_BSQLITE_ALL_TYPEDEFS> = {
|
||||
table: TableType;
|
||||
title: string;
|
||||
onSelect?: (record: T) => void;
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
export default function CopyFromExistingSelect<
|
||||
T extends BUN_SQLITE_BSQLITE_ALL_TYPEDEFS =
|
||||
BUN_SQLITE_BSQLITE_ALL_TYPEDEFS,
|
||||
>({ table, title, onSelect, onClear }: Props<T>) {
|
||||
const { res: records, ready } = useAdminCrudGet<T>({
|
||||
table,
|
||||
});
|
||||
|
||||
const { open, setOpen } = useStatus();
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Button
|
||||
title="Clear Form"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onClear?.();
|
||||
}}
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<Modal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
target={
|
||||
<Button
|
||||
title="Duplicate Existing Record"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
Create from an Existing Record
|
||||
</Button>
|
||||
}
|
||||
className="xl:p-6"
|
||||
>
|
||||
<Stack className="gap-6 w-full">
|
||||
<Stack className="gap-1">
|
||||
<H3 className="text-2xl! font-semibold">
|
||||
{title || "Select Record"}
|
||||
</H3>
|
||||
<Span>Select a record to duplicate</Span>
|
||||
</Stack>
|
||||
|
||||
<Stack className="w-full items-stretch">
|
||||
{records?.[0] ? (
|
||||
records?.map((rec, index) => {
|
||||
const date = dayjs(
|
||||
twuiNumberfy(rec.created_at),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
onClick={() => {
|
||||
onSelect?.(rec);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<Row className="w-full justify-between">
|
||||
<Row>
|
||||
<LucideIcon
|
||||
name="Database"
|
||||
size={17}
|
||||
opacity={0.5}
|
||||
/>
|
||||
<H4 className="text-base! font-semibold">
|
||||
{rec.title}
|
||||
</H4>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<Span
|
||||
size="smaller"
|
||||
variant="faded"
|
||||
className="text-nowrap"
|
||||
>
|
||||
{date
|
||||
.toString()
|
||||
.substring(0, 16)}
|
||||
</Span>
|
||||
</Row>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<EmptyContent title="No Records Found!" />
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import type { FeatureItem } from "@/src/types";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = FeatureItem & {
|
||||
index: number;
|
||||
};
|
||||
|
||||
export default function FeatureCard({
|
||||
Icon,
|
||||
title,
|
||||
description,
|
||||
index,
|
||||
}: Props) {
|
||||
return (
|
||||
<Stack className="h-full w-full gap-14! justify-between bg-light dark:bg-background-dark p-8 xl:p-10 transition-colors hover:bg-white dark:hover:bg-white/5">
|
||||
<Row className="w-full justify-between">
|
||||
<Span className="text-xs text-slate-400">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</Span>
|
||||
<Icon size={20} strokeWidth={1.5} className="text-slate-700 dark:text-slate-300" />
|
||||
</Row>
|
||||
<Stack className="gap-3!">
|
||||
<H3>{title}</H3>
|
||||
<Span className="text-slate-500 dark:text-slate-400 leading-relaxed">
|
||||
{description}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { FeatureItem } from "@/src/types";
|
||||
import FeatureCard from "./feature-card";
|
||||
|
||||
type Props = {
|
||||
items: FeatureItem[];
|
||||
};
|
||||
|
||||
export default function FeatureGrid({ items }: Props) {
|
||||
return (
|
||||
<div className="grid w-full grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-px bg-slate-200 dark:bg-white/10 border border-slate-200 dark:border-white/10 rounded-default-lg overflow-hidden">
|
||||
{items.map((item, index) => (
|
||||
<FeatureCard key={item.title} {...item} index={index} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type useFormInit from "@/src/hooks/use-form-init";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import useGoogleAuth, {
|
||||
type GoogleAuthMode,
|
||||
} from "@/src/hooks/use-google-auth";
|
||||
|
||||
type Props = Pick<
|
||||
ReturnType<typeof useFormInit>,
|
||||
"loading" | "setLoading" | "setStatus"
|
||||
> & {
|
||||
mode: GoogleAuthMode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Themed button with Google's own (transparent) button laid over it. Once
|
||||
* Google's button is ready it takes keyboard focus and clicks, and the
|
||||
* themed button below is hidden from assistive tech.
|
||||
*/
|
||||
export default function GoogleAuthButton({
|
||||
mode,
|
||||
loading,
|
||||
setLoading,
|
||||
setStatus,
|
||||
}: Props) {
|
||||
const { handleButtonClick, googleButtonRef, googleReady } = useGoogleAuth({
|
||||
mode,
|
||||
loading,
|
||||
setLoading,
|
||||
setStatus,
|
||||
});
|
||||
|
||||
const overlayActive = googleReady && !loading;
|
||||
const label =
|
||||
mode == "signup" ? "Sign up with Google" : "Log in with Google";
|
||||
|
||||
return (
|
||||
<Row className="w-full relative justify-center rounded-default focus-within:ring-2 focus-within:ring-primary/70">
|
||||
<Button
|
||||
title={label}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
className="w-full py-3 relative"
|
||||
disabled={loading}
|
||||
onClick={handleButtonClick}
|
||||
tabIndex={overlayActive ? -1 : undefined}
|
||||
aria-hidden={overlayActive || undefined}
|
||||
>
|
||||
<Img alt="" src={`/icons/google.png`} size={17} />
|
||||
<Span>{label}</Span>
|
||||
</Button>
|
||||
<div
|
||||
ref={googleButtonRef}
|
||||
className={twMerge(
|
||||
"absolute inset-0 z-10 opacity-0",
|
||||
overlayActive
|
||||
? "pointer-events-auto"
|
||||
: "pointer-events-none",
|
||||
)}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useContext, type ComponentProps } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import GoogleAuthButton from "./google-auth-button";
|
||||
import AuthDivider from "./auth-divider";
|
||||
|
||||
/**
|
||||
* Google button plus the "OR" divider. Renders nothing when Google
|
||||
* sign-in isn't configured.
|
||||
*/
|
||||
export default function GoogleAuthSection(
|
||||
props: ComponentProps<typeof GoogleAuthButton>,
|
||||
) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
if (!pageProps?.envs?.GOOGLE_CLIENT_ID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GoogleAuthButton {...props} />
|
||||
<AuthDivider />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import type { ComponentProps } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
|
||||
};
|
||||
|
||||
export default function ImageBG({ src, img_props }: Props) {
|
||||
return (
|
||||
<Img
|
||||
{...img_props}
|
||||
alt="bg image"
|
||||
src={src}
|
||||
className={twMerge(
|
||||
`absolute top-0 left-0 w-full h-full object-cover`,
|
||||
"z-0",
|
||||
img_props?.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import Container from "@moduletrace/twui/layout/Container";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
sub_title?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export default function InnerPageHeroSection({
|
||||
title,
|
||||
description,
|
||||
sub_title,
|
||||
}: Props) {
|
||||
return (
|
||||
<Section className="border-b border-slate-200/60 dark:border-white/10">
|
||||
<Container>
|
||||
<Stack className="w-full items-center max-w-3xl mx-auto gap-6!">
|
||||
{sub_title ? (
|
||||
<Span className="htag">{sub_title}</Span>
|
||||
) : null}
|
||||
<H1 className="text-center">{title}</H1>
|
||||
{description ? (
|
||||
<Span className="text-lg sm:text-xl leading-snug text-center text-slate-500 dark:text-slate-400">
|
||||
{description}
|
||||
</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
|
||||
type Props = {
|
||||
onCodeChange: (code: string) => void;
|
||||
};
|
||||
|
||||
export default function LoginCodeInput({ onCodeChange }: Props) {
|
||||
return (
|
||||
<Input
|
||||
id="code"
|
||||
name="code"
|
||||
type="text"
|
||||
label="Code"
|
||||
placeholder="123456"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
onCodeChange(e.target.value);
|
||||
}}
|
||||
showLabel
|
||||
required
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Tag from "@moduletrace/twui/elements/Tag";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import { TriangleAlert, CircleCheck } from "lucide-react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { TWUIStatusType } from "@moduletrace/twui/hooks/useStatus";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
|
||||
type Props = PropsWithChildren & {
|
||||
title?: string;
|
||||
className?: string;
|
||||
status?: TWUIStatusType;
|
||||
};
|
||||
|
||||
export default function LoginShell({ children, className, status }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Paper
|
||||
className={twMerge(
|
||||
"w-full flex-col items-center",
|
||||
"px-4 sm:px-6 py-8 shadow-md relative",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{status?.error && status.msg ? (
|
||||
<Tag
|
||||
variant="outlined"
|
||||
color="error"
|
||||
className="w-full py-1.5 outline-error/50 bg-error/5!"
|
||||
>
|
||||
<Row>
|
||||
<TriangleAlert size={15} />
|
||||
<span>{status.msg}</span>
|
||||
</Row>
|
||||
</Tag>
|
||||
) : null}
|
||||
{status?.success && status.msg ? (
|
||||
<Tag
|
||||
variant="outlined"
|
||||
color="success"
|
||||
className="w-full py-1.5 outline-success/50 bg-success/5!"
|
||||
>
|
||||
<Row>
|
||||
<CircleCheck size={15} />
|
||||
<span>{status.msg}</span>
|
||||
</Row>
|
||||
</Tag>
|
||||
) : null}
|
||||
{children}
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
icon_size?: number;
|
||||
img_props?: Pick<ComponentProps<"svg">, "className">;
|
||||
};
|
||||
|
||||
export default function LogoIcon({ icon_size = 40, img_props }: Props) {
|
||||
return (
|
||||
<svg
|
||||
width={icon_size}
|
||||
height={icon_size}
|
||||
viewBox="0 0 512 512"
|
||||
role="img"
|
||||
aria-label="Logo Icon"
|
||||
className={twMerge("shrink-0", img_props?.className)}
|
||||
>
|
||||
<rect
|
||||
x="16"
|
||||
y="16"
|
||||
width="480"
|
||||
height="480"
|
||||
rx="120"
|
||||
className="fill-slate-900 dark:fill-slate-800"
|
||||
/>
|
||||
<path
|
||||
d="M290 100 L144 286 H244 L222 412 L368 226 H268 Z"
|
||||
strokeWidth="28"
|
||||
strokeLinejoin="round"
|
||||
className="fill-white stroke-white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ComponentProps, PropsWithChildren } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import LogoIcon from "./logo-icon";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
type Props = {
|
||||
icon_size?: number;
|
||||
text_wrapper_props?: ComponentProps<typeof Stack>;
|
||||
text_props?: ComponentProps<typeof Span>;
|
||||
};
|
||||
|
||||
export default function Logo({
|
||||
icon_size = 36,
|
||||
text_props,
|
||||
text_wrapper_props,
|
||||
}: Props) {
|
||||
return (
|
||||
<a href="/" className="logo-link">
|
||||
<Row className="gap-3! flex-nowrap items-center">
|
||||
<LogoIcon icon_size={icon_size} />
|
||||
<Stack
|
||||
{...text_wrapper_props}
|
||||
className={twMerge("gap-1!", text_wrapper_props?.className)}
|
||||
>
|
||||
<Stack className="gap-0!">
|
||||
<HeroText text_props={text_props}>
|
||||
{SiteData["SiteName"]}
|
||||
</HeroText>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Row>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroText({
|
||||
text_props,
|
||||
children,
|
||||
}: PropsWithChildren & {
|
||||
text_props?: ComponentProps<typeof Span>;
|
||||
}) {
|
||||
return (
|
||||
<Span
|
||||
{...text_props}
|
||||
className={twMerge(
|
||||
"text-[17px] font-semibold tracking-tight text-dark dark:text-white heading leading-[24px] whitespace-nowrap",
|
||||
text_props?.className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
import React, { type ComponentProps, type ReactNode } from "react";
|
||||
import Container from "@moduletrace/twui/layout/Container";
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type Img from "@moduletrace/twui/layout/Img";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
image_url?: string;
|
||||
sub_title?: string;
|
||||
cta_buttons?: ReactNode;
|
||||
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
|
||||
img_component?: ReactNode;
|
||||
};
|
||||
|
||||
export default function MainHeroSection({
|
||||
title,
|
||||
cta_buttons,
|
||||
sub_title,
|
||||
}: Props) {
|
||||
return (
|
||||
<Section className="mt-hero-section">
|
||||
<Container className="mt-container flex-col! gap-0">
|
||||
<Span className="htag">{SiteData["SiteName"]}</Span>
|
||||
<H1 className="mt-hero-title">{title}</H1>
|
||||
{sub_title ? (
|
||||
<P className="mt-hero-desc">{sub_title}</P>
|
||||
) : null}
|
||||
{cta_buttons ? (
|
||||
<div className="mt-hero-ctas">{cta_buttons}</div>
|
||||
) : null}
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import ColorSchemeSelector from "@moduletrace/twui/elements/ColorSchemeSelector";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme switch row pinned to the bottom of the mobile navs.
|
||||
*/
|
||||
export default function MobileNavThemeRow({ className }: Props) {
|
||||
return (
|
||||
<Row
|
||||
className={twMerge(
|
||||
"w-full justify-between items-center flex-nowrap",
|
||||
"border-t border-slate-200/70 dark:border-white/10",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Theme
|
||||
</Span>
|
||||
<ColorSchemeSelector
|
||||
className="text-slate-500 hover:text-foreground-light dark:text-slate-400 dark:hover:text-foreground-dark transition-colors"
|
||||
size="small"
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import type { AutocompleteOptions } from "@moduletrace/twui/types";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
label: string;
|
||||
autoComplete: (typeof AutocompleteOptions)[number];
|
||||
onValueChange: (value: string) => void;
|
||||
required?: boolean;
|
||||
info?: ReactNode;
|
||||
};
|
||||
|
||||
export default function PasswordInput({
|
||||
id,
|
||||
label,
|
||||
autoComplete,
|
||||
onValueChange,
|
||||
required,
|
||||
info,
|
||||
}: Props) {
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
type="password"
|
||||
label={label}
|
||||
autoComplete={autoComplete}
|
||||
info={info}
|
||||
onChange={(e) => {
|
||||
onValueChange(e.target.value);
|
||||
}}
|
||||
showLabel
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Center from "@moduletrace/twui/layout/Center";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
url: string;
|
||||
body: any;
|
||||
download_name?: string;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export default function PdfPreview({
|
||||
url,
|
||||
body,
|
||||
download_name,
|
||||
height = 1200,
|
||||
}: Props) {
|
||||
const [blob_url, setBlobUrl] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let object_url: string | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
setBlobUrl(undefined);
|
||||
setError(undefined);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
|
||||
const content_type = res.headers.get("Content-Type") || "";
|
||||
|
||||
if (content_type.includes("application/json")) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.msg || "Couldn't generate PDF");
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Request failed with status ${res.status}`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
object_url = URL.createObjectURL(blob);
|
||||
setBlobUrl(object_url);
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
setError(error.message || "Couldn't generate PDF preview");
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
if (object_url) {
|
||||
URL.revokeObjectURL(object_url);
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center className="py-10">
|
||||
<Span className="text-warning">{error}</Span>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!blob_url) {
|
||||
return (
|
||||
<Center className="py-10">
|
||||
<Loading />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full flex flex-col gap-2 items-end">
|
||||
{download_name ? (
|
||||
<a
|
||||
href={blob_url}
|
||||
download={download_name}
|
||||
className="text-sm text-link-500 hover:opacity-70 underline"
|
||||
>
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
<iframe
|
||||
src={blob_url}
|
||||
title="PDF Preview"
|
||||
className="w-full rounded-default border border-slate-300 dark:border-white/20 bg-white"
|
||||
style={{ height }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
src: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export default function RectImage({
|
||||
src,
|
||||
height = 300,
|
||||
width = 200,
|
||||
alt,
|
||||
}: Props) {
|
||||
const img_component = (
|
||||
<Img
|
||||
alt={alt || "image"}
|
||||
src={src}
|
||||
className={twMerge(`w-full h-full object-cover`)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
className={twMerge(`overflow-hidden hidden xl:flex`)}
|
||||
style={{
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
}}
|
||||
>
|
||||
{img_component}
|
||||
</Stack>
|
||||
<Stack className={twMerge(`overflow-hidden flex xl:hidden w-full`)}>
|
||||
{img_component}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type useFormInit from "@/src/hooks/use-form-init";
|
||||
import resendLoginCode from "@/src/functions/frontend/resend-login-code";
|
||||
|
||||
type Props = Pick<
|
||||
ReturnType<typeof useFormInit>,
|
||||
"loading" | "setLoading" | "setStatus"
|
||||
>;
|
||||
|
||||
export default function ResendCodeButton({
|
||||
loading,
|
||||
setLoading,
|
||||
setStatus,
|
||||
}: Props) {
|
||||
return (
|
||||
<Row className="w-full justify-center gap-1.5 text-sm">
|
||||
<Span variant="faded">Didn't get a code?</Span>
|
||||
<Button
|
||||
title="Resend code"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
resendLoginCode({ setLoading, setStatus });
|
||||
}}
|
||||
>
|
||||
Resend code
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Border from "@moduletrace/twui/elements/Border";
|
||||
import { formatNaira } from "@/src/utils/money";
|
||||
import twuiNumberfy from "@moduletrace/twui/utils/numberfy";
|
||||
|
||||
export type SortableLineItemFormObject = {
|
||||
description?: string;
|
||||
quantity?: string;
|
||||
/** Naira amount as a plain number string (converted to kobo on submit). */
|
||||
unit_price?: string;
|
||||
/** Client-side row identity for stable React keys during reorder. */
|
||||
_key?: string;
|
||||
};
|
||||
|
||||
type Props<T extends SortableLineItemFormObject> = {
|
||||
item: T;
|
||||
index: number;
|
||||
placeholder: string;
|
||||
is_dragging: boolean;
|
||||
is_drop_target: boolean;
|
||||
/** Whether a drag is currently in progress anywhere in the list. */
|
||||
drag_active: boolean;
|
||||
can_move_up: boolean;
|
||||
can_move_down: boolean;
|
||||
onChange: (item: T) => void;
|
||||
onRemove: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onDragStart: () => void;
|
||||
onDragOver: () => void;
|
||||
onDragLeave: () => void;
|
||||
/** `drop_after` is true when dropped on the lower half of this row. */
|
||||
onDrop: (drop_after: boolean) => void;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
export default function SortableLineItemRow<
|
||||
T extends SortableLineItemFormObject,
|
||||
>({
|
||||
item,
|
||||
index,
|
||||
placeholder,
|
||||
is_dragging,
|
||||
is_drop_target,
|
||||
drag_active,
|
||||
can_move_up,
|
||||
can_move_down,
|
||||
onChange,
|
||||
onRemove,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
}: Props<T>) {
|
||||
const line_total_kobo =
|
||||
(Number(item.quantity) || 0) * (Number(item.unit_price) || 0) * 100;
|
||||
|
||||
return (
|
||||
<Paper
|
||||
className={[
|
||||
"w-full gap-3!",
|
||||
is_dragging && "opacity-60",
|
||||
is_drop_target && "ring-2 ring-primary/70",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onDragOver={(e) => {
|
||||
if (!drag_active) return;
|
||||
e.preventDefault();
|
||||
onDragOver();
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (drag_active) onDragLeave();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!drag_active) return;
|
||||
e.preventDefault();
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
onDrop(e.clientY - rect.top > rect.height / 2);
|
||||
}}
|
||||
>
|
||||
<Row className="w-full justify-between items-center gap-2!">
|
||||
<Row className="gap-2! items-center">
|
||||
<Span
|
||||
title="Drag to reorder"
|
||||
className="cursor-grab select-none p-1 -m-1!"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<LucideIcon name="GripVertical" size={16} />
|
||||
</Span>
|
||||
<Span className="text-xs font-semibold opacity-60">
|
||||
Item #{index + 1}
|
||||
</Span>
|
||||
</Row>
|
||||
<Row className="gap-1! items-center">
|
||||
<Button
|
||||
title="Move Up"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
disabled={!can_move_up}
|
||||
onClick={onMoveUp}
|
||||
>
|
||||
<LucideIcon name="ArrowUp" size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
title="Move Down"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
disabled={!can_move_down}
|
||||
onClick={onMoveDown}
|
||||
>
|
||||
<LucideIcon name="ArrowDown" size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
title="Remove Line Item"
|
||||
variant="ghost"
|
||||
color="error"
|
||||
size="smaller"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<LucideIcon name="Trash2" size={14} />
|
||||
</Button>
|
||||
</Row>
|
||||
</Row>
|
||||
|
||||
<Input
|
||||
title="Description"
|
||||
placeholder={placeholder}
|
||||
defaultValue={item.description}
|
||||
onChange={(e) => {
|
||||
onChange({ ...item, description: e.target.value });
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Row className="w-full grid xl:grid-cols-3 items-stretch">
|
||||
<Input
|
||||
title="Quantity"
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={item.quantity}
|
||||
onChange={(e) => {
|
||||
onChange({ ...item, quantity: e.target.value });
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Input
|
||||
title="Unit Price (₦)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min={0}
|
||||
defaultValue={item.unit_price}
|
||||
onChange={(e) => {
|
||||
onChange({
|
||||
...item,
|
||||
unit_price: Math.round(
|
||||
twuiNumberfy(e.target.value),
|
||||
).toString(),
|
||||
});
|
||||
}}
|
||||
showLabel
|
||||
/>
|
||||
|
||||
<Border className="py-1 px-4">
|
||||
<Span className="text-sm font-semibold">
|
||||
{formatNaira(line_total_kobo)}
|
||||
</Span>
|
||||
</Border>
|
||||
</Row>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H3 from "@moduletrace/twui/layout/H3";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { useEffect, useState } from "react";
|
||||
import { formatNaira, sumInvoiceItemsKobo } from "@/src/utils/money";
|
||||
import SortableLineItemRow, {
|
||||
type SortableLineItemFormObject,
|
||||
} from "./sortable-line-item-row";
|
||||
|
||||
let row_key_counter = 0;
|
||||
|
||||
export const makeRowKey = (): string =>
|
||||
typeof crypto.randomUUID === "function"
|
||||
? crypto.randomUUID()
|
||||
: `row-${Date.now()}-${row_key_counter++}`;
|
||||
|
||||
type Props<T extends SortableLineItemFormObject> = {
|
||||
items: T[];
|
||||
empty_item: T;
|
||||
item_placeholder: string;
|
||||
onChange: (items: T[]) => void;
|
||||
};
|
||||
|
||||
export default function SortableLineItemsEditor<
|
||||
T extends SortableLineItemFormObject,
|
||||
>({ items, empty_item, item_placeholder, onChange }: Props<T>) {
|
||||
const [drag_source, set_drag_source] = useState<number | undefined>();
|
||||
const [drag_target, set_drag_target] = useState<number | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (items.some((item) => !item._key)) {
|
||||
onChange(
|
||||
items.map((item) => ({ ...item, _key: makeRowKey() })),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const display_items = items.length ? items : [empty_item];
|
||||
const can_remove = items.length > 1;
|
||||
|
||||
const setItems = (next_items: T[]) => onChange(next_items);
|
||||
|
||||
const endDrag = () => {
|
||||
set_drag_source(undefined);
|
||||
set_drag_target(undefined);
|
||||
};
|
||||
|
||||
const moveTo = (from: number | undefined, target: number, drop_after: boolean) => {
|
||||
if (from === undefined || from === target) return;
|
||||
|
||||
let to = drop_after ? target + 1 : target;
|
||||
if (from < to) to -= 1;
|
||||
if (to === from) return;
|
||||
|
||||
const next_items = items.length ? [...items] : [{ ...empty_item }];
|
||||
const moved_item: T = next_items[from] as T;
|
||||
next_items.splice(from, 1);
|
||||
next_items.splice(to, 0, moved_item);
|
||||
setItems(next_items);
|
||||
};
|
||||
|
||||
const moveBy = (index: number, delta: -1 | 1) => {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= (items.length ? items.length : 1)) return;
|
||||
moveTo(index, target, delta === 1);
|
||||
};
|
||||
|
||||
const total_kobo = sumInvoiceItemsKobo(
|
||||
items.map((item) => ({
|
||||
quantity: item.quantity,
|
||||
unit_price_kobo: (Number(item.unit_price) || 0) * 100,
|
||||
})),
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-4">
|
||||
<H3 className="!mb-0">Line Items</H3>
|
||||
|
||||
{display_items.map((item, index) => (
|
||||
<SortableLineItemRow
|
||||
key={item._key || index}
|
||||
item={item}
|
||||
index={index}
|
||||
placeholder={item_placeholder}
|
||||
is_dragging={drag_source === index}
|
||||
is_drop_target={
|
||||
drag_source !== undefined &&
|
||||
drag_source !== index &&
|
||||
drag_target === index
|
||||
}
|
||||
drag_active={drag_source !== undefined}
|
||||
can_move_up={index > 0}
|
||||
can_move_down={index < items.length - 1}
|
||||
onChange={(next_item) => {
|
||||
const next_items = items.length
|
||||
? [...items]
|
||||
: [{ ...empty_item }];
|
||||
next_items[index] = next_item;
|
||||
setItems(next_items);
|
||||
}}
|
||||
onRemove={() => {
|
||||
if (!can_remove) return;
|
||||
setItems(items.filter((_, i) => i !== index));
|
||||
}}
|
||||
onMoveUp={() => moveBy(index, -1)}
|
||||
onMoveDown={() => moveBy(index, 1)}
|
||||
onDragStart={() => {
|
||||
if (items.length < 2 || drag_source !== undefined) return;
|
||||
set_drag_source(index);
|
||||
}}
|
||||
onDragOver={() => {
|
||||
if (
|
||||
drag_source === undefined ||
|
||||
drag_source === index ||
|
||||
drag_target === index
|
||||
) return;
|
||||
set_drag_target(index);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (drag_target === index) {
|
||||
set_drag_target(undefined);
|
||||
}
|
||||
}}
|
||||
onDrop={(drop_after) => {
|
||||
moveTo(drag_source, index, drop_after);
|
||||
endDrag();
|
||||
}}
|
||||
onDragEnd={endDrag}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Row className="w-full justify-between items-center">
|
||||
<Button
|
||||
title="Add Line Item"
|
||||
color="gray"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setItems([
|
||||
...items,
|
||||
{ ...empty_item, _key: makeRowKey() },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
Add Line Item
|
||||
</Button>
|
||||
|
||||
<Span className="text-base font-semibold">
|
||||
Total: {formatNaira(total_kobo)}
|
||||
</Span>
|
||||
</Row>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
import Section from "@moduletrace/twui/layout/Section";
|
||||
import Container from "@moduletrace/twui/layout/Container";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { TWUILucideIconName } from "@moduletrace/twui/elements/lucide-icon";
|
||||
import LucideIcon from "@moduletrace/twui/elements/lucide-icon";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
tagline: string;
|
||||
icon_name?: TWUILucideIconName;
|
||||
sub_title?: string;
|
||||
post_component?: ReactNode;
|
||||
};
|
||||
|
||||
export default function TaglineSection({
|
||||
tagline,
|
||||
icon_name,
|
||||
sub_title,
|
||||
post_component,
|
||||
}: Props) {
|
||||
/**
|
||||
* Render component
|
||||
*/
|
||||
return (
|
||||
<Section className="bg-secondary relative">
|
||||
<Container className="xl:py-10">
|
||||
<Stack className="w-full items-center gap-6">
|
||||
{icon_name && (
|
||||
<LucideIcon
|
||||
name={icon_name}
|
||||
size={50}
|
||||
className="text-accent"
|
||||
/>
|
||||
)}
|
||||
<H2 className="text-center max-w-4xl xl:text-6xl! text-white">
|
||||
{tagline}
|
||||
</H2>
|
||||
{sub_title ? (
|
||||
<Span
|
||||
className="text-white/80 max-w-4xl text-center"
|
||||
size="larger"
|
||||
>
|
||||
{sub_title}
|
||||
</Span>
|
||||
) : null}
|
||||
{post_component}
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Applies the saved color scheme to `<html>` before first paint, so pages
|
||||
* never flash light before twui's `ColorSchemeSelector` mounts. Mirrors the
|
||||
* selector's resolution order: `localStorage.theme`, then the OS preference.
|
||||
*/
|
||||
const ThemeInitScriptSource = `(function () {
|
||||
try {
|
||||
var theme = localStorage.getItem("theme");
|
||||
if (theme !== "dark" && theme !== "light") {
|
||||
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
var root = document.documentElement;
|
||||
root.classList.remove("dark", "light");
|
||||
root.classList.add(theme);
|
||||
} catch (e) {}
|
||||
})();`;
|
||||
|
||||
export default function ThemeInitScript() {
|
||||
return (
|
||||
<script dangerouslySetInnerHTML={{ __html: ThemeInitScriptSource }} />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user