feat: add homepage & auth

This commit is contained in:
Khairul Hidayat 2024-02-23 19:36:10 +07:00
parent d579c1b7c5
commit 735fa9354b
42 changed files with 981 additions and 70 deletions

3
.env
View File

@ -1 +1,2 @@
NEXT_PUBLIC_BASE_URL=http://localhost:3000
BASE_URL=http://localhost:3000
JWT_KEY=test123

View File

@ -1 +1,2 @@
NEXT_PUBLIC_BASE_URL=http://localhost:3000
BASE_URL=http://localhost:3000
JWT_KEY=test123

View File

@ -0,0 +1,35 @@
import Link from "~/renderer/link";
import Logo from "./logo";
const Footer = () => {
return (
<footer className="py-12 md:py-24 border-t border-white/20">
<div className="container max-w-5xl grid md:grid-cols-2 gap-x-4 gap-y-6">
<div>
<Logo size="lg" />
<p className="text-sm mt-3 text-white/80">
Create, Collaborate, and Share your web design or snippets with
ease.
</p>
</div>
<div>
<p>Links</p>
<ul className="mt-4 text-sm space-y-2 text-white/80">
<li>
<Link href="#" className="hover:text-white">
Terms and Condition
</Link>
</li>
<li>
<Link href="#" className="hover:text-white">
Privacy Policy
</Link>
</li>
</ul>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@ -0,0 +1,27 @@
import React from "react";
import { cn } from "~/lib/utils";
type Props = {
size?: "sm" | "md" | "lg";
};
const Logo = ({ size = "md" }: Props) => {
const sizes: Record<typeof size, string> = {
sm: "text-lg",
md: "text-xl",
lg: "text-2xl",
};
return (
<p
className={cn(
"text-xl font-bold text-transparent bg-gradient-to-b from-gray-200 to-gray-100 bg-clip-text",
sizes[size]
)}
>
CodeShare
</p>
);
};
export default Logo;

View File

@ -0,0 +1,85 @@
import React, { ComponentProps } from "react";
import { cn } from "~/lib/utils";
import Link from "~/renderer/link";
import { Button } from "../ui/button";
import Logo from "./logo";
import { useAuth } from "~/hooks/useAuth";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { FaChevronDown } from "react-icons/fa";
import trpc from "~/lib/trpc";
const Navbar = () => {
const { user } = useAuth();
const logout = trpc.auth.logout.useMutation({
onSuccess() {
window.location.reload();
},
});
return (
<>
<div className="h-16" />
<header className="h-16 bg-background fixed z-20 top-0 left-0 w-full border-b border-white/20">
<div className="h-full container max-w-5xl flex items-center gap-3">
<Logo />
<NavMenu className="md:ml-8 flex-1">
<NavItem href="/">Home</NavItem>
{/* <NavItem href="/browse">Browse</NavItem> */}
</NavMenu>
<div className="flex items-center gap-3">
{user ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="gap-3 px-0">
<div className="size-8 rounded-full bg-white/40" />
<p>{user.name}</p>
<FaChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => logout.mutate()}>
Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button>Log in</Button>
)}
</div>
</div>
</header>
</>
);
};
const NavMenu = ({ children, className }: ComponentProps<"nav">) => (
<nav className={cn("flex items-stretch h-full gap-6", className)}>
{children}
</nav>
);
type NavItemProps = {
href?: string;
children?: React.ReactNode;
};
const NavItem = ({ href, children }: NavItemProps) => {
return (
<Link
href={href}
className="flex items-center text-white/70 data-[active]:text-white hover:text-white transition-colors"
>
{children}
</Link>
);
};
export default Navbar;

View File

@ -0,0 +1,18 @@
import React from "react";
import Navbar from "../containers/navbar";
type LayoutProps = {
children: React.ReactNode;
};
const MainLayout = ({ children }: LayoutProps) => {
return (
<div>
<Navbar />
{children}
</div>
);
};
export default MainLayout;

View File

