diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2ef4a1f --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# Fresh build directory +_fresh/ +# npm dependencies +node_modules/ +/local \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..09cf720 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "denoland.vscode-deno" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index c89c3aa..bf6ade0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,18 @@ { - "deno.enablePaths": [ - "./" - ], "deno.enable": true, + "deno.lint": true, "deno.unstable": true, - "editor.inlayHints.enabled": "off" + "editor.defaultFormatter": "denoland.vscode-deno", + "[typescriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[typescript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "denoland.vscode-deno" + }, + "[javascript]": { + "editor.defaultFormatter": "denoland.vscode-deno" + } } diff --git a/README.md b/README.md index e3988d7..ec0e33e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,16 @@ -# Twifeur +# Fresh project -Feur +Your new Fresh project is ready to go. You can follow the Fresh "Getting +Started" guide here: https://fresh.deno.dev/docs/getting-started -## Feur +### Usage -Feur feur. +Make sure to install Deno: https://deno.land/manual/getting_started/installation + +Then start the project: + +``` +deno task start +``` + +This will watch the project directory and restart as necessary. diff --git a/api/login.ts b/api/login.ts deleted file mode 100644 index 8cfbae6..0000000 --- a/api/login.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Context } from "https://deno.land/x/hono@v4.3.10/mod.ts"; -import { BlankInput } from "https://deno.land/x/hono@v4.3.10/types.ts"; -import { FeurEnv } from "../main.ts"; -import { login, set_user } from "../lib/auth.ts"; - -export async function login_route(context: Context) { - const data = await context.req.formData(); - let username = data.get("login"), pass = data.get("password"); - if (username === null || pass === null) return context.redirect("/login"); - username = username.toString(), pass = pass.toString(); - const logged = await login(username, pass); - if (logged === null) return context.redirect("/login"); - console.log("Logged in", { username }); - set_user(context, logged); - return context.redirect("/user"); -} diff --git a/api/logout.ts b/api/logout.ts deleted file mode 100644 index eeabd70..0000000 --- a/api/logout.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Context } from "https://deno.land/x/hono@v4.3.10/mod.ts"; -import { BlankInput } from "https://deno.land/x/hono@v4.3.10/types.ts"; -import { FeurEnv } from "../main.ts"; -import { set_user } from "../lib/auth.ts"; - -export function logout_route(context: Context) { - set_user(context, null); - return context.redirect("/"); -} diff --git a/auth/auth.ts b/auth/auth.ts new file mode 100644 index 0000000..b3608c8 --- /dev/null +++ b/auth/auth.ts @@ -0,0 +1,72 @@ +import { assert } from "$std/assert/assert.ts"; +import { User } from "../lib/store.ts"; +import { generate } from "https://deno.land/std@0.224.0/uuid/v1.ts"; + +class Authentificator { + tokens; + user_storage; + constructor(user_storage: UserStorage) { + this.tokens = new TokenSet(); + this.user_storage = user_storage; + } +} + +interface UserStorage { + get_user(login: string): User | null; +} + +export class Token { + raw; + user_id; + constructor(raw: string, user_id: string) { + this.raw = raw; + this.user_id = user_id; + } +} + +class TokenSet { + tokens; + + constructor() { + this.tokens = new Map(); + } + + get(token: string) { + return this.tokens.get(token) ?? null; + } + + create(user_id: string) { + const raw = generate(); + assert(typeof raw == "string"); + const token = new Token(raw, user_id); + this.tokens.set(raw, token); + } +} + +class StaticUserStorage implements UserStorage { + users; + + constructor() { + this.users = new Map(); + } + + get_user(user_id: string) { + return this.users.get(user_id) ?? null; + } + + with(user: User) { + this.users.set(user.id, user); + return this; + } +} + +export const auth = new Authentificator( + new StaticUserStorage().with({ + id: "pleinplein", + email: "feur@feur.feur", + like_set: new Set(), + name: "Feur", + password: "feurfeur", + pfp_url: "https://feur.feur.feur/feur.feur", + }), +); diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 0000000..f1b80a0 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,12 @@ +import { JSX } from "preact"; +import { IS_BROWSER } from "$fresh/runtime.ts"; + +export function Button(props: JSX.HTMLAttributes) { + return ( + +

