diff options
-rw-r--r-- | bun.lock | 9 | ||||
-rw-r--r-- | package.json | 3 | ||||
-rw-r--r-- | src/actions/login.ts | 58 | ||||
-rw-r--r-- | src/actions/test.ts | 24 | ||||
-rw-r--r-- | src/components/Login.tsx | 305 | ||||
-rw-r--r-- | src/components/Login2.tsx | 148 | ||||
-rw-r--r-- | src/components/actiontest.tsx | 27 | ||||
-rw-r--r-- | src/components/counter.tsx | 12 | ||||
-rw-r--r-- | src/components/ui/button.tsx | 58 | ||||
-rw-r--r-- | src/components/ui/card.tsx | 68 | ||||
-rw-r--r-- | src/components/ui/form.tsx | 165 | ||||
-rw-r--r-- | src/components/ui/input.tsx | 19 | ||||
-rw-r--r-- | src/components/ui/label.tsx | 24 | ||||
-rw-r--r-- | src/components/ui/select.tsx | 179 | ||||
-rw-r--r-- | src/components/ui/sonner.tsx | 23 | ||||
-rw-r--r-- | src/components/ui/spinner.tsx | 8 | ||||
-rw-r--r-- | src/lib/db/index.ts | 36 | ||||
-rw-r--r-- | src/lib/db/schema.sql | 172 | ||||
-rw-r--r-- | src/lib/server/cookie.ts | 47 | ||||
-rw-r--r-- | src/lib/server/cookiebridge.ts | 33 | ||||
-rw-r--r-- | src/lib/server/header.ts | 12 | ||||
-rw-r--r-- | src/lib/server/setcookie.ts | 25 | ||||
-rw-r--r-- | src/lib/utils.ts | 2 | ||||
-rw-r--r-- | src/pages.gen.ts | 3 | ||||
-rw-r--r-- | src/pages/api/auth.ts | 17 | ||||
-rw-r--r-- | src/pages/login.tsx | 28 | ||||
-rw-r--r-- | waku.config.ts | 23 |
27 files changed, 1508 insertions, 20 deletions
@@ -4,12 +4,14 @@ "": { "name": "waku", "dependencies": { + "@edge-runtime/cookies": "^6.0.0", "@hookform/resolvers": "^5.0.1", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cookie": "^1.0.2", "franc-all": "^7.2.0", "lucide-react": "^0.510.0", "next-themes": "^0.4.6", @@ -27,6 +29,7 @@ "devDependencies": { "@tailwindcss/postcss": "4.1.4", "@types/bun": "latest", + "@types/cookie": "^1.0.0", "@types/react": "19.1.2", "@types/react-dom": "19.1.2", "postcss": "8.5.3", @@ -77,6 +80,8 @@ "@babel/types": ["@babel/types@7.27.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q=="], + "@edge-runtime/cookies": ["@edge-runtime/cookies@6.0.0", "", {}, "sha512-VVO/8AwC2qVbygLb2IOkX1zWFx2yWIHzFv4D602CTnoRffd/+cdcXqpSydKaedFrk7a1dRYXbWwjzfV/gwZ2Gw=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], @@ -313,6 +318,8 @@ "@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="], + "@types/cookie": ["@types/cookie@1.0.0", "", { "dependencies": { "cookie": "*" } }, "sha512-mGFXbkDQJ6kAXByHS7QAggRXgols0mAdP4MuXgloGY1tXokvzaFFM4SMqWvf7AH0oafI7zlFJwoGWzmhDqTZ9w=="], + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], "@types/eslint-scope": ["@types/eslint-scope@3.7.7", "", { "dependencies": { "@types/eslint": "*", "@types/estree": "*" } }, "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg=="], @@ -405,6 +412,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], diff --git a/package.json b/package.json index 31f7965..8ee4fbb 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,14 @@ "start": "bunx --bun waku start" }, "dependencies": { + "@edge-runtime/cookies": "^6.0.0", "@hookform/resolvers": "^5.0.1", "@radix-ui/react-label": "^2.1.6", "@radix-ui/react-select": "^2.2.4", "@radix-ui/react-slot": "^1.2.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cookie": "^1.0.2", "franc-all": "^7.2.0", "lucide-react": "^0.510.0", "next-themes": "^0.4.6", @@ -32,6 +34,7 @@ "devDependencies": { "@tailwindcss/postcss": "4.1.4", "@types/bun": "latest", + "@types/cookie": "^1.0.0", "@types/react": "19.1.2", "@types/react-dom": "19.1.2", "postcss": "8.5.3", diff --git a/src/actions/login.ts b/src/actions/login.ts new file mode 100644 index 0000000..3d83b55 --- /dev/null +++ b/src/actions/login.ts @@ -0,0 +1,58 @@ +"use server"; +import { AsyncRes } from "@/lib/types"; +import db from "../lib/db"; +import cookie from "cookie"; +import { cookies } from "../lib/server/cookiebridge"; +import { unstable_redirect, unstable_rerenderRoute } from "waku/router/server"; + +export type FormState = { + name?: string; + password?: string; + error?: string; + success?: boolean; +}; +async function call(formData: FormData, register: boolean) { + const nam = formData.get("username"); + const creds = formData.get("password"); + const password = await Bun.password.hash(creds!.toString()); + const name = nam!.toString(); + const res = register + ? db.addUser(name, password) + : await db.loginUser(name, creds!.toString()); + return res; +} + +export async function postRegister( + prevState: FormState, + formData: FormData, +): Promise<FormState> { + const res = await call(formData, true); + console.log("reg res", res); + if ("error" in res) return { error: "Something went wrong" }; + else { + return { success: true }; + } +} + +export async function postLogin( + prevState: FormState, + formData: FormData, +): Promise<FormState> { + const res = await call(formData, false); + if ("error" in res) return { error: res.error }; + else { + setCookie(res.ok as number); + return { success: true }; + } +} +async function setCookie(userId: number) { + const COOKIE_EXPIRY = Date.now() + 1000 * 60 * 60 * 24 * 30; + const COOKIE_OPTS = { expires: new Date(COOKIE_EXPIRY) }; + + const { randomBytes } = await import("node:crypto"); + const cokistring = randomBytes(32).toBase64(); + const res = db.setCookie(cokistring, userId, COOKIE_EXPIRY); + const { setCookie } = cookies(); + setCookie("sorlang", cokistring, COOKIE_OPTS); + // unstable_redirect("/"); +} diff --git a/src/actions/test.ts b/src/actions/test.ts new file mode 100644 index 0000000..99d42a0 --- /dev/null +++ b/src/actions/test.ts @@ -0,0 +1,24 @@ +"use server"; +import db from "../lib/db"; + +export async function testFn(lol: any) { + console.log({ lol }); + return "lmao"; +} +export async function testLogin(state: number, formdata: FormData) { + return state + 9; +} +// export async function testLogin({ +// name, +// creds, +// }: { +// name: string; +// creds: string; +// }) { +// const res1 = db.loginUser(name, creds); +// console.log({ res1 }); +// return res1; +// // const res = db.addUser(name, creds); +// // console.log({ res }); +// // return res; +// } diff --git a/src/components/Login.tsx b/src/components/Login.tsx new file mode 100644 index 0000000..5747d06 --- /dev/null +++ b/src/components/Login.tsx @@ -0,0 +1,305 @@ +"use client"; + +import { postLogin, postRegister } from "@/actions/login"; +import { cn } from "@/lib/utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardHeader, + CardDescription, + CardContent, + CardFooter, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { useActionState } from "react"; + +const FormSchema = z.object({ + username: z.string().min(2, { + message: "Username must be at least 2 characters.", + }), + password: z.string().min(2, { + message: "Password must be at least 2 characters.", + }), +}); +export default function AuthScreen() { + return <OOldform />; +} + +function OOldform() { + const [state, formAction, isPending] = useActionState< + { msg: string }, + FormData + >(postLogin, { msg: "init" }); + return ( + <form action={formAction}> + {state.msg} + <label> + Username + <input type="text" placeholder="shadcn" name="username" /> + </label> + <label className="flex justify-between"> + <span>Password</span> + <a + href="#" + className="ml-auto text-sm underline-offset-4 hover:underline" + > + Forgot your password? + </a> + <input type="password" placeholder="..." name="password" /> + </label> + <button type="submit" className="w-full"> + Login + </button> + <div className="text-center text-sm"> + Don't have an account?{" "} + <a href="#" className="underline underline-offset-4"> + Sign up + </a> + </div> + </form> + ); +} +function Oldform() { + const [state, formAction, isPending] = useActionState< + { msg: string }, + FormData + >(postLogin, { msg: "init" }); + return ( + <form action={formAction}> + {state.msg} + <div className="flex flex-col gap-6"> + <Card> + <CardHeader className="text-center"> + <CardTitle className="text-xl">Welcome back</CardTitle> + <CardDescription>Login to Sorlang</CardDescription> + <p>{state.msg}</p> + </CardHeader> + <CardContent> + <div className="grid gap-6"> + <div className="grid gap-6"> + <Label> + Username + <Input placeholder="shadcn" name="username" /> + </Label> + <Label className="flex justify-between"> + <span>Password</span> + <a + href="#" + className="ml-auto text-sm underline-offset-4 hover:underline" + > + Forgot your password? + </a> + <Input type="password" placeholder="..." name="password" /> + </Label> + </div> + <Button type="submit" className="w-full"> + Login + </Button> + <div className="text-center text-sm"> + Don't have an account?{" "} + <a href="#" className="underline underline-offset-4"> + Sign up + </a> + </div> + </div> + </CardContent> + </Card> + <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-primary "> + By clicking continue, you agree to our{" "} + <a href="#">Terms of Service</a> and <a href="#">Privacy Policy</a>. + </div> + </div> + </form> + ); +} + +function Register({ setRegister }: { setRegister: (b: boolean) => void }) { + const form = useForm<z.infer<typeof FormSchema>>({ + resolver: zodResolver(FormSchema), + defaultValues: { + username: "", + password: "", + }, + }); + + async function onSubmit(data: z.infer<typeof FormSchema>) { + const body = JSON.stringify({ + name: data.username, + creds: data.password, + }); + const opts = { + method: "POST", + headers: { "Content-type": "application/json" }, + body, + }; + const res = await fetch("/api/db/user/new", opts); + const j = await res.json(); + console.log(j); + if ("error" in j) toast(`Error: ${j.error}`); + else { + toast("Register successful"); + } + } + + return ( + <Card> + <CardTitle>Register</CardTitle> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="w-2/3 space-y-6" + > + <CardContent> + <FormField + control={form.control} + name="username" + render={({ field }) => ( + <FormItem> + <FormLabel>Username</FormLabel> + <FormControl> + <Input placeholder="shadcn" {...field} /> + </FormControl> + <FormDescription> + This is your public display name. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input type="password" placeholder="..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </CardContent> + <CardFooter> + <Button type="submit">Submit</Button> + </CardFooter> + </form> + </Form> + </Card> + ); +} + +function LoginForm({ + className, + ...props +}: React.ComponentPropsWithoutRef<"div">) { + const form = useForm<z.infer<typeof FormSchema>>({ + resolver: zodResolver(FormSchema), + defaultValues: { + username: "", + password: "", + }, + }); + async function onSubmit(data: z.infer<typeof FormSchema>) { + console.log("oh hai"); + const body = JSON.stringify({ + name: data.username, + creds: data.password, + }); + const opts = { + method: "POST", + headers: { "Content-type": "application/json" }, + body, + }; + const res = await fetch("/api/login", opts); + const j = await res.json(); + console.log({ j }); + if ("error" in j) toast(`Error! ${j.error}`); + else { + toast("Login successful"); + } + } + return ( + <div className={cn("flex flex-col gap-6", className)} {...props}> + <Card> + <CardHeader className="text-center"> + <CardTitle className="text-xl">Welcome back</CardTitle> + <CardDescription>Login to Sorlang</CardDescription> + </CardHeader> + <CardContent> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="grid gap-6"> + <div className="grid gap-6"> + <FormField + control={form.control} + name="username" + render={({ field }) => ( + <FormItem> + <FormLabel>Username</FormLabel> + <FormControl> + <Input placeholder="shadcn" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="password" + render={({ field }) => ( + <FormItem> + <FormLabel className="flex justify-between"> + <span>Password</span> + <a + href="#" + className="ml-auto text-sm underline-offset-4 hover:underline" + > + Forgot your password? + </a> + </FormLabel> + <FormControl> + <Input type="password" placeholder="..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + <Button type="submit" className="w-full"> + Login + </Button> + <div className="text-center text-sm"> + Don't have an account?{" "} + <a href="#" className="underline underline-offset-4"> + Sign up + </a> + </div> + </div> + </form> + </Form> + </CardContent> + </Card> + <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-primary "> + By clicking continue, you agree to our <a href="#">Terms of Service</a>{" "} + and <a href="#">Privacy Policy</a>. + </div> + </div> + ); +} diff --git a/src/components/Login2.tsx b/src/components/Login2.tsx new file mode 100644 index 0000000..2164b1a --- /dev/null +++ b/src/components/Login2.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { FormState, postLogin, postRegister } from "@/actions/login"; + +import { useActionState, useEffect, useState } from "react"; +import { + Card, + CardHeader, + CardDescription, + CardContent, + CardFooter, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useRouter } from "waku"; + +export default function AuthScreen() { + const [isReg, setReg] = useState(false); + return <OOldform isReg={isReg} toggle={() => setReg((b) => !b)} />; +} + +function OOldform({ isReg, toggle }: { isReg: boolean; toggle: () => void }) { + const regstrings = { + title: "Welcome to Sorlang", + desc: "Sign up", + button: "Sign up", + toggle: "Have an account?", + toggle2: "Login", + }; + const logstrings = { + title: "Welcome back", + desc: "Login to Sorlang", + button: "Login", + toggle: "Don't have an account?", + toggle2: "Sign up", + }; + const [strings, setStrings] = useState(logstrings); + + useEffect(() => { + if (isReg) setStrings(regstrings); + else setStrings(logstrings); + }, [isReg]); + + const [state, formAction, isPending] = useActionState<FormState, FormData>( + isReg ? postRegister : postLogin, + {}, + "/login", + ); + // const nav = useRouter(); + // useEffect(() => { + // if (state.success) nav.replace("/"); + // }, [state]); + return ( + <form action={formAction}> + <div className="flex flex-col gap-6"> + <Card> + <CardHeader className="text-center"> + <CardTitle className="text-xl">{strings.title}</CardTitle> + <CardDescription>{strings.desc}</CardDescription> + </CardHeader> + <CardContent> + <div className="grid gap-6"> + <div className="grid gap-6"> + <Label> + Username + <Input placeholder="shadcn" name="username" /> + {state.name && <p>{state.name}</p>} + </Label> + <Label> + {isReg ? ( + "Password" + ) : ( + <Label className="flex justify-between"> + <span>Password</span> + <a + href="#" + className="ml-auto text-sm underline-offset-4 hover:underline" + > + Forgot your password? + </a> + </Label> + )} + <Input type="password" placeholder="..." name="password" /> + {state.password && <p>{state.password}</p>} + </Label> + </div> + <Button type="submit" className="w-full"> + {strings.button} + </Button> + <div className="text-center text-sm"> + {strings.toggle} + <a + onClick={toggle} + href="#" + className="underline underline-offset-4" + > + {strings.toggle2} + </a> + </div> + </div> + {state.error && <p className="text-red">{state.error}</p>} + </CardContent> + </Card> + <div className="text-balance text-center text-xs text-muted-foreground [&_a]:underline [&_a]:underline-offset-4 [&_a]:hover:text-primary "> + By clicking continue, you agree to our{" "} + <a href="#">Terms of Service</a> and <a href="#">Privacy Policy</a>. + </div> + </div> + </form> + // <form action={formAction} aria-disabled={isPending}> + // {state.msg} + // <label> + // Username + // <input type="text" placeholder="shadcn" name="username" /> + // </label> + // <label className="flex justify-between"> + // <span>Password</span> + // <a + // href="#" + // className="ml-auto text-sm underline-offset-4 hover:underline" + // > + // Forgot your password? + // </a> + // <input type="password" placeholder="..." name="password" /> + // </label> + // <button type="submit" className="w-full"> + // Login + // </button> + // <div className="text-center text-sm"> + // Don't have an account?{" "} + // <a href="#" className="underline underline-offset-4"> + // Sign up + // </a> + // </div> + // </form> + ); +} diff --git a/src/components/actiontest.tsx b/src/components/actiontest.tsx new file mode 100644 index 0000000..863f289 --- /dev/null +++ b/src/components/actiontest.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { testFn, testLogin } from "@/actions/test"; +import { useActionState, useState } from "react"; + +export default function TestForm() { + const [state, formAction, isPending] = useActionState<number, FormData>( + testLogin, + 0, + ); + return ( + <form action={formAction}> + <p>State: {state}</p> + <label> + Username + <input type="text" placeholder="shadcn" name="username" /> + </label> + <label className="flex justify-between"> + <span>Password</span> + <input type="password" placeholder="..." name="password" /> + </label> + <button type="submit" className="w-full"> + Login + </button> + </form> + ); +} diff --git a/src/components/counter.tsx b/src/components/counter.tsx index 0e540b8..2122b75 100644 --- a/src/components/counter.tsx +++ b/src/components/counter.tsx @@ -1,11 +1,17 @@ -'use client'; +"use client"; -import { useState } from 'react'; +import { testFn, testLogin } from "@/actions/test"; +import { useState } from "react"; export const Counter = () => { const [count, setCount] = useState(0); - const handleIncrement = () => setCount((c) => c + 1); + const handleIncrement = async () => { + setCount((c) => c + 1); + const res = await testFn("rofl"); + console.log({ res }); + const oof = testLogin({ name: "yago", creds: "xd" }); + }; return ( <section className="border-blue-400 -mx-4 mt-4 rounded-sm border border-dashed p-4"> diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..61875fc --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,58 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 focus-visible:ring-4 focus-visible:outline-1 aria-invalid:focus-visible:ring-0", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90", + outline: + "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps<typeof buttonVariants> & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + <Comp + data-slot="button" + className={cn(buttonVariants({ variant, size, className }))} + {...props} + /> + ) +} + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..3ff199b --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,68 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card" + className={cn( + "bg-card text-card-foreground rounded-xl border shadow-sm", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-header" + className={cn("flex flex-col gap-1.5 p-6", className)} + {...props} + /> + ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-title" + className={cn("leading-none font-semibold tracking-tight", className)} + {...props} + /> + ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-description" + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-content" + className={cn("p-6 pt-0", className)} + {...props} + /> + ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( + <div + data-slot="card-footer" + className={cn("flex items-center p-6 pt-0", className)} + {...props} + /> + ) +} + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..8a83b32 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,165 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, + useFormState, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +> = { + name: TName +} + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div + data-slot="form-item" + className={cn("grid gap-2", className)} + {...props} + /> + </FormItemContext.Provider> + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + const { error, formItemId } = useFormField() + + return ( + <Label + data-slot="form-label" + data-error={!!error} + className={cn("data-[error=true]:text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ) +} + +function FormControl({ ...props }: React.ComponentProps<typeof Slot>) { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + data-slot="form-control" + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ) +} + +function FormDescription({ className, ...props }: React.ComponentProps<"p">) { + const { formDescriptionId } = useFormField() + + return ( + <p + data-slot="form-description" + id={formDescriptionId} + className={cn("text-muted-foreground text-sm", className)} + {...props} + /> + ) +} + +function FormMessage({ className, ...props }: React.ComponentProps<"p">) { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message) : props.children + + if (!body) { + return null + } + + return ( + <p + data-slot="form-message" + id={formMessageId} + className={cn("text-destructive text-sm font-medium", className)} + {...props} + > + {body} + </p> + ) +} + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..596f806 --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + <input + type={type} + data-slot="input" + className={cn( + "border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground aria-invalid:outline-destructive/60 aria-invalid:ring-destructive/20 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/50 ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 aria-invalid:outline-destructive/60 dark:aria-invalid:outline-destructive dark:aria-invalid:ring-destructive/40 aria-invalid:ring-destructive/20 aria-invalid:border-destructive/60 dark:aria-invalid:border-destructive flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-4 focus-visible:outline-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-[3px] aria-invalid:focus-visible:outline-none md:text-sm dark:aria-invalid:focus-visible:ring-4", + className + )} + {...props} + /> + ) +} + +export { Input } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..f948bc3 --- /dev/null +++ b/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + return ( + <LabelPrimitive.Root + data-slot="label" + className={cn( + "text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", + className + )} + {...props} + /> + ) +} + +export { Label } diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx new file mode 100644 index 0000000..b624a5b --- /dev/null +++ b/src/components/ui/select.tsx @@ -0,0 +1,179 @@ +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps<typeof SelectPrimitive.Root>) { + return <SelectPrimitive.Root data-slot="select" {...props} /> +} + +function SelectGroup({ + ...props +}: React.ComponentProps<typeof SelectPrimitive.Group>) { + return <SelectPrimitive.Group data-slot="select-group" {...props} /> +} + +function SelectValue({ + ...props +}: React.ComponentProps<typeof SelectPrimitive.Value>) { + return <SelectPrimitive.Value data-slot="select-value" {...props} /> +} + +function SelectTrigger({ + className, + children, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Trigger>) { + return ( + <SelectPrimitive.Trigger + data-slot="select-trigger" + className={cn( + "border-input data-[placeholder]:text-muted-foreground aria-invalid:border-destructive ring-ring/10 dark:ring-ring/20 dark:outline-ring/40 outline-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex h-9 w-full items-center justify-between rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-4 focus-visible:outline-1 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:focus-visible:ring-0 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>span]:line-clamp-1", + className + )} + {...props} + > + {children} + <SelectPrimitive.Icon asChild> + <ChevronDownIcon className="size-4 opacity-50" /> + </SelectPrimitive.Icon> + </SelectPrimitive.Trigger> + ) +} + +function SelectContent({ + className, + children, + position = "popper", + ...props +}: React.ComponentProps<typeof SelectPrimitive.Content>) { + return ( + <SelectPrimitive.Portal> + <SelectPrimitive.Content + data-slot="select-content" + className={cn( + "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md", + position === "popper" && + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + className + )} + position={position} + {...props} + > + <SelectScrollUpButton /> + <SelectPrimitive.Viewport + className={cn( + "p-1", + position === "popper" && + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" + )} + > + {children} + </SelectPrimitive.Viewport> + <SelectScrollDownButton /> + </SelectPrimitive.Content> + </SelectPrimitive.Portal> + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Label>) { + return ( + <SelectPrimitive.Label + data-slot="select-label" + className={cn("px-2 py-1.5 text-sm font-semibold", className)} + {...props} + /> + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Item>) { + return ( + <SelectPrimitive.Item + data-slot="select-item" + className={cn( + "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", + className + )} + {...props} + > + <span className="absolute right-2 flex size-3.5 items-center justify-center"> + <SelectPrimitive.ItemIndicator> + <CheckIcon className="size-4" /> + </SelectPrimitive.ItemIndicator> + </span> + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> + </SelectPrimitive.Item> + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.Separator>) { + return ( + <SelectPrimitive.Separator + data-slot="select-separator" + className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} + {...props} + /> + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { + return ( + <SelectPrimitive.ScrollUpButton + data-slot="select-scroll-up-button" + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronUpIcon className="size-4" /> + </SelectPrimitive.ScrollUpButton> + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { + return ( + <SelectPrimitive.ScrollDownButton + data-slot="select-scroll-down-button" + className={cn( + "flex cursor-default items-center justify-center py-1", + className + )} + {...props} + > + <ChevronDownIcon className="size-4" /> + </SelectPrimitive.ScrollDownButton> + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..cd62aff --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,23 @@ +import { useTheme } from "next-themes" +import { Toaster as Sonner, ToasterProps } from "sonner" + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + <Sonner + theme={theme as ToasterProps["theme"]} + className="toaster group" + style={ + { + "--normal-bg": "var(--popover)", + "--normal-text": "var(--popover-foreground)", + "--normal-border": "var(--border)", + } as React.CSSProperties + } + {...props} + /> + ) +} + +export { Toaster } diff --git a/src/components/ui/spinner.tsx b/src/components/ui/spinner.tsx new file mode 100644 index 0000000..d1511ad --- /dev/null +++ b/src/components/ui/spinner.tsx @@ -0,0 +1,8 @@ +export const Spinner = ({ className }: { className?: string }) => ( + <div + className={ + "w-7 h-7 border-[3px] border-transparent border-t-primary rounded-full animate-spin" + + ` ${className}` + } + /> +); diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 9897af8..3d46fd9 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -1,7 +1,7 @@ import Database from "bun:sqlite"; -import { getDBOffset, wordFactorial } from "@/lib/utils"; -import type { AddSense, AddWord, State } from "@/lib/types"; -import { DEFAULT_SRS } from "@/lib/services/srs"; +import { getDBOffset, wordFactorial } from "../utils"; +import type { AddSense, AddWord, State } from "../types"; +import { DEFAULT_SRS } from "../services/srs"; const PAGE_SIZE = 100; @@ -32,11 +32,17 @@ class DatabaseHandler { fetchCookie(coki: string) { const query = this.db.query( ` - SELECT * FROM cookies - WHERE cookie = ? + SELECT u.id, u.name, c.expiry FROM cookies as c + JOIN users as u ON u.id = c.user + WHERE c.cookie = ? `, ); - const res = query.get(coki); + const res = query.get(coki) as { + id: number; + name: string; + expiry: number; + }; + console.log("cokifetch", { coki, res }); return res; } setCookie(coki: string, user: number, expiry: number) { @@ -577,14 +583,22 @@ class DatabaseHandler { return { error: `${e}` }; } } - loginUser(name: string, creds: string) { + async loginUser(name: string, creds: string) { const query = this.db.query(` - SELECT id FROM users - WHERE name = ? AND creds = ? + SELECT * FROM users + WHERE name = ? `); - const row = query.get(name, creds) as { id: number } | null; + const row = query.get(name) as { + id: number; + name: string; + creds: string; + } | null; if (!row) return { error: "not found" }; - else return { ok: row.id }; + else { + const ok = await Bun.password.verify(creds, row.creds); + if (!ok) return { error: "Wrong password" }; + else return { ok: row.id }; + } } addCat(category: string) { const queryString = ` diff --git a/src/lib/db/schema.sql b/src/lib/db/schema.sql new file mode 100644 index 0000000..1b678c5 --- /dev/null +++ b/src/lib/db/schema.sql @@ -0,0 +1,172 @@ +-- Enable foreign key support +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; +PRAGMA cache_size = -2000; +PRAGMA mmap_size = 30000000000; + + +CREATE TABLE IF NOT EXISTS cookies( + user INTEGER NOT NULL, + cookie TEXT NOT NULL, + expiry INTEGER NOT NULL, + PRIMARY KEY(user, cookie), + FOREIGN KEY (user) REFERENCES users(id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS languages ( + code TEXT PRIMARY KEY, + name TEXT NOT NULL, + native_name TEXT +); +-- type is "word" or other +-- + +-- an orthographic (and likely phonetic too entity) +-- lang is 2char code ISO 6393-1 I gues +CREATE TABLE IF NOT EXISTS expressions( + id INTEGER PRIMARY KEY AUTOINCREMENT, + spelling TEXT NOT NULL, + lang TEXT NOT NULL, + frequency INTEGER, + type TEXT NOT NULL, + syllables INTEGER, + ipa JSONB, + prosody JSONB, + confidence INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (lang) REFERENCES languages(code), + CONSTRAINT spell_unique UNIQUE (spelling, lang) +); +CREATE INDEX IF NOT EXISTS idx_words_spelling ON expressions(spelling); +CREATE INDEX IF NOT EXISTS idx_words_type ON expressions(type); +CREATE INDEX IF NOT EXISTS idx_words_lang ON expressions(lang); +-- a semantic entity +CREATE TABLE IF NOT EXISTS senses( + id INTEGER PRIMARY KEY AUTOINCREMENT, + parent_id INTEGER NOT NULL, + spelling TEXT NOT NULL, + pos TEXT, + etymology TEXT, + ipa JSONB, + prosody JSONB, + senses JSONB, + forms JSONB, + related JSONB, + confidence INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (parent_id) REFERENCES expressions(id) +); +CREATE INDEX IF NOT EXISTS idx_words_pos ON senses(pos); +CREATE INDEX IF NOT EXISTS idx_senses_parent ON senses(parent_id); + + + +-- Categories table (for noun and verb categories) +CREATE TABLE IF NOT EXISTS categories ( + name TEXT PRIMARY KEY +); + +-- Word Categories junction table +CREATE TABLE IF NOT EXISTS word_categories ( + word_id INTEGER NOT NULL, + category INTEGER NOT NULL, + PRIMARY KEY (word_id, category), + FOREIGN KEY (word_id) REFERENCES expressions(id), + FOREIGN KEY (category) REFERENCES categories(name) +); +CREATE INDEX IF NOT EXISTS idx_word_categories_category_id ON word_categories(category); + + +-- Progress +CREATE TABLE IF NOT EXISTS users( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + creds TEXT NOT NULL, + CONSTRAINT name_unique UNIQUE (name) +); +CREATE TABLE IF NOT EXISTS user_progress ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + card_id INTEGER NOT NULL, + repetition_count INTEGER DEFAULT 0, + ease_factor REAL DEFAULT 2.5, + interval INTEGER DEFAULT 1, + next_review_date INTEGER, + last_reviewed INTEGER, + is_mastered BOOLEAN DEFAULT FALSE, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (card_id) REFERENCES cards(id), + CONSTRAINT progress_unique UNIQUE (user_id, card_id) +); +-- CREATE TABLE IF NOT EXISTS user_progress ( +-- id INTEGER PRIMARY KEY AUTOINCREMENT, +-- user_id INTEGER NOT NULL, +-- card_id INTEGER NOT NULL, +-- repetition_count INTEGER DEFAULT 0, +-- ease_factor REAL DEFAULT 2.5, +-- interval INTEGER DEFAULT 1, +-- next_review_date DATETIME, +-- last_reviewed DATETIME, +-- is_mastered BOOLEAN DEFAULT FALSE, +-- CONSTRAINT progress_unique UNIQUE (user_id, card_id) +-- FOREIGN KEY (user_id) REFERENCES users(id), +-- FOREIGN KEY (card_id) REFERENCES cards(id) +-- ); +-- Lessons +CREATE TABLE IF NOT EXISTS lessons( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + description TEXT, + lang TEXT, + FOREIGN KEY (lang) REFERENCES languages(code) +); +CREATE TABLE IF NOT EXISTS cards( + id INTEGER PRIMARY KEY AUTOINCREMENT, + text TEXT NOT NULL, + note TEXT +); +CREATE TABLE IF NOT EXISTS cards_expressions( + expression_id INTEGER NOT NULL, + card_id INTEGER NOT NULL, + PRIMARY KEY (card_id, expression_id), + FOREIGN KEY (card_id) REFERENCES cards(id), + FOREIGN KEY (expression_id) REFERENCES expressions(id) +); +CREATE TABLE IF NOT EXISTS cards_lessons( + lesson_id INTEGER, + card_id INTEGER NOT NULL, + PRIMARY KEY (card_id, lesson_id), + FOREIGN KEY (card_id) REFERENCES cards(id), + FOREIGN KEY (lesson_id) REFERENCES lessons(id) +); + +CREATE TABLE IF NOT EXISTS attempts( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + card_id INTEGER NOT NULL, + good INTEGER NOT NULL, -- 0 or 1 + FOREIGN KEY (user_id) REFERENCES users(id) + FOREIGN KEY (card_id) REFERENCES cards(id) +); + +-- Index to query attempts on a specific card +CREATE INDEX IF NOT EXISTS idx_attempts_card ON attempts(card_id); + +-- Index to query attempts for a specific user +CREATE INDEX IF NOT EXISTS idx_attempts_user ON attempts(user_id); + +-- (Optional) Index to query attempts by user and resource (useful if you often query by both) +CREATE INDEX IF NOT EXISTS idx_attempts_user_resource ON attempts(user_id, card_id); +CREATE INDEX IF NOT EXISTS idx_cards_resources +ON cards_expressions(expression_id, card_id); + +-- CREATE TRIGGER IF NOT EXISTS populate_cards_resources +-- AFTER INSERT ON cards +-- FOR EACH ROW +-- BEGIN +-- -- Insert matching words into cards_resources +-- INSERT INTO cards_expressions(card_id, expression_id) +-- SELECT NEW.id, w.id +-- FROM expressions w +-- WHERE NEW.text LIKE '%' || w.spelling || '%'; +-- END; +-- diff --git a/src/lib/server/cookie.ts b/src/lib/server/cookie.ts new file mode 100644 index 0000000..fadac9d --- /dev/null +++ b/src/lib/server/cookie.ts @@ -0,0 +1,47 @@ +import { + getCookie, + getSignedCookie, + setCookie, + setSignedCookie, + deleteCookie, +} from "hono/cookie"; +import cookie from "cookie"; +// console.log("db module path:", "@/lib/db"); +// console.log( +// "globalThis.__WAKU_MIDDLEWARE_CONTEXT_STORAGE__:", +// globalThis.__WAKU_MIDDLEWARE_CONTEXT_STORAGE__, +// ); +import db from "../db"; + +import type { Middleware } from "waku/config"; + +// XXX we would probably like to extend config. + +const cookieMiddleware: Middleware = () => { + console.log("cookieMiddleware executed"); + return async (ctx, next) => { + if (ctx.req.url.pathname === "/login") return await next(); + const cookies = cookie.parse(ctx.req.headers.cookie || ""); + console.log({ cookies }); + const coki = cookies.sorlang; + if (!coki) { + ctx.res.status = 301; + ctx.res.headers = { + Location: "/login", + }; + } + if (coki) { + const userRow = db.fetchCookie(coki); + if (userRow) ctx.data.user = { id: userRow.id, name: userRow.name }; + else { + ctx.res.status = 301; + ctx.res.headers = { + Location: "/login", + }; + } + } + await next(); + }; +}; + +export default cookieMiddleware; diff --git a/src/lib/server/cookiebridge.ts b/src/lib/server/cookiebridge.ts new file mode 100644 index 0000000..ca4bd44 --- /dev/null +++ b/src/lib/server/cookiebridge.ts @@ -0,0 +1,33 @@ +import { getContextData } from "waku/middleware/context"; +import { + RequestCookies, + ResponseCookies, + type ResponseCookie, +} from "@edge-runtime/cookies"; +import { mergeSetCookies } from "./setcookie"; + +const cookies = () => { + const ctx = getContextData() as { + headers: Record<string, string | string[]>; + cookies?: ResponseCookie[]; + }; + const headerObj = ctx.headers || {}; + headerObj["set-cookie"] = mergeSetCookies( + headerObj["set-cookie"] || [], + (ctx.cookies || []) as ResponseCookie[], + ); + const headers = new Headers(headerObj as Record<string, string>); + const reqCookies = new RequestCookies(headers); + const resCookies = new ResponseCookies(headers); + + const getCookie: ResponseCookies["get"] = (...args) => + resCookies.get(...args) || reqCookies.get(...args); + const setCookie: ResponseCookies["set"] = (...args) => { + const updated = resCookies.set(...args); + ctx.cookies = updated.getAll(); + return updated; + }; + return { getCookie, setCookie }; +}; + +export { cookies }; diff --git a/src/lib/server/header.ts b/src/lib/server/header.ts new file mode 100644 index 0000000..33f8792 --- /dev/null +++ b/src/lib/server/header.ts @@ -0,0 +1,12 @@ +// https://github.com/t6adev/waku-auth-middleware-demo/blob/a476ecb3d5caf0c7731a34314450400d4dcc2dac/src/middleware/validateRouting.ts + +import type { Middleware } from "waku/config"; + +const headersMiddleware: Middleware = () => { + return async (ctx, next) => { + ctx.data.headers = ctx.req.headers; + await next(); + }; +}; + +export default headersMiddleware; diff --git a/src/lib/server/setcookie.ts b/src/lib/server/setcookie.ts new file mode 100644 index 0000000..f64b380 --- /dev/null +++ b/src/lib/server/setcookie.ts @@ -0,0 +1,25 @@ +import type { Middleware } from "waku/config"; +import { type ResponseCookie, stringifyCookie } from "@edge-runtime/cookies"; + +export const mergeSetCookies = ( + resSetCookies: string | string[], + cookiesInContext: ResponseCookie[], +) => { + if (typeof resSetCookies === "string") { + resSetCookies = [resSetCookies]; + } + return [...resSetCookies, ...cookiesInContext.map(stringifyCookie)]; +}; + +const setCookieMiddleware: Middleware = () => { + return async (ctx, next) => { + await next(); + ctx.res.headers ||= {}; + ctx.res.headers["set-cookie"] = mergeSetCookies( + ctx.res.headers["set-cookie"] || [], + (ctx.data.cookies || []) as ResponseCookie[], + ); + }; +}; + +export default setCookieMiddleware; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 113c874..d3fdf9c 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,6 +1,6 @@ import { type ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; -import type { Result } from "@/lib/types"; +import type { Result } from "./types"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); diff --git a/src/pages.gen.ts b/src/pages.gen.ts index 6f44dd4..94e0a4a 100644 --- a/src/pages.gen.ts +++ b/src/pages.gen.ts @@ -4,6 +4,8 @@ import type { PathsForPages, GetConfigResponse } from 'waku/router'; // prettier-ignore +import type { getConfig as Login_getConfig } from './pages/login'; +// prettier-ignore import type { getConfig as Db_getConfig } from './pages/db'; // prettier-ignore import type { getConfig as About_getConfig } from './pages/about'; @@ -12,6 +14,7 @@ import type { getConfig as Index_getConfig } from './pages/index'; // prettier-ignore type Page = +| ({ path: '/login' } & GetConfigResponse<typeof Login_getConfig>) | ({ path: '/db' } & GetConfigResponse<typeof Db_getConfig>) | ({ path: '/about' } & GetConfigResponse<typeof About_getConfig>) | ({ path: '/' } & GetConfigResponse<typeof Index_getConfig>); diff --git a/src/pages/api/auth.ts b/src/pages/api/auth.ts new file mode 100644 index 0000000..3ed9b76 --- /dev/null +++ b/src/pages/api/auth.ts @@ -0,0 +1,17 @@ +import db from "../../lib/db"; +export const POST = async (request: Request): Promise<Response> => { + const body = await request.json(); + + if (!body.name || !body.creds) { + return Response.json({ message: "Invalid" }, { status: 400 }); + } + + try { + const res = db.loginUser(body.name, body.creds); + console.log({ res }); + + return Response.json(res, { status: 200 }); + } catch (error) { + return Response.json({ message: "Failure" }, { status: 500 }); + } +}; diff --git a/src/pages/login.tsx b/src/pages/login.tsx new file mode 100644 index 0000000..8ddc1a1 --- /dev/null +++ b/src/pages/login.tsx @@ -0,0 +1,28 @@ +import AuthScreen from "@/components/Login2"; +import { Link } from "waku"; +import db from "@/lib/db"; + +export default async function AuthPage() { + const data = await getData(); + + return ( + <div> + <AuthScreen /> + </div> + ); +} + +const getData = async () => { + // const data = { + // title: "Waku", + // headline: "Waku", + // body: "Hello world!", + // }; + // return data; +}; + +export const getConfig = async () => { + return { + render: "static", + } as const; +}; diff --git a/waku.config.ts b/waku.config.ts index 4c455f3..7b4aa09 100644 --- a/waku.config.ts +++ b/waku.config.ts @@ -5,14 +5,27 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ middleware: [ "waku/middleware/context", + "./src/lib/server/header", + "./src/lib/server/setcookie", + "./src/lib/server/cookie", "waku/middleware/dev-server", "waku/middleware/handler", ], unstable_viteConfigs: { - common: () => ({ - plugins: [ - tsconfigPaths({ root: fileURLToPath(new URL(".", import.meta.url)) }), - ], - }), + common: () => { + // console.log( + // "WAKU_DEBUG: Applying common Vite config from waku.config.ts", + // ); + return { + plugins: [ + tsconfigPaths({ root: fileURLToPath(new URL(".", import.meta.url)) }), + ], + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, + }; + }, }, }); |