@ -3,6 +3,7 @@ import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils";
import { Loader2 } from "lucide-react";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 dark:ring-offset-slate-950 dark:focus-visible:ring-slate-300",
@ -14,7 +15,7 @@ const buttonVariants = cva(
destructive:
"bg-red-500 text-slate-50 hover:bg-red-500/90 dark:bg-red-900 dark:text-slate-50 dark:hover:bg-red-900/90",
outline:
"border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-slate-800 dark:bg-slate-950 dark:hover:bg-slate-800 dark:hover:text-slate-50",
"border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900 dark:border-white/40 dark:bg-transparent dark:hover:bg-slate-800 dark:hover:text-slate-50",
secondary:
"bg-slate-100 text-slate-900 hover:bg-slate-100/80 dark:bg-slate-800 dark:text-slate-50 dark:hover:bg-slate-800/80",
ghost:
@ -39,21 +40,36 @@ export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
href?: string;
isLoading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{ className, variant, size, type = "button", asChild = false, ...props },
ref
) => {
const Comp = asChild ? Slot : "button";
(props, ref) => {
const {
className,
variant,
size,
type = "button",
asChild = false,
disabled,
isLoading,
children,
...restProps
} = props;
const Comp = asChild ? Slot : props.href ? "a" : "button";
return (
<Comp
type={type}
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
ref={ref as any}
disabled={isLoading || disabled}
{...(restProps as any)}
>
{isLoading ? <Loader2 className="animate-spin mr-2" /> : null}
{children}
</Comp>
);
}
);

25
components/ui/card.tsx Normal file
View File

@ -0,0 +1,25 @@
import React from "react";
import { cn } from "~/lib/utils";
type Props = React.ComponentProps<"div">;
const Card = ({ className, ...props }: Props) => {
return (
<div
className={cn(
"bg-background border border-white/20 rounded-lg p-4",
className
)}
{...props}
/>
);
};
export const CardTitle = ({
className,
...props
}: React.ComponentProps<"p">) => (
<p className={cn("text-2xl mb-8", className)} {...props} />
);
export default Card;

10
components/ui/divider.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from "react";
import { cn } from "~/lib/utils";
type Props = React.ComponentProps<"hr">;
const Divider = ({ className, ...props }: Props) => {
return <hr className={cn("border-white/20", className)} {...props} />;
};
export default Divider;

View File

@ -0,0 +1,34 @@
import React from "react";
import { cn } from "~/lib/utils";
export type FormFieldProps = {
id?: string;
label?: string;
error?: string;
};
type Props = FormFieldProps & {
children?: React.ReactNode;
className?: string;
};
const FormField = ({ id, label, error, children, className }: Props) => {
return (
<div className={cn("form-field", className)}>
{label ? <FormLabel htmlFor={id}>{label}</FormLabel> : null}
{children}
{error ? <p className="text-sm mt-1 text-red-500">{error}</p> : null}
</div>
);
};
export const FormLabel = ({
className,
...props
}: React.ComponentProps<"label">) => (
<label className={cn("text-sm block mb-2", className)} {...props} />
);
export default FormField;

View File

@ -1,25 +1,77 @@
import * as React from "react";
import { ForwardedRef, forwardRef, useId } from "react";
import { Controller, FieldValues, Path } from "react-hook-form";
import { useFormReturn } from "~/hooks/useForm";
import { cn } from "~/lib/utils";
import FormField, { FormFieldProps } from "./form-field";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
export type BaseInputProps = React.InputHTMLAttributes<HTMLInputElement> &
FormFieldProps & {
inputClassName?: string;
};
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
const BaseInput = forwardRef<HTMLInputElement, BaseInputProps>(
({ className, inputClassName, type, label, error, ...props }, ref) => {
const id = useId();
const input = (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-800 dark:bg-slate-950 dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300",
className
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/40 dark:bg-background dark:ring-offset-slate-950 dark:placeholder:text-slate-400 dark:focus-visible:ring-slate-300",
inputClassName
)}
ref={ref}
{...props}
/>
);
if (label || error) {
return (
<FormField
id={id}
label={label}
error={error}
className={className}
children={input}
/>
);
}
return input;
}
);
Input.displayName = "Input";
BaseInput.displayName = "BaseInput";
export { Input };
type InputProps<T extends FieldValues> = Omit<
BaseInputProps,
"form" | "name"
> & {
form?: useFormReturn<T>;
name?: Path<T>;
};
const Input = <T extends FieldValues>(props: InputProps<T>) => {
const { form, ...restProps } = props;
if (form && props.name) {
return (
<Controller
control={form.control}
name={props.name}
render={({ field, fieldState }) => (
<BaseInput
{...restProps}
{...field}
value={field.value || ""}
error={fieldState.error?.message}
/>
)}
/>
);
}
return <BaseInput {...restProps} />;
};
export { BaseInput };
export default Input;