{props.count}

+ + + ); +} diff --git a/lib/auth.ts b/lib/auth.ts deleted file mode 100644 index c834ca8..0000000 --- a/lib/auth.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Context } from "https://deno.land/x/hono@v4.3.10/mod.ts"; -import { db, User } from "./storage.ts"; -import { FeurEnv } from "../main.ts"; -import { BlankInput } from "https://deno.land/x/hono@v4.3.10/types.ts"; - -export async function login(login: string, password: string) { - for await (const user of db.all("user")) { - if (await user.get("username") !== login) continue; - if (await user.get("password") === password) return user; - return null; - } - return null; -} - -export async function get_user(context: Context) { - const id = context.get("session").get("user"); - if (typeof id !== "string") return null; - return await db.get("user", id); -} - -export function set_user(context: Context, user: User | null) { - if (user === null) context.get("session").set("user", ""); - else context.get("session").set("user", user.id); -} diff --git a/lib/models/User.ts b/lib/models/User.ts new file mode 100644 index 0000000..8859b48 --- /dev/null +++ b/lib/models/User.ts @@ -0,0 +1,9 @@ +import { z } from "https://deno.land/x/zod@v3.23.8/mod.ts"; + +export const UserModel = z.object({ + id: z.string().uuid().describe("primary"), + name: z.string(), + email: z.string(), + password: z.string(), + pfp_url: z.string(), +}); diff --git a/lib/storage.ts b/lib/storage.ts deleted file mode 100644 index 804be37..0000000 --- a/lib/storage.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - EntryFor, - Schema, - Store, -} from "https://git.barnulf.net/mb/debilus/raw/commit/fc701bec680dd73be29c72164f47ee87fac540c7/mod.ts"; -import { project_root } from "./utils.ts"; - -export const db = await Store.open( - new Schema({ - user: { - username: "string", - password: "string", - upvoted_posts: ["many", "post"], - upvoted_comments: ["many", "comment"], - posts: ["many", "post"], - }, - post: { - title: "string", - content: "string", - upvotes: "number", - author: ["one", "user"], - comments: ["many", "comment"], - }, - comment: { - content: "string", - upvotes: "number", - author: ["one", "user"], - post: ["one", "post"], - }, - }), - `${project_root()}/local/db.kv`, -); - -export type User = EntryFor; -export type Post = EntryFor; -export type Comment = EntryFor; diff --git a/lib/store.ts b/lib/store.ts new file mode 100644 index 0000000..2511b59 --- /dev/null +++ b/lib/store.ts @@ -0,0 +1,57 @@ +import { createPentagon } from "https://deno.land/x/pentagon@v0.1.5/mod.ts"; +import { project_root_dir } from "../utils.ts"; +import { z } from "https://deno.land/x/zod@v3.21.4/mod.ts"; + +const kv = await Deno.openKv(project_root_dir() + "local/kv"); + +export type User = z.infer; +export const user_model = z.object({ + id: z.string().uuid().describe("primary"), + name: z.string(), + email: z.string(), + password: z.string(), + pfp_url: z.string(), + like_set: z.set(z.string().uuid()), +}); + +export type Post = z.infer; +export const post_model = z.object({ + id: z.string().uuid().describe("primary"), + title: z.string(), + content: z.string(), + date: z.number(), + like_count: z.number(), + author_id: z.string().uuid(), +}); + +export type Comment = z.infer; +export const comment_model = z.object({ + id: z.string().uuid().describe("primary"), + date: z.number(), + post_id: z.string().uuid(), + author_id: z.string().uuid(), +}); + +export const db = createPentagon(kv, { + users: { + schema: user_model, + relations: { + posts: ["posts", [post_model], "id", "author_id"], + comments: ["comments", [comment_model], "id", "author_id"], + }, + }, + posts: { + schema: post_model, + relations: { + author: ["users", user_model, "author_id", "id"], + comments: ["comments", [comment_model], "id", "post_id"], + }, + }, + comments: { + schema: comment_model, + relations: { + author: ["users", user_model, "author_id", "id"], + post: ["post", post_model, "post_id", "id"], + }, + }, +}); diff --git a/lib/utils.ts b/lib/utils.ts deleted file mode 100644 index 565b5bf..0000000 --- a/lib/utils.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { dirname } from "https://deno.land/std@0.224.0/path/dirname.ts"; - -import { CSSProperties } from "https://deno.land/std@0.40.0/types/react.d.ts"; - -export function _css(prop: CSSProperties) { - return prop; -} - -export function project_root() { - const this_url = new URL(import.meta.url); - const this_abs_path = Deno.realPathSync(this_url.pathname); - const lib_path = dirname(this_abs_path); - return dirname(lib_path); -} diff --git a/local/.gitignore b/local/.gitignore deleted file mode 100644 index aa0e8eb..0000000 --- a/local/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!/.gitignore \ No newline at end of file diff --git a/main.ts b/main.ts old mode 100755 new mode 100644 index 5ad6af6..02ea409 --- a/main.ts +++ b/main.ts @@ -1,33 +1,14 @@ -#!/bin/env -S deno run --allow-net --unstable-kv --watch --allow-read=./pages,./local,./lib,. --allow-write=./local +/// +/// +/// +/// +/// +/// -import { Hono } from "https://deno.land/x/hono@v4.3.10/mod.ts"; -import { serveStatic } from "https://deno.land/x/hono@v4.3.10/middleware.ts"; -import { CookieStore, Session, sessionMiddleware } from "https://deno.land/x/hono_sessions@v0.5.8/mod.ts"; +import "$std/dotenv/load.ts"; -export type FeurEnv = { Variables: { session: Session } }; -const app = new Hono(); +import { start } from "$fresh/server.ts"; +import manifest from "./fresh.gen.ts"; +import config from "./fresh.config.ts"; -app.use("/static/*", serveStatic({ root: "./pages/" })); - -const store = new CookieStore(); -const encryptionKey = "FeurFeurFeurFeurFeurFeurFeurFeurFeurFeurFeurFeurFeurFeurFeurFeurFeurFeurFeurFeur"; -app.use("*", sessionMiddleware({ store, encryptionKey })); - -app.get("/", (c) => c.redirect("/home")); - -import HomePage from "./pages/home.tsx"; -app.get("/home", (c) => c.html(HomePage(c))); - -import LoginPage from "./pages/login.tsx"; -app.get("/login", (c) => c.html(LoginPage())); - -import { login_route } from "./api/login.ts"; -app.post("/api/login", (c) => login_route(c)); - -import { logout_route } from "./api/logout.ts"; -app.get("/api/logout", (c) => logout_route(c)); - -import UserPage from "./pages/user.tsx"; -app.get("/user", async (c) => await UserPage(c)); - -Deno.serve(app.fetch); +await start(manifest, config); diff --git a/pages/components/base.tsx b/pages/components/base.tsx deleted file mode 100644 index eacc13a..0000000 --- a/pages/components/base.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/** @jsx jsx */ - -import { PropsWithChildren, jsx } from "https://deno.land/x/hono@v4.3.10/middleware.ts" -import { _css } from "../../lib/utils.ts"; - -export function BasePage({ name, children }: PropsWithChildren<{ name: string }>) { - return ( - - - - {name} - Twifeur - - - - {children} - - - ) -} diff --git a/pages/components/heading.tsx b/pages/components/heading.tsx deleted file mode 100644 index 8fb551e..0000000 --- a/pages/components/heading.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/** @jsx jsx */ - -import { PropsWithChildren, jsx } from "https://deno.land/x/hono@v4.3.10/middleware.ts" -import { _css } from "../../lib/utils.ts"; -import { User } from "../../lib/storage.ts"; - -// -export default function Heading({ username }: PropsWithChildren<{username: string | null}>) { - return ( -
-
-
-

