mirror of
https://github.com/khairul169/code-share.git
synced 2025-04-28 08:39:35 +07:00
feat: add homepage & auth
This commit is contained in:
parent
d579c1b7c5
commit
735fa9354b
3
.env
3
.env
@ -1 +1,2 @@
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
BASE_URL=http://localhost:3000
|
||||
JWT_KEY=test123
|
||||
|
@ -1 +1,2 @@
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
BASE_URL=http://localhost:3000
|
||||
JWT_KEY=test123
|
||||
|
35
components/containers/footer.tsx
Normal file
35
components/containers/footer.tsx
Normal 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;
|
27
components/containers/logo.tsx
Normal file
27
components/containers/logo.tsx
Normal 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;
|
85
components/containers/navbar.tsx
Normal file
85
components/containers/navbar.tsx
Normal 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;
|
18
components/layout/main-layout.tsx
Normal file
18
components/layout/main-layout.tsx
Normal 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;
|
@ -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
25
components/ui/card.tsx
Normal 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
10
components/ui/divider.tsx
Normal 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;
|
34
components/ui/form-field.tsx
Normal file
34
components/ui/form-field.tsx
Normal 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;
|
@ -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
7
hooks/useAuth.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { usePageContext } from "~/renderer/context";
|
||||
|
||||
export const useAuth = () => {
|
||||
const { user } = usePageContext();
|
||||
|
||||
return { user, isLoggedIn: user != null };
|
||||
};
|
@ -20,3 +20,7 @@ export const useForm = <T extends FieldValues>(
|
||||
|
||||
return form;
|
||||
};
|
||||
|
||||
export type useFormReturn<T extends FieldValues> = ReturnType<
|
||||
typeof useForm<T>
|
||||
>;
|
||||
|
@ -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(" ");
|
||||
};
|
||||
|
@ -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
3
pages/auth/+Layout.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import MainLayout from "~/components/layout/main-layout";
|
||||
|
||||
export { MainLayout as Layout };
|
61
pages/auth/login/+Page.tsx
Normal file
61
pages/auth/login/+Page.tsx
Normal 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
3
pages/index/+Layout.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import MainLayout from "~/components/layout/main-layout";
|
||||
|
||||
export { MainLayout as Layout };
|
@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
143
pages/index/get-started/+Page.tsx
Normal file
143
pages/index/get-started/+Page.tsx
Normal 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;
|
11
pages/index/get-started/+data.ts
Normal file
11
pages/index/get-started/+data.ts
Normal 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>>;
|
@ -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")}
|
||||
|
@ -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
106
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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
|
||||
|
@ -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;
|
@ -3,7 +3,7 @@
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-slate-900 text-white;
|
||||
@apply bg-background text-white;
|
||||
}
|
||||
|
||||
.cm-theme {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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>>;
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
|
@ -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">;
|
||||
|
@ -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
26
server/lib/jwt.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
};
|
38
server/middlewares/auth.ts
Normal file
38
server/middlewares/auth.ts
Normal 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();
|
||||
};
|
@ -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
54
server/routers/auth.ts
Normal 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;
|
@ -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
11
server/types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import type { UserSchema } from "./db/schema/user";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: UserSchema | null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
@ -17,6 +17,9 @@ const config = {
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
background: "#050505",
|
||||
},
|
||||
keyframes: {
|
||||
"accordion-down": {
|
||||
from: { height: "0" },
|
||||
|
Loading…
x
Reference in New Issue
Block a user