commit b344a74a7245bd697eef43f291e98db8921405af Author: Sebastian Korotkiewicz Date: Tue May 28 08:08:42 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..842c9bd --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +dev.db +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..145b5c4 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# vike-stack + +- Base + - [Vike](https://vike.dev) + - [Hono](https://hono.dev) + - [React](https://react.dev/learn) + - [Telefunc](https://telefunc.com) +- Auth + - [Lucia Auth](https://lucia-auth.com) + - @lucia-auth/adapter-prisma + - [Prisma ORM](https://www.prisma.io) + +## Next steps + +### Setup _Prisma_ + +Run the following command once: + +1. Run `npx prisma init` + +Then: + +2. Run `npx prisma db pull` to turn your database schema into a Prisma schema. +3. Run `npx prisma generate` to generate the Prisma Client. +4. Run `npx prisma db push` to push scheme to database. +5. Run `npx prisma migrate dev` to regenerate schema (if scheme change) + +### Example .env file + +```sh +# postgres: +DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" + +# sqlite +DATABASE_URL="file:./dev.db" +``` + +### Imports + +With one simple alias `~/` + +```tsx +import { Input } from "~/components/Input"; +``` + +### Run + +``` +# yarn dev +or +# bun run dev +``` + +# About this app + +This app is ready to start. It's powered by [Vike](https://vike.dev) and [React](https://react.dev/learn). + +### `/pages/+config.ts` + +Such files are [the interface](https://vike.dev/config) between Vike and your code. It defines: + +- A default [`` component](https://vike.dev/Layout) (that wraps your [`` components](https://vike.dev/Page)). +- A default [`title`](https://vike.dev/head). +- Default [`` tags](https://vike.dev/head). + +### Routing + +[Vike's built-in router](https://vike.dev/routing) lets you choose between: + +- [Filesystem Routing](https://vike.dev/filesystem-routing) (the URL of a page is determined based on where its `+Page.jsx` file is located on the filesystem) +- [Route Strings](https://vike.dev/route-string) +- [Route Functions](https://vike.dev/route-function) + +### `/pages/_error/+Page.jsx` + +The [error page](https://vike.dev/error-page) which is rendered when errors occur. + +### `/pages/+onPageTransitionStart.ts` and `/pages/+onPageTransitionEnd.ts` + +The [`onPageTransitionStart()` hook](https://vike.dev/onPageTransitionStart), together with [`onPageTransitionEnd()`](https://vike.dev/onPageTransitionEnd), enables you to implement page transition animations. + +### SSR + +SSR is enabled by default. You can [disable it](https://vike.dev/ssr) for all your pages or only for some pages. + +### HTML Streaming + +You can enable/disable [HTML streaming](https://vike.dev/streaming) for all your pages, or only for some pages while still using it for others. diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000..94d3caa --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,36 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..82888f1 Binary files /dev/null and b/bun.lockb differ diff --git a/components/Input.tsx b/components/Input.tsx new file mode 100644 index 0000000..fa03c3c --- /dev/null +++ b/components/Input.tsx @@ -0,0 +1,15 @@ +import React, { InputHTMLAttributes } from "react"; + +interface InputProps extends InputHTMLAttributes { + id: string; +} + +export function Input({ id, ...props }: InputProps) { + return ( + + ); +} diff --git a/components/Link.tsx b/components/Link.tsx new file mode 100644 index 0000000..8ae6ae4 --- /dev/null +++ b/components/Link.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { usePageContext } from "vike-react/usePageContext"; + +export function Link({ href, children }: { href: string; children: string }) { + const pageContext = usePageContext(); + const { urlPathname } = pageContext; + const isActive = href === "/" ? urlPathname === href : urlPathname.startsWith(href); + return ( + + {children} + + ); +} diff --git a/database/todoItems.ts b/database/todoItems.ts new file mode 100644 index 0000000..46d4e8b --- /dev/null +++ b/database/todoItems.ts @@ -0,0 +1,12 @@ +type TodoItem = { text: string }; +const todoItems: TodoItem[] = []; +init(); + +// Initial data +function init() { + todoItems.push({ text: "Buy milk" }); + todoItems.push({ text: "Buy strawberries" }); +} + +export { todoItems }; +export type { TodoItem }; diff --git a/hono-entry.ts b/hono-entry.ts new file mode 100644 index 0000000..ef1b759 --- /dev/null +++ b/hono-entry.ts @@ -0,0 +1,80 @@ +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { Hono } from "hono"; +import { compress } from "hono/compress"; +import { poweredBy } from "hono/powered-by"; +import { telefunc } from "telefunc"; +import { renderPage } from "vike/server"; + +import authRoute from "~/lib/auth"; + +import type { Session, User } from "lucia"; + +const isProduction = process.env.NODE_ENV === "production"; +const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; + +// const app = new Hono(); + +const app = new Hono<{ + Variables: { + user: User | null; + session: Session | null; + }; +}>(); + +app.use(poweredBy()); +app.use(compress()); + +app.route("/", authRoute); + +if (isProduction) { + app.use( + "/*", + serveStatic({ + root: `dist/client/`, + }) + ); +} + +app.post("/_telefunc", async (c) => { + const httpResponse = await telefunc({ + url: c.req.url.toString(), + method: c.req.method, + body: await c.req.text(), + context: c, + }); + const { body, statusCode, contentType } = httpResponse; + + c.status(statusCode); + c.header("Content-Type", contentType); + + return c.body(body); +}); + +app.all("*", async (c, next) => { + const pageContextInit = { + urlOriginal: c.req.url, + auth: c.get("user"), + }; + const pageContext = await renderPage(pageContextInit); + const { httpResponse } = pageContext; + if (!httpResponse) { + return next(); + } else { + const { body, statusCode, headers } = httpResponse; + headers.forEach(([name, value]) => c.header(name, value)); + c.status(statusCode); + + return c.body(body); + } +}); + +if (isProduction) { + console.log(`Server listening on http://localhost:${port}`); + serve({ + fetch: app.fetch, + port: port, + }); +} + +export default app; diff --git a/layouts/HeadDefault.tsx b/layouts/HeadDefault.tsx new file mode 100644 index 0000000..cfdcd41 --- /dev/null +++ b/layouts/HeadDefault.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import logoUrl from "../assets/logo.svg"; + +// Default (can be overridden by pages) + +export default function HeadDefault() { + return ( + <> + + + + + ); +} diff --git a/layouts/LayoutDefault.tsx b/layouts/LayoutDefault.tsx new file mode 100644 index 0000000..04c6f89 --- /dev/null +++ b/layouts/LayoutDefault.tsx @@ -0,0 +1,88 @@ +import "./style.css"; + +import React from "react"; +import logoUrl from "../assets/logo.svg"; +import { Link } from "../components/Link.js"; +import { usePageContext } from "vike-react/usePageContext"; + +export default function LayoutDefault({ + children, +}: { + children: React.ReactNode; +}) { + const pageContext = usePageContext(); + + return ( +
+ + + Welcome + Todo (telefunc) + Data Fetching + + {pageContext.auth ? ( + Account + ) : ( + Auth + )} + + {children} +
+ ); +} + +function Sidebar({ children }: { children: React.ReactNode }) { + return ( + + ); +} + +function Content({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+
+ ); +} + +function Logo() { + return ( +
+ + logo + +
+ ); +} diff --git a/layouts/style.css b/layouts/style.css new file mode 100644 index 0000000..c5a3d28 --- /dev/null +++ b/layouts/style.css @@ -0,0 +1,29 @@ +/* Links */ +a { + text-decoration: none; +} +#sidebar a { + padding: 2px 10px; + margin-left: -10px; +} +#sidebar a.is-active { + background-color: #eee; +} + +/* Reset */ +body { + margin: 0; + font-family: sans-serif; +} +* { + box-sizing: border-box; +} + +/* Page Transition Animation */ +#page-content { + opacity: 1; + transition: opacity 0.3s ease-in-out; +} +body.page-is-transitioning #page-content { + opacity: 0; +} diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..c1fb484 --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,148 @@ +import { Context, Hono } from "hono"; +import { getCookie, setCookie } from "hono/cookie"; +import type { User, Session } from "lucia"; +import { generateId } from "lucia"; +import { Argon2id } from "oslo/password"; +import { prisma } from "./prisma.js"; +import { lucia } from "./lucia.js"; +import { CookieOptions } from "hono/utils/cookie"; + +const app = new Hono<{ + Variables: { + user: User | null; + session: Session | null; + }; +}>(); + +app.use("*", async (c, next) => { + const sessionId = getCookie(c, lucia.sessionCookieName) ?? null; + + if (!sessionId) { + c.set("user", null); + c.set("session", null); + return next(); + } + + const { session, user } = await lucia.validateSession(sessionId); + + if (session && session.fresh) { + const sessionCookie = lucia.createSessionCookie(session.id); + setAuthCookie(c, sessionCookie); + } + if (!session) { + const sessionCookie = lucia.createBlankSessionCookie(); + setAuthCookie(c, sessionCookie); + } + + c.set("user", user); + c.set("session", session); + + return next(); +}); + +export const handler = app + .post("/api/auth/login", async (c) => { + const { email, password } = await c.req.json(); + + const formDataRaw = { + email: email as string, + password: password as string, + }; + + try { + const user = await prisma.user.findUnique({ + where: { email: formDataRaw.email }, + }); + + if (!user) { + return c.text("Incorrect email or password", 400); + } + + const validPassword = await new Argon2id().verify( + user.hashedPassword, + formDataRaw.password + ); + + if (!validPassword) { + return c.text("Incorrect email or password", 400); + } + + const session = await lucia.createSession(user.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + + setAuthCookie(c, sessionCookie); + + return c.json("ok"); + } catch (error) {} + }) + + .post("/api/auth/register", async (c) => { + const { firstName, lastName, email, password, confirmPassword } = + await c.req.json(); + + const formDataRaw = { + firstName: firstName as string, + lastName: lastName as string, + email: email as string, + password: password as string, + confirmPassword: confirmPassword as string, + }; + + if (formDataRaw.password !== formDataRaw.confirmPassword) { + return c.text("Passwords do not match", 400); + } + + try { + const hashedPassword = await new Argon2id().hash(formDataRaw.password); + const userId = generateId(15); + + try { + await prisma.user.create({ + data: { + id: userId, + firstName: formDataRaw.firstName, + lastName: formDataRaw.lastName, + email: formDataRaw.email, + hashedPassword, + }, + }); + } catch (error) { + return c.text("Something went wrong, try again", 400); + } + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + + setAuthCookie(c, sessionCookie); + + return c.json("ok"); + } catch (error) { + console.log(error); + } + }) + + .post("/api/auth/logout", async (c) => { + if (c.get("user") == null) return; + + await lucia.invalidateSession(c.get("session")?.id ?? ""); + + const sessionCookie = lucia.createBlankSessionCookie(); + setAuthCookie(c, sessionCookie); + + return c.json("ok"); + }); + +export default app; +export type AuthRPCType = typeof handler; + +const setAuthCookie = ( + c: Context, + sessionCookie: { name: string; value: string; attributes: CookieOptions } +) => { + setCookie( + c, + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ); +}; diff --git a/lib/lucia.ts b/lib/lucia.ts new file mode 100644 index 0000000..68f9f00 --- /dev/null +++ b/lib/lucia.ts @@ -0,0 +1,34 @@ +import { Lucia } from "lucia"; +import { PrismaAdapter } from "@lucia-auth/adapter-prisma"; +import { prisma } from "./prisma"; + +const adapter = new PrismaAdapter(prisma.session, prisma.user); + +export const lucia = new Lucia(adapter, { + sessionCookie: { + // this sets cookies with super long expiration + // since Next.js doesn't allow Lucia to extend cookie expiration when rendering pages + expires: true, + attributes: { + // set to `true` when using HTTPS + secure: process.env.NODE_ENV === "production", + }, + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + email: attributes.email, + }; + }, +}); + +declare module "lucia" { + interface Register { + Lucia: typeof lucia; + DatabaseUserAttributes: DatabaseUserAttributes; + } +} + +interface DatabaseUserAttributes { + email: string; +} diff --git a/lib/prisma.ts b/lib/prisma.ts new file mode 100644 index 0000000..1299460 --- /dev/null +++ b/lib/prisma.ts @@ -0,0 +1,5 @@ +// import { PrismaClient } from '@prisma/client/edge' +import { PrismaClient } from "@prisma/client"; +const prisma = new PrismaClient(); + +export { prisma }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..6992751 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "my-app", + "version": "0.0.1", + "description": "", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "NODE_ENV=production tsx ./hono-entry.ts", + "prisma:studio": "prisma studio", + "prisma:generate": "prisma generate" + }, + "keywords": [], + "author": "", + "devDependencies": { + "typescript": "^5.4.5", + "@hono/vite-dev-server": "^0.12.1", + "@types/node": "^18.19.14", + "prisma": "^5.14.0", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.3.0" + }, + "dependencies": { + "@hono/node-server": "^1.11.1", + "@lucia-auth/adapter-prisma": "^4.0.1", + "@prisma/client": "^5.14.0", + "@vite-plugin-vercel/vike": "^6.0.1", + "@vitejs/plugin-react": "^4.2.1", + "better-sqlite3": "^10.0.0", + "cross-fetch": "^4.0.0", + "hono": "^4.3.8", + "lucia": "^3.2.0", + "oslo": "^1.2.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "telefunc": "^0.1.72", + "tsx": "^4.10.5", + "vike": "^0.4.171", + "vike-react": "^0.4.10", + "vite": "^5.2.11", + "vite-plugin-vercel": "^6.0.1" + } +} \ No newline at end of file diff --git a/pages/+config.ts b/pages/+config.ts new file mode 100644 index 0000000..11f7009 --- /dev/null +++ b/pages/+config.ts @@ -0,0 +1,14 @@ +import vikeReact from "vike-react/config"; +import type { Config } from "vike/types"; +import Head from "../layouts/HeadDefault.js"; +import Layout from "../layouts/LayoutDefault.js"; + +// Default config (can be overridden by pages) +export default { + Layout, + Head, + // + title: "My Vike App", + extends: vikeReact, + passToClient: ["auth"], +} satisfies Config; diff --git a/pages/+onPageTransitionEnd.ts b/pages/+onPageTransitionEnd.ts new file mode 100644 index 0000000..75af2e0 --- /dev/null +++ b/pages/+onPageTransitionEnd.ts @@ -0,0 +1,6 @@ +import type { OnPageTransitionEndAsync } from "vike/types"; + +export const onPageTransitionEnd: OnPageTransitionEndAsync = async () => { + console.log("Page transition end"); + document.querySelector("body")?.classList.remove("page-is-transitioning"); +}; diff --git a/pages/+onPageTransitionStart.ts b/pages/+onPageTransitionStart.ts new file mode 100644 index 0000000..12c344b --- /dev/null +++ b/pages/+onPageTransitionStart.ts @@ -0,0 +1,6 @@ +import type { OnPageTransitionStartAsync } from "vike/types"; + +export const onPageTransitionStart: OnPageTransitionStartAsync = async () => { + console.log("Page transition start"); + document.querySelector("body")?.classList.add("page-is-transitioning"); +}; diff --git a/pages/PageContext.ts b/pages/PageContext.ts new file mode 100644 index 0000000..d542df6 --- /dev/null +++ b/pages/PageContext.ts @@ -0,0 +1,12 @@ +import type { User } from "lucia"; + +// https://vike.dev/pageContext#typescript +declare global { + namespace Vike { + interface PageContext { + auth: User; + } + } +} + +export {}; diff --git a/pages/_error/+Page.tsx b/pages/_error/+Page.tsx new file mode 100644 index 0000000..cd01d65 --- /dev/null +++ b/pages/_error/+Page.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { usePageContext } from "vike-react/usePageContext"; + +export default function Page() { + const { is404 } = usePageContext(); + if (is404) { + return ( + <> + <h1>404 Page Not Found</h1> + <p>This page could not be found.</p> + </> + ); + } + return ( + <> + <h1>500 Internal Server Error</h1> + <p>Something went wrong.</p> + </> + ); +} diff --git a/pages/account/+Page.tsx b/pages/account/+Page.tsx new file mode 100644 index 0000000..5b8c222 --- /dev/null +++ b/pages/account/+Page.tsx @@ -0,0 +1,31 @@ +export default Page; + +import { reload } from "vike/client/router"; +import { usePageContext } from "vike-react/usePageContext"; +import { hc } from "hono/client"; + +import type { AuthRPCType } from "~/lib/auth"; +import User from "./User"; + +function Page() { + const client = hc<AuthRPCType>("/"); + const session = usePageContext().auth ?? {}; + + return ( + <> + <h1>Account</h1> + <button + onClick={async (e) => { + e.preventDefault(); + await client.api.auth.logout.$post(); + await reload(); + }} + > + Logout + </button> + <pre>{JSON.stringify(session, null, 2)}</pre> + + <User id={session.id} /> + </> + ); +} diff --git a/pages/account/+guard.ts b/pages/account/+guard.ts new file mode 100644 index 0000000..c136d60 --- /dev/null +++ b/pages/account/+guard.ts @@ -0,0 +1,12 @@ +export { guard }; + +import { redirect } from "vike/abort"; +import type { GuardAsync } from "vike/types"; + +const guard: GuardAsync = async (pageContext): ReturnType<GuardAsync> => { + const session = pageContext.auth; + + if (session == undefined) { + throw redirect("/auth"); + } +}; diff --git a/pages/account/User.telefunc.ts b/pages/account/User.telefunc.ts new file mode 100644 index 0000000..4205e07 --- /dev/null +++ b/pages/account/User.telefunc.ts @@ -0,0 +1,16 @@ +import { prisma } from "~/lib/prisma"; + +export const onGetUser = async ({ id }: { id: string }) => { + try { + const user = await prisma.user.findUnique({ + where: { id }, + select: { + firstName: true, + lastName: true, + email: true, + }, + }); + + return user; + } catch (error) {} +}; diff --git a/pages/account/User.tsx b/pages/account/User.tsx new file mode 100644 index 0000000..5fc974b --- /dev/null +++ b/pages/account/User.tsx @@ -0,0 +1,20 @@ +import React, { useEffect, useState } from "react"; +import { onGetUser } from "./User.telefunc"; + +const User = ({ id }: { id: string }) => { + const [user, setUser] = useState<any>({}); + + useEffect(() => { + onGetUser({ id }).then((user) => { + setUser(user); + }); + }, []); + + return ( + <> + <pre>{JSON.stringify(user, null, 2)}</pre> + </> + ); +}; + +export default User; diff --git a/pages/auth/+Page.tsx b/pages/auth/+Page.tsx new file mode 100644 index 0000000..970a746 --- /dev/null +++ b/pages/auth/+Page.tsx @@ -0,0 +1,16 @@ +import { Link } from "~/components/Link"; + +const Page = () => { + return ( + <div> + <p> + <Link href="/auth/sign-in">Sign in</Link> + </p> + <p> + <Link href="/auth/sign-up">Sign up</Link> + </p> + </div> + ); +}; + +export default Page; diff --git a/pages/auth/sign-in/+Page.tsx b/pages/auth/sign-in/+Page.tsx new file mode 100644 index 0000000..e4ce3ac --- /dev/null +++ b/pages/auth/sign-in/+Page.tsx @@ -0,0 +1,66 @@ +import React, { useState } from "react"; +import { navigate } from "vike/client/router"; +import { hc } from "hono/client"; + +import type { AuthRPCType } from "~/lib/auth"; +import { Input } from "~/components/Input"; + +const Page = () => { + const client = hc<AuthRPCType>("/"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + setError(""); + + try { + const response = await client.api.auth.login.$post({ + json: { + email, + password, + }, + }); + + if (response.ok) { + await navigate("/account"); + } else { + setError(await response.text()); + } + } catch (err) { + setError("Something went wrong."); + console.error(err); + } + }; + + return ( + <> + <h2>Sign In Page</h2> + + <form onSubmit={handleSubmit}> + <Input + id="email" + type="email" + placeholder="Email" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + <Input + id="password" + type="password" + placeholder="Password" + value={password} + onChange={(e) => setPassword(e.target.value)} + /> + + <div id="validation" style={{ color: "#f00" }}> + {error} + </div> + <button type="submit">Login</button> + </form> + </> + ); +}; + +export default Page; diff --git a/pages/auth/sign-up/+Page.tsx b/pages/auth/sign-up/+Page.tsx new file mode 100644 index 0000000..6c14863 --- /dev/null +++ b/pages/auth/sign-up/+Page.tsx @@ -0,0 +1,92 @@ +import React, { useState } from "react"; +import { navigate } from "vike/client/router"; +import { hc } from "hono/client"; + +import type { AuthRPCType } from "~/lib/auth"; +import { Input } from "~/components/Input"; + +const Page = () => { + const client = hc<AuthRPCType>("/"); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + setError(""); + + try { + const response = await client.api.auth.register.$post({ + json: { + firstName, + lastName, + email, + password, + confirmPassword, + }, + }); + + if (response.ok) { + await navigate("/account"); + } else { + setError(await response.text()); + } + } catch (err) { + setError("Something went wrong."); + console.error(err); + } + }; + + return ( + <> + <h2>Sign Up Page</h2> + + <form onSubmit={handleSubmit}> + <Input + id="firstName" + type="text" + placeholder="First Name" + value={firstName} + onChange={(e) => setFirstName(e.target.value)} + /> + <Input + id="lastName" + type="text" + placeholder="Last Name" + value={lastName} + onChange={(e) => setLastName(e.target.value)} + /> + <Input + id="email" + type="email" + placeholder="Email" + value={email} + onChange={(e) => setEmail(e.target.value)} + /> + <Input + id="password" + type="password" + placeholder="Password" + value={password} + onChange={(e) => setPassword(e.target.value)} + /> + <Input + id="confirmPassword" + type="password" + placeholder="Confirm Password" + value={confirmPassword} + onChange={(e) => setConfirmPassword(e.target.value)} + /> + <div id="validation" style={{ color: "#f00" }}> + {error} + </div> + <button type="submit">Login</button> + </form> + </> + ); +}; + +export default Page; diff --git a/pages/index/+Page.tsx b/pages/index/+Page.tsx new file mode 100644 index 0000000..0955fda --- /dev/null +++ b/pages/index/+Page.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { Counter } from "./Counter.js"; + +export default function Page() { + return ( + <> + <h1>My Vike app</h1> + This page is: + <ul> + <li>Rendered to HTML.</li> + <li> + Interactive. <Counter /> + </li> + </ul> + </> + ); +} diff --git a/pages/index/Counter.tsx b/pages/index/Counter.tsx new file mode 100644 index 0000000..00ea9aa --- /dev/null +++ b/pages/index/Counter.tsx @@ -0,0 +1,11 @@ +import React, { useState } from "react"; + +export function Counter() { + const [count, setCount] = useState(0); + + return ( + <button type="button" onClick={() => setCount((count) => count + 1)}> + Counter {count} + </button> + ); +} diff --git a/pages/star-wars/@id/+Page.tsx b/pages/star-wars/@id/+Page.tsx new file mode 100644 index 0000000..6e50b0e --- /dev/null +++ b/pages/star-wars/@id/+Page.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { useData } from "vike-react/useData"; +import type { Data } from "./+data.js"; + +export default function Page() { + const movie = useData<Data>(); + return ( + <> + <h1>{movie.title}</h1> + Release Date: {movie.release_date} + <br /> + Director: {movie.director} + <br /> + Producer: {movie.producer} + </> + ); +} diff --git a/pages/star-wars/@id/+data.ts b/pages/star-wars/@id/+data.ts new file mode 100644 index 0000000..1ca9d2c --- /dev/null +++ b/pages/star-wars/@id/+data.ts @@ -0,0 +1,22 @@ +// https://vike.dev/data + +import fetch from "cross-fetch"; +import type { PageContextServer } from "vike/types"; +import type { MovieDetails } from "../types.js"; + +export type Data = Awaited<ReturnType<typeof data>>; + +export const data = async (pageContext: PageContextServer) => { + const response = await fetch(`https://brillout.github.io/star-wars/api/films/${pageContext.routeParams.id}.json`); + let movie = (await response.json()) as MovieDetails; + // We remove data we don't need because the data is passed to + // the client; we should minimize what is sent over the network. + movie = minimize(movie); + return movie; +}; + +function minimize(movie: MovieDetails): MovieDetails { + const { id, title, release_date, director, producer } = movie; + const minimizedMovie = { id, title, release_date, director, producer }; + return minimizedMovie; +} diff --git a/pages/star-wars/@id/+title.ts b/pages/star-wars/@id/+title.ts new file mode 100644 index 0000000..7e9de82 --- /dev/null +++ b/pages/star-wars/@id/+title.ts @@ -0,0 +1,7 @@ +import type { PageContext } from "vike/types"; +import type { Data } from "./+data.js"; + +export function title(pageContext: PageContext<Data>) { + const movie = pageContext.data; + return movie.title; +} diff --git a/pages/star-wars/index/+Page.tsx b/pages/star-wars/index/+Page.tsx new file mode 100644 index 0000000..3c24a4e --- /dev/null +++ b/pages/star-wars/index/+Page.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { useData } from "vike-react/useData"; +import type { Data } from "./+data.js"; + +export default function Page() { + const movies = useData<Data>(); + return ( + <> + <h1>Star Wars Movies</h1> + <ol> + {movies.map(({ id, title, release_date }) => ( + <li key={id}> + <a href={`/star-wars/${id}`}>{title}</a> ({release_date}) + </li> + ))} + </ol> + <p> + Source: <a href="https://brillout.github.io/star-wars">brillout.github.io/star-wars</a>. + </p> + </> + ); +} diff --git a/pages/star-wars/index/+data.ts b/pages/star-wars/index/+data.ts new file mode 100644 index 0000000..9385c2a --- /dev/null +++ b/pages/star-wars/index/+data.ts @@ -0,0 +1,22 @@ +// https://vike.dev/data + +import fetch from "cross-fetch"; +import type { Movie, MovieDetails } from "../types.js"; + +export type Data = Awaited<ReturnType<typeof data>>; + +export const data = async () => { + const response = await fetch("https://brillout.github.io/star-wars/api/films.json"); + const moviesData = (await response.json()) as MovieDetails[]; + // We remove data we don't need because the data is passed to the client; we should + // minimize what is sent over the network. + const movies = minimize(moviesData); + return movies; +}; + +function minimize(movies: MovieDetails[]): Movie[] { + return movies.map((movie) => { + const { title, release_date, id } = movie; + return { title, release_date, id }; + }); +} diff --git a/pages/star-wars/index/+title.ts b/pages/star-wars/index/+title.ts new file mode 100644 index 0000000..fc6e4e1 --- /dev/null +++ b/pages/star-wars/index/+title.ts @@ -0,0 +1,7 @@ +import type { PageContext } from "vike/types"; +import type { Data } from "./+data.js"; + +export function title(pageContext: PageContext<Data>) { + const movies = pageContext.data; + return `${movies.length} Star Wars Movies`; +} diff --git a/pages/star-wars/types.ts b/pages/star-wars/types.ts new file mode 100644 index 0000000..ffccdf5 --- /dev/null +++ b/pages/star-wars/types.ts @@ -0,0 +1,10 @@ +export type Movie = { + id: string; + title: string; + release_date: string; +}; + +export type MovieDetails = Movie & { + director: string; + producer: string; +}; diff --git a/pages/todo/+Page.tsx b/pages/todo/+Page.tsx new file mode 100644 index 0000000..09b35ac --- /dev/null +++ b/pages/todo/+Page.tsx @@ -0,0 +1,27 @@ +import React, { useState } from "react"; +import { useData } from "vike-react/useData"; +import type { Data } from "./+data.js"; +import { TodoList } from "./TodoList.js"; + +export default function Page() { + const todoItemsInitial = useData<Data>(); + return ( + <> + <h1>To-do List</h1> + <TodoList todoItemsInitial={todoItemsInitial} /> + <Counter /> + </> + ); +} + +function Counter() { + const [count, setCount] = useState(0); + return ( + <div> + This page is interactive: + <button type="button" onClick={() => setCount((count) => count + 1)}> + Counter {count} + </button> + </div> + ); +} diff --git a/pages/todo/+config.ts b/pages/todo/+config.ts new file mode 100644 index 0000000..e7f60cd --- /dev/null +++ b/pages/todo/+config.ts @@ -0,0 +1,5 @@ +const config = { + prerender: false, +}; + +export default config; diff --git a/pages/todo/+data.ts b/pages/todo/+data.ts new file mode 100644 index 0000000..2a13056 --- /dev/null +++ b/pages/todo/+data.ts @@ -0,0 +1,9 @@ +// https://vike.dev/data +import { todoItems } from "../../database/todoItems"; + +export type Data = ReturnType<typeof data>; + +export default function data() { + const todoItemsInitial = todoItems; + return todoItemsInitial; +} diff --git a/pages/todo/TodoList.telefunc.ts b/pages/todo/TodoList.telefunc.ts new file mode 100644 index 0000000..e07340b --- /dev/null +++ b/pages/todo/TodoList.telefunc.ts @@ -0,0 +1,6 @@ +import { todoItems, type TodoItem } from "../../database/todoItems"; + +export async function onNewTodo({ text }: TodoItem) { + todoItems.push({ text }); + return { todoItems }; +} diff --git a/pages/todo/TodoList.tsx b/pages/todo/TodoList.tsx new file mode 100644 index 0000000..b74aad2 --- /dev/null +++ b/pages/todo/TodoList.tsx @@ -0,0 +1,38 @@ +import type { TodoItem } from "../../database/todoItems"; +import React, { useState } from "react"; +import { onNewTodo } from "./TodoList.telefunc.js"; + +export function TodoList({ + todoItemsInitial, +}: { + todoItemsInitial: TodoItem[]; +}) { + const [todoItems, setTodoItems] = useState(todoItemsInitial); + const [draft, setDraft] = useState(""); + return ( + <> + <ul> + {todoItems.map((todoItem, i) => ( + <li key={i}>{todoItem.text}</li> + ))} + <li> + <form + onSubmit={async (ev) => { + ev.preventDefault(); + const { todoItems } = await onNewTodo({ text: draft }); + setDraft(""); + setTodoItems(todoItems); + }} + > + <input + type="text" + onChange={(ev) => setDraft(ev.target.value)} + value={draft} + />{" "} + <button type="submit">Add to-do</button> + </form> + </li> + </ul> + </> + ); +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..04741bd --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,31 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + // provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id + sessions Session[] + firstName String + lastName String + email String @unique + hashedPassword String +} + +model Session { + id String @id + expiresAt DateTime + userId String + user User @relation(references: [id], fields: [userId], onDelete: Cascade) +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5d41744 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "strict": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "module": "ESNext", + "noEmit": true, + "moduleResolution": "Bundler", + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "types": ["vite/client", "vike-react"], + "jsx": "preserve", + "jsxImportSource": "react", + "paths": { + "~/*": ["./*"] + } + }, + "exclude": ["dist"] +} diff --git a/vike.d.ts b/vike.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/vike.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..32182a1 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,37 @@ +import vercel from "vite-plugin-vercel"; +import { telefunc } from "telefunc/vite"; +import ssr from "vike/plugin"; +import react from "@vitejs/plugin-react"; +import devServer from "@hono/vite-dev-server"; +import { defineConfig } from "vite"; + +export default defineConfig({ + resolve: { + alias: { + "~": __dirname, + }, + }, + plugins: [ + devServer({ + entry: "hono-entry.ts", + + exclude: [ + /^\/@.+$/, + /.*\.(ts|tsx|vue)($|\?)/, + /.*\.(s?css|less)($|\?)/, + /^\/favicon\.ico$/, + /.*\.(svg|png)($|\?)/, + /^\/(public|assets|static)\/.+/, + /^\/node_modules\/.*/, + ], + + injectClientScript: false, + }), + react({}), + ssr({ + prerender: true, + }), + telefunc(), + // vercel(), + ], +});