7
hooks/useAuth.ts Normal file
View File

@ -0,0 +1,7 @@
import { usePageContext } from "~/renderer/context";
export const useAuth = () => {
const { user } = usePageContext();
return { user, isLoggedIn: user != null };
};

View File

@ -20,3 +20,7 @@ export const useForm = <T extends FieldValues>(
return form;
};
export type useFormReturn<T extends FieldValues> = ReturnType<
typeof useForm<T>
>;

View File

@ -25,3 +25,11 @@ export function getPreviewUrl(
typeof file === "string" ? file : file.path
);
}
export const ucfirst = (str: string) => {
return str.charAt(0).toUpperCase() + str.substring(1);
};
export const ucwords = (str: string) => {
return str.split(" ").map(ucfirst).join(" ");
};

View File

@ -22,8 +22,10 @@
"license": "ISC",
"devDependencies": {
"@swc/cli": "^0.3.9",
"@types/bcrypt": "^5.0.2",
"@types/cookie-parser": "^1.4.6",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.5",
"@types/node": "^20.11.19",
"@types/nprogress": "^0.2.3",
"@types/react": "^18.2.57",
@ -42,6 +44,7 @@
"@codemirror/lang-javascript": "^6.2.1",
"@codemirror/lang-json": "^6.0.1",
"@emmetio/codemirror6-plugin": "^0.3.0",
"@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^3.3.4",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-dialog": "^1.0.5",
@ -63,9 +66,11 @@
"cookie-parser": "^1.4.6",
"copy-to-clipboard": "^3.3.3",
"cssnano": "^6.0.3",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.29.3",
"drizzle-zod": "^0.5.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.331.0",
"mime": "^4.0.1",
"nprogress": "^0.2.0",

3
pages/auth/+Layout.tsx Normal file
View File

@ -0,0 +1,3 @@
import MainLayout from "~/components/layout/main-layout";
export { MainLayout as Layout };

View File

@ -0,0 +1,61 @@
import { z } from "zod";
import { Button } from "~/components/ui/button";
import Card, { CardTitle } from "~/components/ui/card";
import Divider from "~/components/ui/divider";
import Input from "~/components/ui/input";
import { useForm } from "~/hooks/useForm";
import trpc from "~/lib/trpc";
import { useSearchParams } from "~/renderer/hooks";
const schema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const initialValue: z.infer<typeof schema> = {
email: "",
password: "",
};
const LoginPage = () => {
const form = useForm(schema, initialValue);
const searchParams = useSearchParams();
const login = trpc.auth.login.useMutation({
onSuccess() {
const prevPage = searchParams.get("return");
window.location.href = prevPage || "/";
},
});
const onSubmit = form.handleSubmit((values) => {
login.mutate(values);
});
return (
<main className="container max-w-xl min-h-[80dvh] flex flex-col items-center justify-center py-8 md:py-16">
<Card className="w-full md:p-8">
<CardTitle>Log in</CardTitle>
<form onSubmit={onSubmit}>
<div className="space-y-3">
<Input form={form} name="email" label="Email Address" />
<Input
form={form}
type="password"
name="password"
label="Password"
/>
</div>
<Divider className="mt-8 mb-4" />
<Button type="submit" isLoading={login.isPending}>
Login
</Button>
</form>
</Card>
</main>
);
};
export default LoginPage;

3
pages/index/+Layout.tsx Normal file
View File

@ -0,0 +1,3 @@
import MainLayout from "~/components/layout/main-layout";
export { MainLayout as Layout };

View File

@ -1,20 +1,61 @@
import { Button } from "~/components/ui/button";
import type { Data } from "./+data";
import { useData } from "~/renderer/hooks";
import Link from "~/renderer/link";
import Footer from "~/components/containers/footer";
const HomePage = () => {
const { projects } = useData<Data>();
return (
<div>
<h1>Posts</h1>
<>
<main>
<section className="container py-8 md:py-16 min-h-dvh md:min-h-[80vh] flex items-center justify-center flex-col text-center">
<h1 className="text-4xl md:text-5xl lg:text-6xl font-medium text-transparent bg-gradient-to-b from-gray-200 to-gray-100 bg-clip-text">
Create and Share Web Snippets
</h1>
<p className="text-lg mt-8 md:mt-12 text-gray-300">
Collaborate with Ease, Share with the World
</p>
{projects.map((project: any) => (
<Link key={project.id} href={`/${project.slug}`}>
{project.title}
</Link>
))}
</div>
<div className="flex flex-col sm:flex-row gap-3 items-center mt-8 md:mt-12">
<Button href="/get-started" size="lg">
Getting Started
</Button>
<Button href="#browse" size="lg" variant="outline">
Browse Projects
</Button>
</div>
</section>
<section id="browse" className="container max-w-5xl py-8 md:py-16">
<h2 className="text-4xl font-medium border-b pb-3 mb-8 border-white/20">
Community Projects
</h2>
<div className="grid sm:grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
{projects.map((project) => (
<Link
key={project.id}
href={`/${project.slug}`}
className="border border-white/20 hover:border-white/40 rounded-lg transition-colors overflow-hidden"
>
<div className="w-full aspect-[3/2] bg-gray-900"></div>
<div className="py-2 px-3 flex items-center gap-3">
<div className="size-8 rounded-full bg-white/80"></div>
<div>
<p className="text-md truncate">{project.title}</p>
<p className="text-xs truncate">{project.user.name}</p>
</div>
</div>
</Link>
))}
</div>
</section>
</main>
<Footer />
</>
);
};

View File

@ -0,0 +1,143 @@
import { Button } from "~/components/ui/button";
import Card, { CardTitle } from "~/components/ui/card";
import { FormLabel } from "~/components/ui/form-field";
import Input from "~/components/ui/input";
import { useData } from "~/renderer/hooks";
import { Data } from "./+data";
import { useForm } from "~/hooks/useForm";
import { z } from "zod";
import { Controller } from "react-hook-form";
import { navigate } from "vike/client/router";
import Divider from "~/components/ui/divider";
import trpc from "~/lib/trpc";
import { useAuth } from "~/hooks/useAuth";
import { usePageContext } from "~/renderer/context";
const schema = z.object({
forkFromId: z.number(),
title: z.string(),
user: z
.object({
name: z.string().min(3),
email: z.string().email(),
password: z.string().min(6),
})
.optional(),
});
const initialValue: z.infer<typeof schema> = {
forkFromId: 0,
title: "",
};
const GetStartedPage = () => {
const { presets } = useData<Data>();
const { isLoggedIn } = useAuth();
const ctx = usePageContext();
const form = useForm(schema, initialValue);
const create = trpc.project.create.useMutation({
onSuccess(data) {
navigate(`/${data.slug}`);
},
});
const onSubmit = form.handleSubmit((values) => {
create.mutate(values);
});
return (
<div className="container max-w-3xl min-h-[80dvh] flex flex-col items-center justify-center py-8 md:py-16">
<Card className="w-full md:p-8">
<CardTitle>Create New Project</CardTitle>
<form onSubmit={onSubmit}>
<FormLabel>Select Preset</FormLabel>
<Controller
control={form.control}
name="forkFromId"
render={({ field }) => (
<div className="flex md:grid md:grid-cols-3 gap-4 overflow-x-auto md:overflow-x-hidden">
{presets.map((preset) => (
<Button
key={preset.projectId}
variant={
field.value === preset.projectId ? "default" : "outline"
}
className="flex py-16 border border-white/40 shrink-0 w-[160px] md:w-auto"
onClick={() => field.onChange(preset.projectId)}
>
<p className="text-wrap">{preset.title}</p>
</Button>
))}
</div>
)}
/>
<Input
form={form}
name="title"
label="Title"
placeholder="Optional"
className="mt-4"
/>
{!isLoggedIn ? (
<div className="mt-8">
<p className="text-lg">Account detail</p>
<Divider className="mt-2 mb-4" />
<div className="grid md:grid-cols-2 gap-x-2 gap-y-4">
<div className="order-1 md:order-2 flex flex-col items-center justify-center gap-3 py-4">
<p className="text-center">Already have an account?</p>
<Button
href={`/auth/login?return=${encodeURI(ctx.urlPathname)}`}
variant="outline"
>
Log in now
</Button>
</div>
<div className="order-2 md:order-1 space-y-3">
<Input
form={form}
name="user.name"
label="Full Name"
placeholder="John Doe"
/>
<Input
form={form}
name="user.email"
label="Email Address"
placeholder="john.doe@mail.com"
/>
<Input
form={form}
type="password"
name="user.password"
label="Password"
placeholder="P@ssw0rd123!"
/>
</div>
</div>
</div>
) : null}
<Divider className="my-6" />
<Button
type="submit"
size="lg"
isLoading={create.isPending}
className="w-full md:w-[160px]"
>
Create
</Button>
</form>
</Card>
</div>
);
};
export default GetStartedPage;

View File

@ -0,0 +1,11 @@
import { PageContext } from "vike/types";
import trpcServer from "~/server/api/trpc/trpc";
export const data = async (ctx: PageContext) => {
const trpc = await trpcServer(ctx);
const presets = await trpc.project.getTemplates();
return { presets };
};
export type Data = Awaited<ReturnType<typeof data>>;

View File

@ -5,7 +5,7 @@ import {
DialogTitle,
} from "../../../../components/ui/dialog";
import { UseDiscloseReturn } from "~/hooks/useDisclose";
import { Input } from "../../../../components/ui/input";
import { BaseInput } from "../../../../components/ui/input";
import { Button } from "../../../../components/ui/button";
import { useForm } from "~/hooks/useForm";
import { z } from "zod";
@ -76,7 +76,7 @@ const CreateFileDialog = ({ disclose, onSuccess }: Props) => {
<form onSubmit={onSubmit}>
<FormErrorMessage form={form} />
<Input
<BaseInput
placeholder={isDir ? "Directory Name" : "Filename"}
autoFocus
{...form.register("filename")}

View File

@ -3,8 +3,7 @@ import Panel from "~/components/ui/panel";
import { useCallback, useEffect, useRef } from "react";
import { useProjectContext } from "../context/project";
import { Button } from "~/components/ui/button";
import { FaEllipsisV, FaRedo } from "react-icons/fa";
import { Input } from "~/components/ui/input";
import { FaRedo } from "react-icons/fa";
import { previewStore } from "../stores/web-preview";
type WebPreviewProps = {

106
pnpm-lock.yaml generated
View File

@ -20,6 +20,9 @@ dependencies:
'@emmetio/codemirror6-plugin':
specifier: ^0.3.0
version: 0.3.0(@codemirror/autocomplete@6.12.0)(@codemirror/commands@6.3.3)(@codemirror/highlight@0.19.8)(@codemirror/history@0.19.2)(@codemirror/lang-css@6.2.1)(@codemirror/lang-html@6.4.8)(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.24.1)(@lezer/common@1.2.1)
'@faker-js/faker':
specifier: ^8.4.1
version: 8.4.1
'@hookform/resolvers':
specifier: ^3.3.4
version: 3.3.4(react-hook-form@7.50.1)
@ -83,6 +86,9 @@ dependencies:
cssnano:
specifier: ^6.0.3
version: 6.0.3(postcss@8.4.35)
dotenv:
specifier: ^16.4.5
version: 16.4.5
drizzle-orm:
specifier: ^0.29.3
version: 0.29.3(@types/react@18.2.57)(better-sqlite3@9.4.2)(react@18.2.0)
@ -92,6 +98,9 @@ dependencies:
express:
specifier: ^4.18.2
version: 4.18.2
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
lucide-react:
specifier: ^0.331.0
version: 0.331.0(react@18.2.0)
@ -145,12 +154,18 @@ devDependencies:
'@swc/cli':
specifier: ^0.3.9
version: 0.3.9(@swc/core@1.4.2)
'@types/bcrypt':
specifier: ^5.0.2
version: 5.0.2
'@types/cookie-parser':
specifier: ^1.4.6
version: 1.4.6
'@types/express':
specifier: ^4.17.21
version: 4.17.21
'@types/jsonwebtoken':
specifier: ^9.0.5
version: 9.0.5
'@types/node':
specifier: ^20.11.19
version: 20.11.19
@ -1165,6 +1180,11 @@ packages:
requiresBuild: true
optional: true
/@faker-js/faker@8.4.1:
resolution: {integrity: sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'}
dev: false
/@floating-ui/core@1.6.0:
resolution: {integrity: sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==}
dependencies:
@ -2202,6 +2222,12 @@ packages:
'@babel/types': 7.23.9
dev: true
/@types/bcrypt@5.0.2:
resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==}
dependencies:
'@types/node': 20.11.19
dev: true
/@types/body-parser@1.19.5:
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
dependencies:
@ -2259,6 +2285,12 @@ packages:
resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==}
dev: true
/@types/jsonwebtoken@9.0.5:
resolution: {integrity: sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==}
dependencies:
'@types/node': 20.11.19
dev: true
/@types/keyv@3.1.4:
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
dependencies:
@ -2674,6 +2706,10 @@ packages:
node-releases: 2.0.14
update-browserslist-db: 1.0.13(browserslist@4.23.0)
/buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
dev: false
/buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -3213,6 +3249,11 @@ packages:
domhandler: 5.0.3
dev: false
/dotenv@16.4.5:
resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==}
engines: {node: '>=12'}
dev: false
/dreamopt@0.8.0:
resolution: {integrity: sha512-vyJTp8+mC+G+5dfgsY+r3ckxlz+QMX40VjPQsZc5gxVAxLmi64TBoVkP54A/pRAXMXsbu2GMMBrZPxNv23waMg==}
engines: {node: '>=0.4.0'}
@ -3331,6 +3372,12 @@ packages:
/eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
/ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
dependencies:
safe-buffer: 5.2.1
dev: false
/ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: false
@ -4145,6 +4192,37 @@ packages:
hasBin: true
dev: true
/jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
dependencies:
jws: 3.2.2
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.6.0
dev: false
/jwa@1.4.1:
resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==}
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
dev: false
/jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
dependencies:
jwa: 1.4.1
safe-buffer: 5.2.1
dev: false
/keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
dependencies:
@ -4178,10 +4256,38 @@ packages:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
dev: false
/lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
dev: false
/lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
dev: false
/lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
dev: false
/lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
dev: false
/lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
dev: false
/lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
dev: false
/lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
dev: false
/lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
dev: false
/lodash.throttle@4.1.1:
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
dev: true

