Add theme customizer

This commit is contained in:
mbaharip
2024-05-09 15:31:39 +07:00
parent eda30f23c4
commit 0eeaf04b2c
14 changed files with 1492 additions and 47 deletions
+28 -6
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { z } from "zod";
@@ -38,8 +39,9 @@ import { CreateDownloadToken } from "./actions";
type Props = {
data: z.infer<typeof Schema_File>;
disabled?: boolean;
};
export default function FileGrid({ data }: Props) {
export default function FileGrid({ data, disabled }: Props) {
const pathname = usePathname();
const filePath = useMemo<string>(() => {
// const currentPath = pathname.startsWith("/e") ? pathname : `/e${pathname}`;
@@ -120,7 +122,10 @@ export default function FileGrid({ data }: Props) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={onCopy}>
<DropdownMenuItem
onClick={onCopy}
disabled={disabled}
>
<Icon
name='Link'
className='mr-3'
@@ -129,7 +134,10 @@ export default function FileGrid({ data }: Props) {
Copy link
</DropdownMenuItem>
{data.mimeType.includes("folder") ? null : (
<DropdownMenuItem onClick={onDownload}>
<DropdownMenuItem
onClick={onDownload}
disabled={disabled}
>
<Icon
name='Download'
className='mr-3'
@@ -167,7 +175,10 @@ export default function FileGrid({ data }: Props) {
</DrawerHeader>
<div className='grid gap-1.5 px-4'>
<DrawerClose asChild>
<Button onClick={onCopy}>
<Button
onClick={onCopy}
disabled={disabled}
>
<Icon
name='Link'
className='mr-3'
@@ -178,7 +189,10 @@ export default function FileGrid({ data }: Props) {
</DrawerClose>
{data.mimeType.includes("folder") ? null : (
<DrawerClose asChild>
<Button onClick={onDownload}>
<Button
onClick={onDownload}
disabled={disabled}
>
<Icon
name='Download'
className='mr-3'
@@ -199,6 +213,14 @@ export default function FileGrid({ data }: Props) {
</Drawer>
)}
<Link
onClick={async (e) => {
if (disabled) {
e.preventDefault();
e.stopPropagation();
await new Promise((resolve) => setTimeout(resolve, 500));
nProgress.done(true);
}
}}
href={filePath}
className={cn(
"h-full w-full",
@@ -291,7 +313,7 @@ export default function FileGrid({ data }: Props) {
>
{data.mimeType.includes("folder")
? "folder"
: data.fileExtension}
: data.fileExtension || "unknown"}
</span>
{!data.mimeType.includes("folder") && (
<>
+28 -6
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import nProgress from "nprogress";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { z } from "zod";
@@ -38,8 +39,9 @@ import { CreateDownloadToken } from "./actions";
type Props = {
data: z.infer<typeof Schema_File>;
disabled?: boolean;
};
export default function FileList({ data }: Props) {
export default function FileList({ data, disabled }: Props) {
const pathname = usePathname();
const filePath = useMemo<string>(() => {
@@ -121,7 +123,10 @@ export default function FileList({ data }: Props) {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end'>
<DropdownMenuItem onClick={onCopy}>
<DropdownMenuItem
onClick={onCopy}
disabled={disabled}
>
<Icon
name='Link'
className='mr-3'
@@ -130,7 +135,10 @@ export default function FileList({ data }: Props) {
Copy link
</DropdownMenuItem>
{data.mimeType.includes("folder") ? null : (
<DropdownMenuItem onClick={onDownload}>
<DropdownMenuItem
onClick={onDownload}
disabled={disabled}
>
<Icon
name='Download'
className='mr-3'
@@ -169,7 +177,10 @@ export default function FileList({ data }: Props) {
</DrawerHeader>
<div className='grid gap-1.5 px-4'>
<DrawerClose asChild>
<Button onClick={onCopy}>
<Button
onClick={onCopy}
disabled={disabled}
>
<Icon
name='Link'
className='mr-3'
@@ -180,7 +191,10 @@ export default function FileList({ data }: Props) {
</DrawerClose>
{data.mimeType.includes("folder") ? null : (
<DrawerClose asChild>
<Button onClick={onDownload}>
<Button
onClick={onDownload}
disabled={disabled}
>
<Icon
name='Download'
className='mr-3'
@@ -202,6 +216,14 @@ export default function FileList({ data }: Props) {
)}
</div>
<Link
onClick={async (e) => {
if (disabled) {
e.preventDefault();
e.stopPropagation();
await new Promise((resolve) => setTimeout(resolve, 500));
nProgress.done(true);
}
}}
href={filePath}
className={cn(
"relative",
@@ -279,7 +301,7 @@ export default function FileList({ data }: Props) {
>
{data.mimeType.includes("folder")
? "folder"
: data.fileExtension}
: data.fileExtension || "unknown"}
</span>
{!data.mimeType.includes("folder") && (
<>
+1 -1
View File
@@ -42,7 +42,7 @@ export default function Footer({ content }: Props) {
{content}
</ReactMarkdown>
{isDev && (
<div className='fixed bottom-0 z-[999] rounded-t-[var(--radius)] border border-border bg-primary px-3 py-1 text-xs text-primary-foreground'>
<div className='fixed bottom-0 z-[999] rounded-t-[var(--radius)] border border-b-0 border-border bg-primary px-3 py-1 text-xs text-primary-foreground'>
Dev Mode
</div>
)}
@@ -23,7 +23,7 @@ import { Separator } from "~/components/ui/separator";
import { decryptData } from "~/utils/encryptionHelper/hash";
import { parseConfigFile } from "~/utils/parseConfigFile";
import ConfigInput from "./@form.input";
import ConfigInput from "./@form.input-config";
type Props = {
state: {
@@ -17,7 +17,7 @@ import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Separator } from "~/components/ui/separator";
import ConfigInput from "./@form.input";
import ConfigInput from "./@form.input-config";
type Props = {
state: {
@@ -0,0 +1,181 @@
"use client";
import { PopoverTrigger } from "@radix-ui/react-popover";
import React, { PropsWithChildren } from "react";
import { HslColor, HslColorPicker } from "react-colorful";
import { z } from "zod";
import { Schema_Theme } from "~/schema";
import Icon from "~/components/Icon";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Popover, PopoverContent } from "~/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
type Props = {
key: keyof z.infer<typeof Schema_Theme>;
title: string;
description?: string;
value: HslColor;
onChange: (color: HslColor) => void;
children?: React.ReactNode;
};
export default function ThemeInput(props: PropsWithChildren<Props>) {
// const [value, setValue] = useState<HslColor>(props.value);
// const [debouncedValue, setDebouncedValue] = useState<HslColor>(props.value);
// useEffect(() => {
// setValue(props.value);
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [props.value]);
// useEffect(() => {
// const timeout = setTimeout(() => {
// setDebouncedValue(value);
// }, 250);
// return () => clearTimeout(timeout);
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [value]);
// useEffect(() => {
// props.onChange(debouncedValue);
// // eslint-disable-next-line react-hooks/exhaustive-deps
// }, [debouncedValue]);
return (
<div
id={props.key}
slot={`input-${props.key}`}
// className='flex w-full items-center justify-between gap-6'
className='grid w-full grid-cols-3 gap-6'
>
<div
slot='label'
className='flex items-center gap-3'
>
<span className='text-base'>{props.title}</span>
{props.description && (
<Tooltip>
<TooltipTrigger
onClick={(e) => e.preventDefault()}
className='cursor-default'
>
<Icon
name='Info'
size='1rem'
className='text-muted-foreground'
/>
</TooltipTrigger>
<TooltipContent
side='right'
className='max-w-screen-sm'
>
<p className='max-w-screen-sm !whitespace-pre-wrap'>
{props.description}
</p>
</TooltipContent>
</Tooltip>
)}
</div>
<div
slot='input'
className='col-span-2 flex flex-grow items-center justify-end gap-3'
>
{props.children ? (
props.children
) : (
<>
<span className='flex-grow text-sm text-muted-foreground'>
hsl({props.value.h}, {props.value.s}%, {props.value.l}%)
</span>
<Popover>
<PopoverTrigger>
<div className='size-8 rounded-[var(--radius)] border border-border p-0.5'>
<div
className='size-full rounded-[calc(var(--radius)-0.125rem)]'
style={{
background: `hsl(${props.value.h}, ${props.value.s}%, ${props.value.l}%)`,
}}
/>
</div>
</PopoverTrigger>
<PopoverContent className='flex gap-6'>
<HslColorPicker
color={props.value}
onChange={props.onChange}
/>
<div className='flex flex-grow flex-col items-center justify-between'>
<div className='flex flex-col gap-1.5'>
<Label htmlFor={`${props.key}-h`}>Hue</Label>
<Input
id={`${props.key}-h`}
name={`${props.key}-h`}
type='number'
value={props.value.h}
min={0}
max={360}
onChange={(e) => {
const value = props.value;
props.onChange({
h: Number(e.target.value),
s: value.s,
l: value.l,
});
}}
/>
</div>
<div className='flex flex-col gap-1.5'>
<Label htmlFor={`${props.key}-s`}>Saturation</Label>
<Input
id={`${props.key}-s`}
name={`${props.key}-s`}
type='number'
value={props.value.s}
min={0}
max={100}
onChange={(e) => {
const value = props.value;
props.onChange({
h: value.h,
s: Number(e.target.value),
l: value.l,
});
}}
/>
</div>
<div className='flex flex-col gap-1.5'>
<Label htmlFor={`${props.key}-l`}>Lightness</Label>
<Input
id={`${props.key}-l`}
name={`${props.key}-l`}
type='number'
value={props.value.l}
min={0}
max={100}
onChange={(e) => {
const value = props.value;
props.onChange({
h: value.h,
s: value.s,
l: Number(e.target.value),
});
}}
/>
</div>
</div>
</PopoverContent>
</Popover>
</>
)}
</div>
</div>
);
}
@@ -22,7 +22,7 @@ import { parseConfigFile } from "~/utils/parseConfigFile";
import config from "~/config/gIndex.config";
import ConfigInput from "./@form.input";
import ConfigInput from "./@form.input-config";
type Props = {
state: {
+235
View File
@@ -0,0 +1,235 @@
"use client";
import { z } from "zod";
import { Schema_Theme, ThemeKeys } from "~/schema";
import { Input } from "~/components/ui/input";
import { Separator } from "~/components/ui/separator";
import { Slider } from "~/components/ui/slider";
import { parseThemeValue } from "~/utils/parseConfigFile";
import ThemeInput from "./@form.input-theme";
type Props = {
currentTheme: "light" | "dark";
state: {
get: {
light: z.input<typeof Schema_Theme>;
dark: z.input<typeof Schema_Theme>;
};
set: (theme: "light" | "dark", key: ThemeKeys, value: string) => void;
};
};
export default function ThemeForm({
currentTheme,
state: { get, set },
}: Props) {
return (
<div className='flex flex-col gap-1.5'>
<ThemeInput
key='background'
title='Background'
value={parseThemeValue(get[currentTheme].background)}
onChange={(val) => {
set(currentTheme, "background", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='foreground'
title='Text'
value={parseThemeValue(get[currentTheme].foreground)}
onChange={(val) => {
set(currentTheme, "foreground", `${val.h} ${val.s} ${val.l}`);
}}
/>
<Separator className='my-1.5' />
<ThemeInput
key='card'
title='Card'
value={parseThemeValue(get[currentTheme].card)}
onChange={(val) => {
set(currentTheme, "card", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='card-foreground'
title='Card Text'
value={parseThemeValue(get[currentTheme]["card-foreground"])}
onChange={(val) => {
set(currentTheme, "card-foreground", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='popover'
title='Popover'
value={parseThemeValue(get[currentTheme].popover)}
onChange={(val) => {
set(currentTheme, "popover", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='popover-foreground'
title='Popover Text'
value={parseThemeValue(get[currentTheme]["popover-foreground"])}
onChange={(val) => {
set(currentTheme, "popover-foreground", `${val.h} ${val.s} ${val.l}`);
}}
/>
<Separator className='my-1.5' />
<ThemeInput
key='primary'
title='Primary'
value={parseThemeValue(get[currentTheme].primary)}
onChange={(val) => {
set(currentTheme, "primary", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='primary-foreground'
title='Primary Text'
value={parseThemeValue(get[currentTheme]["primary-foreground"])}
onChange={(val) => {
set(currentTheme, "primary-foreground", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='secondary'
title='Secondary'
value={parseThemeValue(get[currentTheme].secondary)}
onChange={(val) => {
set(currentTheme, "secondary", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='secondary-foreground'
title='Secondary Text'
value={parseThemeValue(get[currentTheme]["secondary-foreground"])}
onChange={(val) => {
set(
currentTheme,
"secondary-foreground",
`${val.h} ${val.s} ${val.l}`,
);
}}
/>
<ThemeInput
key='accent'
title='Accent'
value={parseThemeValue(get[currentTheme].accent)}
onChange={(val) => {
set(currentTheme, "accent", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='accent-foreground'
title='Accent Text'
value={parseThemeValue(get[currentTheme]["accent-foreground"])}
onChange={(val) => {
set(currentTheme, "accent-foreground", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='muted'
title='Muted'
value={parseThemeValue(get[currentTheme].muted)}
onChange={(val) => {
set(currentTheme, "muted", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='muted-foreground'
title='Muted Text'
value={parseThemeValue(get[currentTheme]["muted-foreground"])}
onChange={(val) => {
set(currentTheme, "muted-foreground", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='destructive'
title='Destructive'
value={parseThemeValue(get[currentTheme].destructive)}
onChange={(val) => {
set(currentTheme, "destructive", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='destructive-foreground'
title='Destructive Text'
value={parseThemeValue(get[currentTheme]["destructive-foreground"])}
onChange={(val) => {
set(
currentTheme,
"destructive-foreground",
`${val.h} ${val.s} ${val.l}`,
);
}}
/>
<Separator className='my-1.5' />
<ThemeInput
key='border'
title='Element Border'
value={parseThemeValue(get[currentTheme].border)}
onChange={(val) => {
set(currentTheme, "border", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='input'
title='Input Border'
value={parseThemeValue(get[currentTheme].input)}
onChange={(val) => {
set(currentTheme, "input", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='ring'
title='Focus Ring'
value={parseThemeValue(get[currentTheme].ring)}
onChange={(val) => {
set(currentTheme, "ring", `${val.h} ${val.s} ${val.l}`);
}}
/>
<ThemeInput
key='radius'
title='Roundness'
value={{ h: 0, s: 0, l: 0 }}
onChange={(val) => {}}
>
<div className='flex w-full items-center gap-3'>
<Slider
className='w-full'
defaultValue={[parseFloat(get[currentTheme].radius) * 16 || 0]}
min={0}
max={24}
value={[parseFloat(get[currentTheme].radius) * 16 || 0]}
onValueChange={(val) => {
const value = val[0] / 16;
set("light", "radius", `${value}rem`);
set("dark", "radius", `${value}rem`);
}}
/>
<Input
value={parseFloat(get[currentTheme].radius) * 16 || 0}
onChange={(e) => {
const value = parseFloat(e.currentTarget.value) / 16;
set("light", "radius", `${value}rem`);
set("dark", "radius", `${value}rem`);
}}
type='number'
className='w-16 min-w-0 '
min={0}
max={24}
/>
</div>
</ThemeInput>
</div>
);
}
+369
View File
@@ -0,0 +1,369 @@
"use client";
import nProgress from "nprogress";
import { CSSProperties, PropsWithChildren } from "react";
import toast from "react-hot-toast";
import { z } from "zod";
import { Schema_Theme } from "~/schema";
import FileGrid from "~/app/@file.grid";
import FileList from "~/app/@file.list";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "~/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Input } from "~/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Separator } from "~/components/ui/separator";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet";
import { Textarea } from "~/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
type Props = {
theme: z.input<typeof Schema_Theme>;
};
export default function ThemePreview({ theme }: Props) {
return (
<div
className='flex flex-col gap-3 rounded-[var(--radius)] border border-border bg-background p-3 text-foreground'
style={
{
"--background": theme.background,
"--foreground": theme.foreground,
"--card": theme.card,
"--card-foreground": theme["card-foreground"],
"--popover": theme.popover,
"--popover-foreground": theme["popover-foreground"],
"--primary": theme.primary,
"--primary-foreground": theme["primary-foreground"],
"--secondary": theme.secondary,
"--secondary-foreground": theme["secondary-foreground"],
"--muted": theme.muted,
"--muted-foreground": theme["muted-foreground"],
"--accent": theme.accent,
"--accent-foreground": theme["accent-foreground"],
"--destructive": theme.destructive,
"--destructive-foreground": theme["destructive-foreground"],
"--border": theme.border,
"--input": theme.input,
"--ring": theme.ring,
"--radius": theme.radius,
} as CSSProperties
}
>
<Wrapper title='Button'>
<Button
size={"sm"}
variant={"default"}
>
Default
</Button>
<Button
size={"sm"}
variant={"secondary"}
>
Secondary
</Button>
<Button
size={"sm"}
variant={"destructive"}
>
Destructive
</Button>
<Button
size={"sm"}
variant={"outline"}
>
Outline
</Button>
<Button
size={"sm"}
variant={"ghost"}
>
Ghost
</Button>
<Button
size={"sm"}
variant={"link"}
>
Link
</Button>
</Wrapper>
<Wrapper title='Card'>
<Card className='w-full'>
<CardHeader>
<CardTitle>Title</CardTitle>
<CardDescription>Description</CardDescription>
<Separator />
</CardHeader>
<CardContent>Content</CardContent>
<CardFooter>Footer</CardFooter>
</Card>
</Wrapper>
<Wrapper title='Input'>
<Input placeholder='Input' />
<Textarea placeholder='Textarea' />
</Wrapper>
<Wrapper title='Popover'>
<Tooltip>
<TooltipTrigger>
<Button
size={"sm"}
variant={"outline"}
>
Hover me
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Tooltip content</p>
</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger>
<Button
size={"sm"}
variant={"outline"}
>
Click me
</Button>
</PopoverTrigger>
<PopoverContent>
<p>Popover content</p>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger>
<Button
size={"sm"}
variant={"outline"}
>
Click me
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>Item 1</DropdownMenuItem>
<DropdownMenuItem>Item 2</DropdownMenuItem>
<DropdownMenuItem disabled>Item 3</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
size={"sm"}
variant={"outline"}
onClick={async () => {
toast.loading("Loading...", {
duration: 3000,
});
toast.success("Success", {
duration: 3000,
});
toast.error("Error", {
duration: 3000,
});
}}
>
Show toast
</Button>
</Wrapper>
<Wrapper title='Overlay'>
<Dialog>
<DialogTrigger>
<Button
size={"sm"}
variant={"outline"}
>
Open Dialog
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Title</DialogTitle>
<DialogDescription>Description</DialogDescription>
<Separator />
</DialogHeader>
<div>Content</div>
<DialogFooter>
<DialogClose>
<Button
size={"sm"}
variant={"secondary"}
>
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<Drawer>
<DrawerTrigger>
<Button
size={"sm"}
variant={"outline"}
>
Open Drawer
</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Title</DrawerTitle>
<DrawerDescription>Description</DrawerDescription>
<Separator />
</DrawerHeader>
<div className='p-6'>Content</div>
<DrawerFooter>
<DrawerClose>
<Button
size={"sm"}
variant={"secondary"}
className='w-full'
>
Close
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
<Sheet>
<SheetTrigger>
<Button
size={"sm"}
variant={"outline"}
>
Open Sheet
</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Title</SheetTitle>
<SheetDescription>Description</SheetDescription>
<Separator />
</SheetHeader>
<div>Content</div>
<SheetFooter>
<SheetClose>
<Button
size={"sm"}
variant={"secondary"}
className='w-full'
>
Close
</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
<Button
size={"sm"}
variant={"outline"}
onClick={async () => {
nProgress.start();
await new Promise((resolve) => setTimeout(resolve, 1500));
nProgress.done();
}}
>
Test Loading
</Button>
</Wrapper>
<Wrapper title='File'>
<div className='flex w-full flex-col gap-3'>
<FileList
data={{
encryptedId: "encryptedId",
name: "File Name",
modifiedTime: "2022-01-01T00:00:00Z",
mimeType: "text/plain",
trashed: false,
size: 1024 * 1024 * 100,
}}
disabled
/>
<div className='grid w-full'>
<FileGrid
data={{
encryptedId: "encryptedId",
name: "File Name",
modifiedTime: "2022-01-01T00:00:00Z",
mimeType: "text/plain",
trashed: false,
size: 1024 * 1024 * 100,
}}
disabled
/>
</div>
</div>
</Wrapper>
</div>
);
}
type WrapperProps = {
title: string;
};
function Wrapper({ title, children }: PropsWithChildren<WrapperProps>) {
return (
<div className='flex w-full flex-col gap-1.5'>
<h2 className='text-lg font-semibold'>{title}</h2>
<div className={"flex w-full flex-wrap items-center gap-1.5"}>
{children}
</div>
</div>
);
}
+608 -31
View File
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import toast from "react-hot-toast";
import { z } from "zod";
@@ -9,6 +10,8 @@ import {
ConfigurationKeys,
ConfigurationValue,
Schema_App_Configuration,
Schema_Theme,
ThemeKeys,
} from "~/schema";
import { cn } from "~/utils";
@@ -16,15 +19,29 @@ import Markdown from "~/app/@markdown";
import Icon from "~/components/Icon";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Separator } from "~/components/ui/separator";
import { Textarea } from "~/components/ui/textarea";
import { encryptData } from "~/utils/encryptionHelper/hash";
import { parseThemeValue } from "~/utils/parseConfigFile";
import config from "~/config/gIndex.config";
import ApiConfig from "./@form.api-config";
import EnvironmentConfig from "./@form.env-config";
import SiteConfig from "./@form.site-config";
import ThemeForm from "./@form.theme";
import ThemePreview from "./@theme-preview";
export const getting_started = `Welcome to the deployment guide! This guide will help you to deploy the application to Vercel or similar services.
@@ -198,6 +215,23 @@ const initialConfiguration: z.input<typeof Schema_App_Configuration> = {
},
};
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
const afterClick = () => {
setTimeout(() => {
URL.revokeObjectURL(url);
removeEventListener("click", afterClick);
}, 100);
};
anchor.addEventListener("click", afterClick, false);
anchor.click();
}
export function Configuration() {
const [loading, setLoading] = useState<boolean>(true);
const [configuration, setConfiguration] =
@@ -282,23 +316,6 @@ export function Configuration() {
);
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
const afterClick = () => {
setTimeout(() => {
URL.revokeObjectURL(url);
removeEventListener("click", afterClick);
}, 100);
};
anchor.addEventListener("click", afterClick, false);
anchor.click();
}
let envContent = Object.entries(configuration.environment)
.map(([key, value]) => `${key}=${value}`)
.join("\n");
@@ -750,29 +767,589 @@ If you're migrating from previous version, you can load your old environment and
}
export function CustomizeTheme() {
const [colors, setColors] = useState({
primary: { h: 200, s: 50, l: 50 },
secondary: { h: 200, s: 50, l: 50 },
const [initialTheme] = useState<{
light: z.input<typeof Schema_Theme>;
dark: z.input<typeof Schema_Theme>;
}>({
light: {
"background": "0 0% 100%",
"foreground": "0 0% 3.9%",
"card": "0 0% 100%",
"card-foreground": "0 0% 3.9%",
"popover": "0 0% 100%",
"popover-foreground": "0 0% 3.9%",
"primary": "0 0% 9%",
"primary-foreground": "0 0% 98%",
"secondary": "0 0% 96.1%",
"secondary-foreground": "0 0% 9%",
"muted": "0 0% 96.1%",
"muted-foreground": "0 0% 45.1%",
"accent": "0 0% 96.1%",
"accent-foreground": "0 0% 9%",
"destructive": "0 84.2% 60.2%",
"destructive-foreground": "0 0% 98%",
"border": "0 0% 89.8%",
"input": "0 0% 89.8%",
"ring": "0 0% 89.8%",
"radius": "0.5rem",
},
dark: {
"background": "0 0% 3.9%",
"foreground": "0 0% 98%",
"card": "0 0% 3.9%",
"card-foreground": "0 0% 98%",
"popover": "0 0% 3.9%",
"popover-foreground": "0 0% 98%",
"primary": "0 0% 98%",
"primary-foreground": "0 0% 9%",
"secondary": "0 0% 14.9%",
"secondary-foreground": "0 0% 98%",
"muted": "0 0% 14.9%",
"muted-foreground": "0 0% 63.9%",
"accent": "0 0% 14.9%",
"accent-foreground": "0 0% 98%",
"destructive": "0 62.8% 30.6%",
"destructive-foreground": "0 0% 98%",
"border": "0 0% 14.9%",
"input": "0 0% 14.9%",
"ring": "0 0% 14.9%",
"radius": "0.5rem",
},
});
const [currentTheme, setCurrentTheme] = useState<"light" | "dark">("light");
const [theme, setTheme] = useState<{
light: z.input<typeof Schema_Theme>;
dark: z.input<typeof Schema_Theme>;
}>(initialTheme);
const [dialog, setDialog] = useState<boolean>(false);
const [cssLoading, setCssLoading] = useState<boolean>(false);
function onThemeChange(
theme: "light" | "dark",
key: ThemeKeys,
value: string,
) {
setTheme((prev) => ({
...prev,
[theme]: {
...prev[theme],
[key]: value,
},
}));
}
function onSelectedThemeReset(theme: "light" | "dark", key: ThemeKeys) {
setTheme((prev) => ({
...prev,
[theme]: {
...prev[theme],
[key]: initialTheme[theme][key],
},
}));
}
function onThemeReset() {
setTheme(initialTheme);
}
function isChanged(t: "light" | "dark", key: ThemeKeys): boolean {
return t === "light"
? theme.light[key] !== initialTheme.light[key]
: theme.dark[key] !== initialTheme.dark[key];
}
return (
<Card>
<CardHeader className='pb-0'>
<CardTitle
className='text-3xl'
id='theme'
>
Customize Theme
</CardTitle>
<div className='flex flex-col gap-3 tablet:flex-row tablet:items-center tablet:justify-between'>
<CardTitle
className='text-3xl'
id='theme'
>
Customize Theme
</CardTitle>
<div className='flex w-full items-center gap-3 tablet:w-fit'>
<Button
size='sm'
variant={"outline"}
onClick={(e) => {
e.preventDefault();
try {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".css";
fileInput.onchange = async (fileEvent) => {
const file = (fileEvent.target as HTMLInputElement)
.files?.[0];
if (!file) return toast.error("No file selected");
const fileName = file.name;
if (fileName !== "globals.css")
return toast.error("Please select globals.css file");
const reader = new FileReader();
reader.onload = async (read) => {
const result = read.target?.result as string;
if (!result) return toast.error("Failed to read file");
const themeRegex =
/\/\* Shadcn\/ui theme \*\/.*?\:root \{(.*?)\}.*?.dark \{(.*?) \}/gm;
const themeMatch = themeRegex.exec(
result.replace(/\r\n/g, ""),
);
if (!themeMatch)
return toast.error(
"Can't find theme, are you sure it's the right file?",
);
const lightTheme = themeMatch[1]
.replace(/\s{2,4}/g, "\n")
.split("\n")
.map((v) => v.trim())
.filter(
(v) =>
v.length &&
(v.startsWith("/*") && v.endsWith("*/")
? false
: true),
);
const darkTheme = themeMatch[2]
.replace(/\s{2,4}/g, "\n")
.split("\n")
.map((v) => v.trim())
.filter(
(v) =>
v.length &&
(v.startsWith("/*") && v.endsWith("*/")
? false
: true),
);
// Loop through the theme and set it
for (const key in lightTheme) {
const [k, v] = lightTheme[key]
.split(":")
.map((v) => v.replace("--", "").trim());
const hslRegex = /\d\s\d.*?\%\s\d.*?\%;/g;
if (hslRegex.test(v)) {
const color = parseThemeValue(v);
onThemeChange(
"light",
k as ThemeKeys,
`${color.h} ${color.s}% ${color.l}%`,
);
} else {
onThemeChange("light", k as ThemeKeys, v);
}
}
for (const key in darkTheme) {
const [k, v] = darkTheme[key]
.split(":")
.map((v) => v.replace("--", "").trim());
const hslRegex = /\d\s\d.*?\%\s\d.*?\%;/g;
if (hslRegex.test(v)) {
const color = parseThemeValue(v);
onThemeChange(
"dark",
k as ThemeKeys,
`${color.h} ${color.s}% ${color.l}%`,
);
} else {
onThemeChange("dark", k as ThemeKeys, v);
}
}
fileInput.value = "";
toast.success("Theme loaded successfully");
};
reader.readAsText(file);
};
fileInput.click();
} catch (error) {
const e = error as Error;
console.error(e);
toast.error(e.message);
}
}}
>
Load CSS
</Button>
<Dialog
open={dialog}
onOpenChange={setDialog}
>
<DialogTrigger>
<Button
size='sm'
variant={"outline"}
>
Paste Code
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Paste Theme</DialogTitle>
<DialogDescription>
Paste the CSS code from your theme file
<br />
<Link
href={"https://ui.shadcn.com/themes"}
target='_blank'
className='text-blue-600 opacity-80 transition-all duration-300 hover:opacity-100 dark:text-blue-400'
>
Get themes from shadcn/ui
</Link>
</DialogDescription>
</DialogHeader>
<form
className='space-y-3'
onSubmit={(e) => {
e.preventDefault();
setCssLoading(true);
try {
const formData = new FormData(
e.target as HTMLFormElement,
);
const css = formData.get("css-paste") as string;
if (!css) throw new Error("No CSS code provided");
const flatten = css.trim().replace(/\n/g, "");
const themeRegex =
/\@layer base.*?\:root \{(.*?)\}.*?.dark \{(.*?) \}/gm;
const themeMatch = themeRegex.exec(
flatten.replace(/\r\n/g, ""),
);
if (!themeMatch)
throw new Error(
"Unknown format, please refer to shadcn/ui theme",
);
const lightTheme = themeMatch[1]
.replace(/\s{2,4}/g, "\n")
.split("\n")
.map((v) => v.trim())
.filter(
(v) =>
v.length &&
(v.startsWith("/*") && v.endsWith("*/")
? false
: true),
);
const darkTheme = themeMatch[2]
.replace(/\s{2,4}/g, "\n")
.split("\n")
.map((v) => v.trim())
.filter(
(v) =>
v.length &&
(v.startsWith("/*") && v.endsWith("*/")
? false
: true),
);
for (const key in lightTheme) {
const [k, v] = lightTheme[key]
.split(":")
.map((v) => v.replace("--", "").trim());
const color = parseThemeValue(v);
const hslRegex = /\d\s\d.*?\%\s\d.*?\%;/g;
if (hslRegex.test(v)) {
const color = parseThemeValue(v);
onThemeChange(
"light",
k as ThemeKeys,
`${color.h} ${color.s}% ${color.l}%`,
);
} else {
onThemeChange("light", k as ThemeKeys, v);
}
}
for (const key in darkTheme) {
const [k, v] = darkTheme[key]
.split(":")
.map((v) => v.replace("--", "").trim());
const color = parseThemeValue(v);
const hslRegex = /\d\s\d.*?\%\s\d.*?\%;/g;
if (hslRegex.test(v)) {
const color = parseThemeValue(v);
onThemeChange(
"dark",
k as ThemeKeys,
`${color.h} ${color.s}% ${color.l}%`,
);
} else {
onThemeChange("dark", k as ThemeKeys, v);
}
}
toast.success("Theme loaded successfully");
setDialog(false);
} catch (error) {
const e = error as Error;
console.error(e);
toast.error(e.message);
} finally {
setCssLoading(false);
}
}}
>
<Textarea
id='css-paste'
name='css-paste'
className='w-full'
rows={10}
placeholder='Please refer to shadcn/ui theme for the theme code'
/>
<DialogFooter>
<DialogClose asChild>
<Button
size={"sm"}
variant={"destructive"}
type='reset'
>
Cancel
</Button>
</DialogClose>
<Button
size={"sm"}
disabled={cssLoading}
type='submit'
>
<div className='relative flex w-full items-center justify-center'>
<span className='relative transition-all duration-300 ease-in-out'>
Load Theme
</span>
<Icon
name='LoaderCircle'
className={cn(
"animate-spin transition-all",
cssLoading
? "ml-1.5 size-4 opacity-100"
: "ml-0 size-0 opacity-0",
)}
/>
</div>
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Button
size='sm'
variant={"destructive"}
onClick={onThemeReset}
>
Reset All
</Button>
</div>
</div>
<Separator />
</CardHeader>
<CardContent>
<Markdown
content={`#### Under Construction
<div className='grid grid-cols-1 gap-6 py-3 tablet:grid-cols-2'>
<div className='col-span-full flex w-full items-center'>
<Button
size='sm'
variant={currentTheme === "light" ? "default" : "outline"}
onClick={() => setCurrentTheme("light")}
className='w-full rounded-r-none'
>
Light Theme
</Button>
<Button
size='sm'
variant={currentTheme === "dark" ? "default" : "outline"}
onClick={() => setCurrentTheme("dark")}
className='w-full rounded-l-none'
>
Dark Theme
</Button>
</div>
<div className='flex flex-col gap-3'>
<ThemeForm
currentTheme={currentTheme}
state={{
get: theme,
set: onThemeChange,
}}
/>
<Button
size={"sm"}
variant={"secondary"}
onClick={(e) => {
e.preventDefault();
const otherTheme = currentTheme === "light" ? "dark" : "light";
setTheme((prev) => ({
...prev,
[otherTheme]: {
...prev[currentTheme],
radius: "0.5rem",
},
}));
toast.success(
`${currentTheme
.slice(0, 1)
.toUpperCase()}${currentTheme.slice(
1,
)} theme copied to ${otherTheme} theme`,
);
}}
>
Copy to {currentTheme === "light" ? "Dark" : "Light"} Theme
</Button>
</div>
While I'm working on the theme customization, you can check [shadcn UI theme](https://ui.shadcn.com/themes) for now`}
view='markdown'
/>
<ThemePreview
theme={currentTheme === "light" ? theme.light : theme.dark}
/>
<Separator className='col-span-full' />
<div className='col-span-full flex w-full flex-col items-end justify-center gap-1.5'>
<div className='flex items-center gap-3'>
<Button
variant={"outline"}
size={"sm"}
onClick={async (e) => {
try {
const style = `@layer base {
:root {
${Object.entries(theme.light)
.map(([key, value]) => ` --${key}: ${value};`.replace(";;", ";"))
.join("\n")}
}
.dark {
${Object.entries(theme.dark)
.map(([key, value]) => ` --${key}: ${value};`.replace(";;", ";"))
.join("\n")}
}
}`;
await navigator.clipboard.writeText(style);
toast.success("CSS code copied to clipboard");
} catch (error) {
const e = error as Error;
console.error(e);
toast.error(e.message);
}
}}
>
Copy CSS Code
</Button>
<Button
size={"sm"}
onClick={(e) => {
e.preventDefault();
toast.loading("Creating CSS file...", {
id: "download-css",
});
try {
const css = `@tailwind base;
@tailwind components;
@tailwind utilities;
/* Shadcn/ui theme */
@layer base {
:root {
${Object.entries(theme.light)
.map(([key, value]) => ` --${key}: ${value};`.replace(";;", ";"))
.join("\n")}
}
.dark {
${Object.entries(theme.dark)
.map(([key, value]) => ` --${key}: ${value};`.replace(";;", ";"))
.join("\n")}
}
}
html,
body {
margin: 0;
padding: 0;
box-sizing: border-box;
}
@layer base {
:root {
/* @apply text-[14px] tablet:text-[16px]; */
@apply text-[100%];
}
* {
@apply border-border;
/* @apply outline outline-1 outline-red-500; */
}
body {
@apply bg-background text-foreground;
}
::-webkit-scrollbar {
@apply h-1.5 w-1.5;
}
::-webkit-scrollbar-track {
@apply bg-primary/5;
}
::-webkit-scrollbar-thumb {
@apply rounded-full bg-primary/25 hover:bg-primary/50;
}
/* Typography */
h1 {
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
}
h2 {
@apply scroll-m-20 text-3xl font-semibold tracking-tight first:mt-0;
}
h3 {
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
}
h4 {
@apply scroll-m-20 text-xl font-semibold tracking-tight;
}
.paragraph {
@apply leading-7 [&:not(:first-child)]:mt-6;
}
blockquote {
@apply mt-6 border-l-2 pl-6 italic;
}
ul {
@apply my-6 ml-6 list-disc [&>li]:mt-2;
}
.lead {
@apply text-xl text-muted-foreground;
}
.large {
@apply text-lg font-semibold;
}
.muted {
@apply text-sm text-muted-foreground;
}
small {
@apply text-sm font-medium leading-none;
}
}`;
const blob = new Blob([css], { type: "text/css" });
downloadBlob(blob, "globals.css");
toast.success("CSS downloaded successfully", {
id: "download-css",
});
} catch (error) {
const e = error as Error;
console.error(e);
toast.error(e.message, {
id: "download-css",
});
}
}}
>
Download CSS
</Button>
</div>
<span className='text-sm text-muted-foreground'>
It is recommended to use "Copy CSS Code" button to copy the CSS
code
</span>
</div>
</div>
</CardContent>
</Card>
);
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "~/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
+1
View File
@@ -236,3 +236,4 @@ export const Schema_Theme = z.object({
"ring": z.string(),
"radius": z.string(),
});
export type ThemeKeys = keyof z.infer<typeof Schema_Theme>;
+10
View File
@@ -1,4 +1,5 @@
// Used for configuration on deploy guide page
import { HslColor } from "react-colorful";
import { z } from "zod";
import {
Schema_Config,
@@ -77,3 +78,12 @@ export function parseConfigFile(config: string):
return { success: false, message: e.message };
}
}
export function parseThemeValue(color: string): HslColor {
const [h, s, l] = color.split(" ");
return {
h: parseFloat(h),
s: parseFloat(s),
l: parseFloat(l),
};
}