TwiFeur

-
- - - - {(username !== null) && } -
-
- ) -} - -function MenuItem({ name, location }: { name: string, location: string }) { - return ( - -

{name}

-
- ) -} diff --git a/pages/components/login.tsx b/pages/components/login.tsx deleted file mode 100644 index b926bc3..0000000 --- a/pages/components/login.tsx +++ /dev/null @@ -1,13 +0,0 @@ -/** @jsx jsx */ - -import { jsx } from "https://deno.land/x/hono@v4.3.10/middleware.ts" - -export default function Login() { - return ( -
- - - -
- ) -} diff --git a/pages/components/main.tsx b/pages/components/main.tsx deleted file mode 100644 index e6eb672..0000000 --- a/pages/components/main.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** @jsx jsx */ - -import { PropsWithChildren, jsx, Fragment } from "https://deno.land/x/hono@v4.3.10/middleware.ts" -import { _css } from "../../lib/utils.ts"; - -export default function Main({ children }: PropsWithChildren) { - return ( - -
-
-
- {children} -
-
-
-
-
- Footer -
-
-
- ) -} diff --git a/pages/components/post.tsx b/pages/components/post.tsx deleted file mode 100644 index 91aea6e..0000000 --- a/pages/components/post.tsx +++ /dev/null @@ -1,12 +0,0 @@ -/** @jsx jsx */ - -import { PropsWithChildren, jsx } from "https://deno.land/x/hono@v4.3.10/middleware.ts" -import { _css } from "../../lib/utils.ts"; - -export function Post({ children, name }: PropsWithChildren<{ name: string }>) { - return ( -
- -
- ) -} diff --git a/pages/home.tsx b/pages/home.tsx deleted file mode 100644 index 795de82..0000000 --- a/pages/home.tsx +++ /dev/null @@ -1,20 +0,0 @@ -/** @jsx jsx */ - -import { jsx } from "https://deno.land/x/hono@v4.3.10/middleware.ts" -import { BasePage } from "./components/base.tsx" -import Main from "./components/main.tsx"; -import Heading from "./components/heading.tsx"; -import { get_user } from "../lib/auth.ts"; -import { Context } from "https://deno.land/x/hono@v4.3.10/mod.ts"; -import { FeurEnv } from "../main.ts"; -import { BlankInput } from "https://deno.land/x/hono@v4.3.10/types.ts"; - -export default async function HomePage(context: Context) { - const user = await get_user(context); - return ( - - -
Main
-
- ) -} diff --git a/pages/login.tsx b/pages/login.tsx deleted file mode 100644 index 1eeab01..0000000 --- a/pages/login.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/** @jsx jsx */ - -import { jsx } from "https://deno.land/x/hono@v4.3.10/middleware.ts" -import { BasePage } from "./components/base.tsx"; -import Heading from "./components/heading.tsx"; -import Main from "./components/main.tsx"; -import Login from "./components/login.tsx"; - -export default function LoginPage() { - return ( - -
-

Login

- -
-
- ) -} diff --git a/pages/static/style.css b/pages/static/style.css deleted file mode 100644 index 9c3f605..0000000 --- a/pages/static/style.css +++ /dev/null @@ -1,23 +0,0 @@ -html, -body, -header, -main, -footer { - margin: 0; - padding: 0; -} - -h1 { - font-size: 1.5rem; -} - -h3 { - font-size: 1rem; -} - -body { - background-color: #181818; - color: #eee; - font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif; - overflow-x: hidden -} \ No newline at end of file diff --git a/pages/user.tsx b/pages/user.tsx deleted file mode 100644 index 779c2d9..0000000 --- a/pages/user.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/** @jsx jsx */ - -import { jsx } from "https://deno.land/x/hono@v4.3.10/middleware.ts" -import { BasePage } from "./components/base.tsx"; -import Heading from "./components/heading.tsx"; -import Main from "./components/main.tsx"; -import Login from "./components/login.tsx"; -import { User } from "../lib/storage.ts"; -import { Context } from "https://deno.land/x/hono@v4.3.10/mod.ts"; -import { FeurEnv } from "../main.ts"; -import { BlankInput } from "https://deno.land/x/hono@v4.3.10/types.ts"; -import { get_user } from "../lib/auth.ts"; - -export default async function UserPage(context: Context) { - const user = await get_user(context); - if (user === null) return context.text("Must be logged.", 401); - return context.html( - -
-

Logged as {await user.get("username")}

-
-
- ) -} diff --git a/routes/_404.tsx b/routes/_404.tsx new file mode 100644 index 0000000..c63ae2e --- /dev/null +++ b/routes/_404.tsx @@ -0,0 +1,27 @@ +import { Head } from "$fresh/runtime.ts"; + +export default function Error404() { + return ( + <> + + 404 - Page not found + +
+
+ the Fresh logo: a sliced lemon dripping with juice +

404 - Page not found

+

+ The page you were looking for doesn't exist. +

+ Go back home +
+
+ + ); +} diff --git a/routes/_app.tsx b/routes/_app.tsx new file mode 100644 index 0000000..c280e2b --- /dev/null +++ b/routes/_app.tsx @@ -0,0 +1,16 @@ +import { type PageProps } from "$fresh/server.ts"; +export default function App({ Component }: PageProps) { + return ( + + + + + twifeur + + + + + + + ); +} diff --git a/routes/_middleware.ts b/routes/_middleware.ts new file mode 100644 index 0000000..6057b2e --- /dev/null +++ b/routes/_middleware.ts @@ -0,0 +1,40 @@ +import { FreshContext } from "$fresh/server.ts"; +import { deleteCookie, getCookies, setCookie } from "$std/http/cookie.ts"; +import { auth, Token } from "../auth/auth.ts"; + +type State = { + user_id: string | null; +}; + +export async function handler( + req: Request, + ctx: FreshContext, +) { + const cookies = getCookies(req.headers); + const token = get_session_token(cookies); + + ctx.state.user_id = token?.user_id ?? null; + + const resp = await ctx.next(); + set_session_token(resp.headers, token); + return resp; +} + +function get_session_token(cookies: Record) { + const stored = cookies["auth_token"]; + if (stored === undefined) return null; + const token = auth.tokens.get(stored); + if (token === null) return null; + return token; +} + +function set_session_token(headers: Headers, token: Token | null) { + if (token === null) { + deleteCookie(headers, "auth_token"); + return; + } + setCookie(headers, { + name: "auth_token", + value: token.raw, + }); +} diff --git a/routes/api/joke.ts b/routes/api/joke.ts new file mode 100644 index 0000000..db17edd --- /dev/null +++ b/routes/api/joke.ts @@ -0,0 +1,21 @@ +import { FreshContext } from "$fresh/server.ts"; + +// Jokes courtesy of https://punsandoneliners.com/randomness/programmer-jokes/ +const JOKES = [ + "Why do Java developers often wear glasses? They can't C#.", + "A SQL query walks into a bar, goes up to two tables and says “can I join you?”", + "Wasn't hard to crack Forrest Gump's password. 1forrest1.", + "I love pressing the F5 key. It's refreshing.", + "Called IT support and a chap from Australia came to fix my network connection. I asked “Do you come from a LAN down under?”", + "There are 10 types of people in the world. Those who understand binary and those who don't.", + "Why are assembly programmers often wet? They work below C level.", + "My favourite computer based band is the Black IPs.", + "What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.", + "An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.", +]; + +export const handler = (_req: Request, _ctx: FreshContext): Response => { + const randomIndex = Math.floor(Math.random() * JOKES.length); + const body = JOKES[randomIndex]; + return new Response(body); +}; diff --git a/pages/components/footer.tsx b/routes/feur.tsx similarity index 100% rename from pages/components/footer.tsx rename to routes/feur.tsx diff --git a/routes/greet/[name].tsx b/routes/greet/[name].tsx new file mode 100644 index 0000000..9c06827 --- /dev/null +++ b/routes/greet/[name].tsx @@ -0,0 +1,5 @@ +import { PageProps } from "$fresh/server.ts"; + +export default function Greet(props: PageProps) { + return
Hello {props.params.name}
; +} diff --git a/routes/index.tsx b/routes/index.tsx new file mode 100644 index 0000000..67a22a7 --- /dev/null +++ b/routes/index.tsx @@ -0,0 +1,25 @@ +import { useSignal } from "@preact/signals"; +import Counter from "../islands/Counter.tsx"; + +export default function Home() { + const count = useSignal(3); + return ( +
+
+ the Fresh logo: a sliced lemon dripping with juice +

Welcome to Fresh

+

+ Try updating this message in the + ./routes/index.tsx file, and refresh. +

+ +
+
+ ); +} diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..1cfaaa2 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/logo.svg b/static/logo.svg new file mode 100644 index 0000000..ef2fbe4 --- /dev/null +++ b/static/logo.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..e94132d --- /dev/null +++ b/static/styles.css @@ -0,0 +1,129 @@ + +*, +*::before, +*::after { + box-sizing: border-box; +} +* { + margin: 0; +} +button { + color: inherit; +} +button, [role="button"] { + cursor: pointer; +} +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-size: 1em; +} +img, +svg { + display: block; +} +img, +video { + max-width: 100%; + height: auto; +} + +html { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, + "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; +} +.transition-colors { + transition-property: background-color, border-color, color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} +.my-6 { + margin-bottom: 1.5rem; + margin-top: 1.5rem; +} +.text-4xl { + font-size: 2.25rem; + line-height: 2.5rem; +} +.mx-2 { + margin-left: 0.5rem; + margin-right: 0.5rem; +} +.my-4 { + margin-bottom: 1rem; + margin-top: 1rem; +} +.mx-auto { + margin-left: auto; + margin-right: auto; +} +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} +.py-8 { + padding-bottom: 2rem; + padding-top: 2rem; +} +.bg-\[\#86efac\] { + background-color: #86efac; +} +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} +.py-6 { + padding-bottom: 1.5rem; + padding-top: 1.5rem; +} +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} +.py-1 { + padding-bottom: 0.25rem; + padding-top: 0.25rem; +} +.border-gray-500 { + border-color: #6b7280; +} +.bg-white { + background-color: #fff; +} +.flex { + display: flex; +} +.gap-8 { + grid-gap: 2rem; + gap: 2rem; +} +.font-bold { + font-weight: 700; +} +.max-w-screen-md { + max-width: 768px; +} +.flex-col { + flex-direction: column; +} +.items-center { + align-items: center; +} +.justify-center { + justify-content: center; +} +.border-2 { + border-width: 2px; +} +.rounded { + border-radius: 0.25rem; +} +.hover\:bg-gray-200:hover { + background-color: #e5e7eb; +} +.tabular-nums { + font-variant-numeric: tabular-nums; +} diff --git a/utils.ts b/utils.ts new file mode 100644 index 0000000..749ad4d --- /dev/null +++ b/utils.ts @@ -0,0 +1,7 @@ +import { dirname } from "$std/path/dirname.ts"; + +export function project_root_dir() { + const this_url = new URL(import.meta.url); + const this_dir = dirname(this_url.pathname); + return this_dir + "/"; +}