View File

@ -2,7 +2,7 @@ import type { Config } from "vike/types";
export default {
clientRouting: true,
passToClient: ["routeParams", "cookies"],
passToClient: ["routeParams", "cookies", "user"],
meta: {
title: {
env: { server: true, client: true },
@ -10,6 +10,9 @@ export default {
description: {
env: { server: true },
},
Layout: {
env: { server: true, client: true },
},
},
hydrationCanBeAborted: true,
} satisfies Config;

View File

@ -1,7 +1,7 @@
import ReactDOM from "react-dom/client";
import { getPageMetadata } from "./utils";
import type { OnRenderClientAsync } from "vike/types";
import Layout from "./layout";
import Layout from "./app";
let root: ReactDOM.Root;

View File

@ -2,7 +2,7 @@ import ReactDOMServer from "react-dom/server";
import { escapeInject, dangerouslySkipEscape } from "vike/server";
import type { OnRenderHtmlAsync } from "vike/types";
import { getPageMetadata } from "./utils";
import Layout from "./layout";
import App from "./app";
export const onRenderHtml: OnRenderHtmlAsync = async (
pageContext
@ -15,9 +15,9 @@ export const onRenderHtml: OnRenderHtmlAsync = async (
);
const page = ReactDOMServer.renderToString(
<Layout pageContext={pageContext}>
<App pageContext={pageContext}>
<Page />
</Layout>
</App>
);
// See https://vike.dev/head

View File

@ -5,19 +5,23 @@ import Providers from "./providers";
import "./globals.css";
import "nprogress/nprogress.css";
type LayoutProps = {
type AppProps = {
children: React.ReactNode;
pageContext: PageContext;
};
const Layout = ({ children, pageContext }: LayoutProps) => {
const App = ({ children, pageContext }: AppProps) => {
const { Layout } = pageContext.config;
return (
<React.StrictMode>
<PageContextProvider pageContext={pageContext}>
<Providers>{children}</Providers>
<Providers>
{Layout ? <Layout children={children} /> : children}
</Providers>
</PageContextProvider>
</React.StrictMode>
);
};
export default Layout;
export default App;

View File

@ -3,7 +3,7 @@
@tailwind utilities;
body {
@apply bg-slate-900 text-white;
@apply bg-background text-white;
}
.cm-theme {

View File

@ -1,19 +1,23 @@
import { ComponentProps } from "react";
import React from "react";
import { usePageContext } from "./context";
import { Slot } from "@radix-ui/react-slot";
const Link = (props: ComponentProps<"a">) => {
type LinkProps = {
href?: string;
className?: string;
asChild?: boolean;
children?: React.ReactNode;
};
const Link = ({ asChild, href, ...props }: LinkProps) => {
const pageContext = usePageContext();
const { urlPathname } = pageContext;
const { href } = props;
const Comp = asChild ? Slot : "a";
const isActive =
href === "/" ? urlPathname === href : urlPathname.startsWith(href!);
const className = [props.className, isActive && "is-active"]
.filter(Boolean)
.join(" ");
return <a {...props} className={className} />;
return <Comp data-active={isActive || undefined} href={href} {...props} />;
};
export default Link;

View File

@ -1,4 +1,5 @@
import type { Request } from "express";
import type { Request, Response } from "express";
import type { UserSchema } from "~/server/db/schema/user";
declare global {
namespace Vike {
@ -11,10 +12,14 @@ declare global {
config: {
title?: string;
description?: string;
Layout?: (props: { children: React.ReactNode }) => React.ReactElement;
};
abortReason?: string;
req: Request;
res: Response;
cookies: Record<string, string>;
user?: UserSchema | null;
}
}
}

View File

@ -1,7 +1,7 @@
import { Request } from "express";
import { CreateExpressContextOptions } from "@trpc/server/adapters/express";
export const createContext = async ({ req }: { req: Request }) => {
return {};
export const createContext = async (ctx: CreateExpressContextOptions) => {
return ctx;
};
export type Context = Awaited<ReturnType<typeof createContext>>;

View File

@ -5,7 +5,7 @@ import { PageContext } from "vike/types";
const trpcServer = async (ctx: PageContext) => {
const createCaller = createCallerFactory(appRouter);
const context = await createContext({ req: ctx.req });
const context = await createContext(ctx);
return createCaller(context);
};

View File

@ -17,4 +17,5 @@ export const user = sqliteTable("users", {
export const insertUserSchema = createInsertSchema(user);
export const selectUserSchema = createSelectSchema(user);
export type UserSchema = z.infer<typeof selectUserSchema>;
export type UserWithPassword = z.infer<typeof selectUserSchema>;
export type UserSchema = Omit<UserWithPassword, "password">;

View File

@ -1,8 +1,10 @@
import "dotenv/config";
import express from "express";
import { renderPage } from "vike/server";
import { IS_DEV } from "./lib/consts";
import cookieParser from "cookie-parser";
import api from "./api";
import { authMiddleware } from "./middlewares/auth";
async function createServer() {
const app = express();
@ -24,12 +26,13 @@ async function createServer() {
}
app.use(cookieParser());
app.use(authMiddleware);
app.use("/api", api);
app.use("*", async (req, res, next) => {
const url = req.originalUrl;
const pageContext = { req, cookies: req.cookies };
const pageContext = { req, res, cookies: req.cookies, user: req.user };
const ctx = await renderPage({ urlOriginal: url, ...pageContext });
const { httpResponse } = ctx;

26
server/lib/jwt.ts Normal file
View File

@ -0,0 +1,26 @@
import jwt from "jsonwebtoken";
const JWT_KEY =
process.env.JWT_KEY || "e2b185bd471dda7a14cb8d9cfb8d1c568ac27926";
export type TokenData = {
id: number;
};
export const createToken = (payload: TokenData) => {
return new Promise<string>((resolve, reject) => {
jwt.sign(payload, JWT_KEY, { expiresIn: "30d" }, function (err, token) {
if (err) return reject(err);
resolve(token as string);
});
});
};
export const verifyToken = (token: string) => {
return new Promise<TokenData>((resolve, reject) => {
jwt.verify(token, JWT_KEY, function (err, decoded) {
if (err) return reject(err);
resolve(decoded as TokenData);
});
});
};

View File

@ -0,0 +1,38 @@
import { NextFunction, Request, Response } from "express";
import { verifyToken } from "../lib/jwt";
import db from "../db";
import { and, eq, isNull } from "drizzle-orm";
import { user } from "../db/schema/user";
export const authMiddleware = async (
req: Request,
_res: Response,
next: NextFunction
) => {
try {
const token = req.cookies["auth-token"];
if (!token) {
throw new Error("No token in cookies.");
}
const data = await verifyToken(token);
if (!data?.id) {
throw new Error("Invalid token!");
}
const userData = await db.query.user.findFirst({
where: and(eq(user.id, data.id), isNull(user.deletedAt)),
columns: { password: false },
});
if (!userData) {
throw new Error("User is not found!");
}
// store userdata to every request
(req as any).user = userData;
} catch (err) {
//
}
next();
};

View File

@ -1,8 +1,10 @@
import { router } from "../api/trpc";
import auth from "./auth";
import project from "./project";
import file from "./file";
export const appRouter = router({
auth,
project,
file,
});

54
server/routers/auth.ts Normal file
View File

@ -0,0 +1,54 @@
import { z } from "zod";
import { procedure, router } from "../api/trpc";
import db from "../db";
import { TRPCError } from "@trpc/server";
import { and, eq, isNull } from "drizzle-orm";
import { user } from "../db/schema/user";
import { verifyPassword } from "../lib/crypto";
import { createToken } from "../lib/jwt";
const authRouter = router({
login: procedure
.input(
z.object({
email: z.string().email(),
password: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
const userData = await db.query.user.findFirst({
where: and(eq(user.email, input.email), isNull(user.deletedAt)),
});
if (!userData) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Email is not found!",
});
}
if (!(await verifyPassword(userData.password, input.password))) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invalid password!",
});
}
// set user token
const token = await createToken({ id: userData.id });
ctx.res.cookie("auth-token", token, { httpOnly: true });
return { ...userData, password: undefined };
}),
logout: procedure.mutation(({ ctx }) => {
ctx.res.cookie("auth-token", null, {
httpOnly: true,
expires: new Date(0),
});
return true;
}),
});
export default authRouter;

View File

@ -9,6 +9,11 @@ import {
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { uid } from "../lib/utils";
import { insertUserSchema, user } from "../db/schema/user";
import { faker } from "@faker-js/faker";
import { ucwords } from "~/lib/utils";
import { hashPassword } from "../lib/crypto";
import { createToken } from "../lib/jwt";
const projectRouter = router({
getAll: procedure.query(async () => {
@ -45,19 +50,59 @@ const projectRouter = router({
create: procedure
.input(
insertProjectSchema.pick({
title: true,
})
insertProjectSchema
.pick({
title: true,
})
.merge(
z.object({
forkFromId: z.number().optional(),
user: insertUserSchema
.pick({
name: true,
email: true,
password: true,
})
.optional(),
})
)
)
.mutation(async ({ input }) => {
const data: z.infer<typeof insertProjectSchema> = {
userId: 1,
title: input.title,
slug: uid(),
};
.mutation(async ({ ctx, input }) => {
const title =
input.title.length > 0 ? input.title : ucwords(faker.lorem.words(2));
let userId = 0;
const [result] = await db.insert(project).values(data).returning();
return result;
return db.transaction(async (tx) => {
if (input.user) {
const [usr] = await tx
.insert(user)
.values({
...input.user,
password: await hashPassword(input.user.password),
})
.returning();
userId = usr.id;
// set user token
const token = await createToken({ id: userId });
ctx.res.cookie("auth-token", token, { httpOnly: true });
}
if (!userId) {
throw new Error("Invalid userId!");
}
const data: z.infer<typeof insertProjectSchema> = {
userId,
title,
slug: uid(),
};
const [result] = await tx.insert(project).values(data).returning();
return result;
});
}),
update: procedure
@ -95,6 +140,23 @@ const projectRouter = router({
return result;
}),
getTemplates: procedure.query(() => {
return [
{
title: "Empty Project",
projectId: 0,
},
{
title: "Vanilla HTML+CSS+JS",
projectId: 1,
},
{
title: "React + Tailwindcss",
projectId: 2,
},
];
}),
});
export default projectRouter;

11
server/types.ts Normal file
View File

@ -0,0 +1,11 @@
import type { UserSchema } from "./db/schema/user";
declare global {
namespace Express {
interface Request {
user?: UserSchema | null;
}
}
}
export {};

View File

@ -17,6 +17,9 @@ const config = {
},
},
extend: {
colors: {
background: "#050505",
},
keyframes: {
"accordion-down": {
from: { height: "0" },