personal website

import blogs from thoughts-zeudev

+3 -2
astro.config.mjs
··· 1 1 import { defineConfig } from 'astro/config'; 2 - 3 2 import tailwind from "@astrojs/tailwind"; 4 3 import svelte from "@astrojs/svelte"; 4 + 5 + import mdx from "@astrojs/mdx"; 5 6 6 7 // https://astro.build/config 7 8 export default defineConfig({ 8 - integrations: [tailwind(), svelte()] 9 + integrations: [tailwind(), svelte(), mdx()] 9 10 });
bun.lockb

This is a binary file and will not be displayed.

+4
package.json
··· 11 11 }, 12 12 "dependencies": { 13 13 "@astrojs/check": "^0.9.3", 14 + "@astrojs/mdx": "^3.1.5", 14 15 "@astrojs/svelte": "^5.7.0", 15 16 "@astrojs/tailwind": "^5.1.0", 16 17 "astro": "^4.14.6", 17 18 "svelte": "^5.0.0-next.241", 18 19 "tailwindcss": "^3.4.10", 19 20 "typescript": "^5.5.4" 21 + }, 22 + "devDependencies": { 23 + "@tailwindcss/typography": "^0.5.15" 20 24 } 21 25 }
public/Back to Basics Making a Node js Web Application 9447f567860d464283ee35f0bda5f2d2/2023-10-30_14-49.png

This is a binary file and will not be displayed.

public/Back to Basics Making a Node js Web Application 9447f567860d464283ee35f0bda5f2d2/Untitled 1.png

This is a binary file and will not be displayed.

public/Back to Basics Making a Node js Web Application 9447f567860d464283ee35f0bda5f2d2/Untitled 2.png

This is a binary file and will not be displayed.

public/Back to Basics Making a Node js Web Application 9447f567860d464283ee35f0bda5f2d2/Untitled 3.png

This is a binary file and will not be displayed.

public/Back to Basics Making a Node js Web Application 9447f567860d464283ee35f0bda5f2d2/Untitled.png

This is a binary file and will not be displayed.

public/Back to Basics Making a Node js Web Application 9447f567860d464283ee35f0bda5f2d2/diagram-export-10-28-2023-3_06_37-AM.png

This is a binary file and will not be displayed.

public/Back to Basics Making a Node js Web Application 9447f567860d464283ee35f0bda5f2d2/shapes_at_23-10-30_16.46.52.png

This is a binary file and will not be displayed.

+9
public/brain-svgrepo-com.svg
··· 1 + <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> 2 + <!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools --> 3 + <svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 4 + <g id="SVGRepo_bgCarrier" stroke-width="0"/> 5 + <g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"/> 6 + <g id="SVGRepo_iconCarrier"> 7 + <path d="M19.864 8.465a3.505 3.505 0 0 0-3.03-4.449A3.005 3.005 0 0 0 14 2a2.98 2.98 0 0 0-2 .78A2.98 2.98 0 0 0 10 2c-1.301 0-2.41.831-2.825 2.015a3.505 3.505 0 0 0-3.039 4.45A4.028 4.028 0 0 0 2 12c0 1.075.428 2.086 1.172 2.832A4.067 4.067 0 0 0 3 16c0 1.957 1.412 3.59 3.306 3.934A3.515 3.515 0 0 0 9.5 22c.979 0 1.864-.407 2.5-1.059A3.484 3.484 0 0 0 14.5 22a3.51 3.51 0 0 0 3.19-2.06 4.006 4.006 0 0 0 3.138-5.108A4.003 4.003 0 0 0 22 12a4.028 4.028 0 0 0-2.136-3.535zM9.5 20c-.711 0-1.33-.504-1.47-1.198L7.818 18H7c-1.103 0-2-.897-2-2 0-.352.085-.682.253-.981l.456-.816-.784-.51A2.019 2.019 0 0 1 4 12c0-.977.723-1.824 1.682-1.972l1.693-.26-1.059-1.346a1.502 1.502 0 0 1 1.498-2.39L9 6.207V5a1 1 0 0 1 2 0v13.5c0 .827-.673 1.5-1.5 1.5zm9.575-6.308-.784.51.456.816c.168.3.253.63.253.982 0 1.103-.897 2-2.05 2h-.818l-.162.802A1.502 1.502 0 0 1 14.5 20c-.827 0-1.5-.673-1.5-1.5V5c0-.552.448-1 1-1s1 .448 1 1.05v1.207l1.186-.225a1.502 1.502 0 0 1 1.498 2.39l-1.059 1.347 1.693.26A2.002 2.002 0 0 1 20 12c0 .683-.346 1.315-.925 1.692z"/> 8 + </g> 9 + </svg>
public/rnlive-overview.png

This is a binary file and will not be displayed.

public/rnlive-partykit 4fb551f2d806451192762f4484efdba2/Untitled.png

This is a binary file and will not be displayed.

public/rnlive-partykit 4fb551f2d806451192762f4484efdba2/shapes.png

This is a binary file and will not be displayed.

public/rnlive-specifics.png

This is a binary file and will not be displayed.

+22
src/components/BlogCard.astro
··· 1 + --- 2 + import type { CollectionEntry } from "astro:content"; 3 + 4 + export interface Props { 5 + blog : CollectionEntry<'blog'> 6 + } 7 + 8 + const { blog } = Astro.props; 9 + 10 + const directory = blog.collection + "/"; 11 + --- 12 + 13 + <a href={directory + blog.slug}> 14 + <article 15 + class="flex flex-row justify-between items-center w-full h-fit px-8 py-4 border-2 text-white rounded-md"> 16 + <div class="flex flex-col gap-2 !!no-underline "> 17 + <h1 class="text-3xl font-bold">{blog.data.title}</h1> 18 + <p class="text-lg italic">{blog.data.description}</p> 19 + </div> 20 + <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M13.22 19.03a.75.75 0 0 1 0-1.06L18.19 13H3.75a.75.75 0 0 1 0-1.5h14.44l-4.97-4.97a.749.749 0 0 1 .326-1.275a.749.749 0 0 1 .734.215l6.25 6.25a.75.75 0 0 1 0 1.06l-6.25 6.25a.75.75 0 0 1-1.06 0Z"/></svg> 21 + </article> 22 + </a>
+8
src/components/CodePreview.astro
··· 1 + <section class="flex flex-col"> 2 + <div class="border-2 rounded-t-xl px-8 py-4"> 3 + <slot name="preview" /> 4 + </div> 5 + <div class="border-2 rounded-b-xl px-8 py-4"> 6 + <slot name="code" /> 7 + </div> 8 + </section>
+13 -15
src/components/SiteLayout.astro
··· 11 11 <title>{ title }</title> 12 12 </head> 13 13 <body class="relative font-syne bg-neutral-900 text-yellow-500 grid grid-cols-1 md:grid-cols-5 w-full h-full min-w-screen min-h-screen"> 14 - <aside class="flex items-center border-yellow-500 justify-between md:border-r-2 w-full md:w-fit md:justify-start md:items-start h-fit md:h-full md:flex-col gap-4 font-medium p-4"> 14 + <aside class="sticky top-0 max-h-screen flex items-center border-yellow-500 justify-between md:border-r-2 w-full md:w-fit md:justify-start md:items-start h-fit md:h-full md:flex-col gap-4 font-medium p-4"> 15 15 <div class="flex flex-col gap-2 text-xl"> 16 - <h1>zeu.dev</h1> 16 + <h1> 17 + <a href="/" class="after:content-['_->']">zeu.dev</a> 18 + </h1> 17 19 <h2 class="hidden md:flex">all things me!</h2> 18 20 </div> 19 21 ··· 26 28 27 29 <section class="flex flex-col gap-2 text-white"> 28 30 <h3 class="text-yellow-500">socials</h3> 29 - <a href="https://twitter.com/zeu_dev">twitter</a> 30 - <a href="https://github.com/zeucapua">github</a> 31 - <a href="https://twitch.tv/zeu_dev">twitch</a> 31 + <a href="https://twitter.com/zeu_dev" target="_blank" class="after:content-['_↗']">twitter</a> 32 + <a href="https://github.com/zeucapua" target="_blank" class="after:content-['_↗']">github</a> 33 + <a href="https://twitch.tv/zeu_dev" target="_blank" class="after:content-['_↗']">twitch</a> 32 34 </section> 33 35 34 36 <hr class="border-yellow-500 " /> 35 37 36 38 <section class="flex flex-col gap-2 text-white"> 37 - <h3 class="text-yellow-500">blog</h3> 38 - <p>skogz: diy svelte 5 ssr router</p> 39 - <a href="/blog">more {"->"}</a> 39 + <h3 class="text-yellow-500"> 40 + <a href="/blog" class="after:content-['_->']">blog</a> 41 + </h3> 40 42 </section> 41 43 42 44 <hr class="border-yellow-500 " /> 43 45 44 46 <section class="flex flex-col gap-2 text-white"> 45 - <h3 class="text-yellow-500">related</h3> 46 - <a href="https://easytodo.link">easytodo.link</a> 47 - <a href="https://app.opensauced.pizza">OpenSauced</a> 47 + <h3 class="text-yellow-500">some works</h3> 48 + <a href="https://app.opensauced.pizza" target="_blank" class="after:content-['_↗']">OpenSauced</a> 49 + <a href="https://easytodo.link" target="_blank" class="after:content-['_↗']">easytodo.link</a> 48 50 </section> 49 51 </nav> 50 52 </aside> ··· 64 66 @font-face { 65 67 font-family: "SyneMono"; 66 68 src: url("/SyneMono-Regular.ttf"); 67 - } 68 - 69 - a { 70 - text-decoration-line: underline; 71 69 } 72 70 </style>
+453
src/content/blog/basics-node-app.md
··· 1 + --- 2 + title: "Back to Basics: Making a Node.js Web Application" 3 + description: Taking a break from Javascript (meta) frameworks and making a web application and website with Hono and Node.js as the foundation. 4 + date: "2023-10-28" 5 + draft: false 6 + link: https://github.com/zeucapua/robin-tutorial 7 + --- 8 + 9 + ### Why? 10 + 11 + For my latest project, I wanted to get away from the bustling modern world of JS (meta) frameworks and return to the basics. Since I just started learning web development over a year ago, I’ve only been learning abstractions based on any given UI framework. But I wanted to know if there is a simpler way to understand and make small web applications? Here are my notes on how to make a small web application from start to finish! 12 + 13 + ### What are we building? 14 + 15 + Robin is a project time tracker, inspired by [Watson CLI](https://github.com/TailorDev/Watson) tool. A user can create projects and simply clock in and clock out of a session. All sessions are counted to get a total time spent doing projects. The front end will allow for simple CRUD actions to manage the data. 16 + 17 + ### The Stack 18 + 19 + Robin will be a Node.js (Node) web application, built with [Hono](http://hono.dev) as our server framework. Deployed on [Railway](http://railway.app) alongside a PostgreSQL database. The database is managed and query using [Drizzle ORM](http://orm.drizzle.team). We will be setting up the project so that we can create a front-end website using `tsx` components with [HTMX](http://htmx.org) for a future follow-up blog. 20 + 21 + If you want to see the codebase, check out the annotated [Github repository](https://github.com/zeucapua/robin-tutorial) and give it a star if you found it useful! 22 + 23 + ### How does it work? 24 + 25 + ![shapes at 23-10-30 16.46.52.png](/Back%20to%20Basics%20Making%20a%20Node%20js%20Web%20Application%209447f567860d464283ee35f0bda5f2d2/shapes_at_23-10-30_16.46.52.png) 26 + 27 + Before writing any code, I think we should take a step back and check how websites work. When someone goes to a URL, the browser makes an HTTP GET request to the index endpoint. Endpoints are how clients, like our browser, can interact and tell the server to do things. In this case, the server starts turning the TSX template we wrote into HTML and returns it back with any Javascript to the browser. The browser then takes the HTML and JS to render the page so the user can look and interact with it. 28 + 29 + To put it in other words, we deal with a client making an HTTP request to the server that responds back with data we can parse and use. We can put all of our pages and CRUD actions into server endpoints that we can interact with. 30 + 31 + ### Author’s Note 32 + 33 + This blog assumes **NOTHING** of the reader. That means that this blog will have sections setting up the project in *painfully detailed step-by-step instructions.* However, I will not be going over installing terminal commands like `npm/pnpm` , `tsc` , `git`, `gh`, etc. I will try my darnedest not to be sidetracked, and keep my focus on creating and deploying a Node.js web application, but no promises. 34 + 35 + ### Installation 36 + 37 + Here’s how to get started (using a terminal): 38 + 39 + - Create a new folder (`mkdir robin-tutorial` ) and go inside it (`cd robin-tutorial`) 40 + - We’ll start a new Node project by using `pnpm init`, which should generate a `package.json` file. 41 + - For this tutorial, we will be using `pnpm` , but `npm` should be similar (`pnpm init` = `npm init` , `pnpm add` = `npm install` , etc.) 42 + - From here we have to install our packages, which in our case are: 43 + - Hono (our server framework): `pnpm add hono @hono/node-server` 44 + - Dotenv (to access our `.env` variables): `pnpm add dotenv` 45 + - Drizzle ORM (to manipulate our database): `pnpm add drizzle-orm pg` & `pnpm add -D drizzle-kit @types/pg` 46 + - TSX (our HTML templates in TS): `pnpm add -D tsx` 47 + - Before moving on, you can look inside the folder to ensure that we have a `node_modules` folder, `package.json` file (which we change in a moment), and a `pnpm-lock.yaml` file (I assume this sets the packages’ version). 48 + - To setup TSX, run `tsc --init` to create a `tsconfig.json` that we will edit to ensure the following properties are not commented. Use a text editor to recreate the following: 49 + 50 + ```json 51 + { 52 + "compilerOptions": { 53 + "target": "es2016", 54 + "jsx": "react-jsx", 55 + // some stuff... 56 + "jsxImportSource": "hono/jsx", 57 + 58 + // some stuff... 59 + // the following are already set by `tsc --init`, but make sure anyway! 60 + "module": "commonjs", 61 + "esModuleInterop": true, 62 + "forceConsistentCasingInFileNames": true, 63 + "strict": true, 64 + "skipLibCheck": true 65 + } 66 + } 67 + ``` 68 + 69 + - Afterwards, let’s add a new `src` folder with our files inside: `index.tsx` (our app’s entry point), `components.tsx` (our JSX templates), and `schema.ts` (used to model our database with Drizzle). 70 + - Lastly, let’s modify our `package.json` and change our main file and add scripts to run our application, including some for using Drizzle (will be explained later): 71 + 72 + ```json 73 + { 74 + // ... 75 + "main": "src/index.tsx", 76 + "scripts": { 77 + "start": "tsx src/index.tsx", 78 + 79 + // for drizzle, will be used later 80 + "generate": "drizzle-kit generate:pg", 81 + "migrate": "drizzle-kit migrate:pg" 82 + }, 83 + // ... 84 + } 85 + ``` 86 + 87 + ### Did you know Hono means ‘Fire’ in Japanese? 88 + 89 + Hono is a Node server framework which makes coding endpoints easy. Other similar frameworks would be Elysia, Fastify, and Express. 90 + 91 + To start our project, start by creating a new `Hono` object and subsequently call functions with the appropriate HTTP request and endpoint. Afterwards export and serve the web app. This will be inside our `index.tsx` file. 92 + 93 + ```tsx 94 + // index.tsx 95 + // --------------------------------------- 96 + 97 + /* 🪂 Import packages (installed via npm/pnpm) */ 98 + 99 + // Hono packages 100 + import { Hono } from 'hono'; 101 + import { serve } from "@hono/node-server"; 102 + 103 + // loads environment variables from `.env`, will be used later 104 + import * as dotenv from "dotenv"; 105 + dotenv.config(); 106 + 107 + // --------------------------------------- 108 + 109 + /* 🏗️ Configure Hono Web Application */ 110 + 111 + // initialize web application 112 + const app = new Hono(); 113 + 114 + // --------------------------------------- 115 + 116 + /* 🛣️ Route Endpoints */ 117 + 118 + // GET index page 119 + app.get("/", async (c) => { 120 + // return HTML response 121 + return c.html( 122 + <h1>Hello world!</h1> 123 + ); 124 + }); 125 + 126 + export default app; 127 + 128 + // --------------------------------------- 129 + 130 + /* 🚀 Deployment */ 131 + 132 + // use `.env` set PORT, for Railway deployment 133 + const PORT = Number(process.env.PORT) || 3000; 134 + 135 + // become a server, to deploy as Node.js app on Railway 136 + serve({ 137 + fetch: app.fetch, 138 + port: PORT 139 + }); 140 + 141 + // --------------------------------------- 142 + ``` 143 + 144 + Now going back to the terminal, we can run our web application by using the start script from the `package.json` file that we set up earlier: `pnpm run start`. Use the browser and go to `[http://localhost:3000](http://localhost:3000)` and you should be greeted with a big bold “**Hello world!”** 145 + 146 + <aside> 147 + 💡 If you’re familiar with modern JS (meta) frameworks, making any changes while a development (dev) server is running will cause a re-render, allowing you to see changes in styling, for example. This is because of HMR (Hot Module Reloading). We **don’t** have HMR in this project. So any further changes will require you to stop (`ctrl-c` in the terminal) and restart the dev server (`pnpm run start`). 148 + 149 + </aside> 150 + 151 + ### Database Setup with Drizzle (fo’ shizzle) 152 + 153 + Now that we have the basic web application setup, let’s move our focus onto the database that we’ll use for our time tracking functions. Drizzle ORM (Object-Relational Mapping) is a library to manage and communicate with the database via Typescript (TS) code. We can use the ORM to create the source of truth for the database’s schema. Let’s set it (and our hosted DB) up! 154 + 155 + - Provision a new PostgreSQL (postgres) database on Railway by creating a new project. 156 + 157 + ![Untitled](/Back%20to%20Basics%20Making%20a%20Node%20js%20Web%20Application%209447f567860d464283ee35f0bda5f2d2/Untitled.png) 158 + 159 + - Once deployed, go to the **Variables** tab on the postgres service and copy the `DATABASE_URL` value… 160 + 161 + ![Untitled](/Back%20to%20Basics%20Making%20a%20Node%20js%20Web%20Application%209447f567860d464283ee35f0bda5f2d2/Untitled%201.png) 162 + 163 + - …which we will add to a new `.env` file in our root directory. 164 + 165 + ``` 166 + # .env 167 + DATABASE_URL=postgresql://<username>:<password>@<location>:<port>/<dbname> 168 + ``` 169 + 170 + - Moving on, we now need to define the shape of our data in our `schema.ts` file using Drizzle: 171 + 172 + ```tsx 173 + // schema.ts 174 + // --------------------------------------- 175 + 176 + /* Import packages (installed via npm/pnpm) */ 177 + // drizzle-orm packages 178 + import { relations } from "drizzle-orm"; 179 + import { pgTable, serial, timestamp, varchar } from "drizzle-orm/pg-core"; 180 + 181 + // --------------------------------------- 182 + 183 + /* Data Models */ 184 + // >> find more information on defining the schema: 185 + // >> https://orm.drizzle.team/docs/sql-schema-declaration 186 + export const projects = pgTable("projects", { 187 + id: serial("id").primaryKey(), 188 + name: varchar("name", { length: 100 }).unique() 189 + }); 190 + 191 + export const sessions = pgTable("sessions", { 192 + id: serial("id").primaryKey(), 193 + start: timestamp("start").defaultNow(), 194 + end: timestamp("end"), 195 + projectName: varchar("project_name").notNull() 196 + }); 197 + 198 + /* Relationships Between Models */ 199 + // find more information on declaring relations: 200 + // https://orm.drizzle.team/docs/rqb#declaring-relations 201 + export const projects_relations = relations(projects, ({ many }) => ({ 202 + sessions: many(sessions) 203 + })); 204 + 205 + export const sessions_relations = relations(sessions, ({ one }) => ({ 206 + project: one(projects, { 207 + fields: [sessions.projectName], 208 + references: [projects.name] 209 + }) 210 + })); 211 + 212 + // --------------------------------------- 213 + ``` 214 + 215 + This schema will create a one-to-many relationship where a **project** can have multiple **sessions**. Visually it’ll look like so, thanks to [DiagramGPT](https://www.eraser.io/diagramgpt): 216 + 217 + ![diagram-export-10-28-2023-3_06_37-AM.png](/Back%20to%20Basics%20Making%20a%20Node%20js%20Web%20Application%209447f567860d464283ee35f0bda5f2d2/diagram-export-10-28-2023-3_06_37-AM.png) 218 + 219 + - To turn this schema into our database’s tables, we need to create a `drizzle.config.ts` file in the root directory to setup the migration correctly, giving it the schema file, the folder that will hold the migrations, and the `DATABASE_URL` as the connection string to the database. 220 + 221 + ```tsx 222 + // --------------------------------------- 223 + 224 + /* Import packages (installed via npm/pnpm) */ 225 + 226 + // to type check the configuration 227 + import type { Config } from "drizzle-kit"; 228 + 229 + // load .env variables 230 + import * as dotenv from "dotenv"; 231 + dotenv.config(); 232 + 233 + // --------------------------------------- 234 + 235 + /* declare Drizzle config */ 236 + export default { 237 + schema: "./src/schema.ts", 238 + out: "./drizzle", 239 + driver: "pg", 240 + dbCredentials: { 241 + connectionString: process.env.DATABASE_URL as string 242 + } 243 + } satisfies Config 244 + 245 + // --------------------------------------- 246 + ``` 247 + 248 + - Once that is set, we need to generate a SQL migration file using the `generate` script we made earlier inside the `package.json` file, then push the changes with the `migrate` script. 249 + 250 + ``` 251 + # scripts declared in 'package.json' 252 + 253 + # runs 'drizzle-kit generate:pg' 254 + pnpm run generate 255 + 256 + # runs 'drizzle-kit push:pg' 257 + pnpm run migrate 258 + ``` 259 + 260 + - Check your Railway deployment to see if the migration went through by ensuring our **projects** and **sessions** tables are in the postgres’ data tab. 261 + 262 + ![Untitled](/Back%20to%20Basics%20Making%20a%20Node%20js%20Web%20Application%209447f567860d464283ee35f0bda5f2d2/Untitled%202.png) 263 + 264 + - Finally, import the relevant packages and setup the Drizzle client ready for use in the next 265 + 266 + ```tsx 267 + // index.tsx 268 + // --------------------------------------- 269 + 270 + /* 🪂 Import pacakages (installed via npm/pnpm) */ 271 + // ... 272 + 273 + // Database Driver 274 + import { Pool } from "pg"; 275 + 276 + // Drizzle ORM packages 277 + import * as schema from "./schema"; 278 + import { desc, eq } from "drizzle-orm"; 279 + import { drizzle } from "drizzle-orm/node-postgres"; 280 + 281 + // --------------------------------------- 282 + 283 + /* 🏗️ Configure Hono Web Application */ 284 + // ... 285 + 286 + // create pool connection to database 287 + const pool = new Pool({ 288 + connectionString: process.env.DATABASE_URL 289 + }); 290 + 291 + // initialize ORM client with schema types 292 + const database = drizzle(pool, { schema }); 293 + 294 + // --------------------------------------- 295 + ``` 296 + 297 + ### Implementing CRUD API with HTML Endpoints 298 + 299 + Let’s implement the `GET` and `POST` HTTP endpoints to create and read **projects** to demonstrate how it’s written in Hono. Endpoints are made by calling the HTTP verbs’ function on the `app` variable, passing a string representing the path and an async function with the context as a parameter. Here, the context (`c`) is used to handle both the incoming `Request` and outgoing `Response`. 300 + 301 + ```tsx 302 + // index.tsx 303 + // --------------------------------------- 304 + 305 + /* 🛣️ Route Endpoints */ 306 + // ... 307 + 308 + // GET: return project by name 309 + app.get("/api/project/:name", async (c) => { 310 + // get route parameter (denoted with ':') 311 + const name = c.req.param("name") as string; 312 + 313 + // query database to find project with name 314 + const result = await database.query.projects.findFirst({ 315 + where: eq(schema.projects.name, name) 316 + }); 317 + 318 + // return JSON response 319 + return c.json({ result }); 320 + }); 321 + 322 + // POST: create new project with name 323 + app.post("/api/project/:name", async (c) => { 324 + // get route parameter (denoted with ':') 325 + const name = c.req.param("name") as string; 326 + 327 + // create a new project 328 + const result = await database 329 + .insert(schema.projects) 330 + .values({ name }) 331 + .returning(); 332 + 333 + // return JSON response 334 + return c.json({ result: result[0] }); 335 + }); 336 + ``` 337 + 338 + For this code snippet, the endpoints will run database queries and inserts with our Drizzle client based on the name given as part of the path and then return the results. We separate these functions with different HTTP verbs, even if they are under the same path/endpoint. 339 + 340 + Now what are projects but holders of our sessions. Implementing these aren’t going to be as easy as our project endpoints since we need to ensure that all sessions started must end, as well as ensuring we are returning null if there is no latest session for the project. 341 + 342 + ```tsx 343 + // index.tsx 344 + // --------------------------------------- 345 + 346 + /* 🛣️ Route Endpoints */ 347 + // ... 348 + 349 + // GET latest session under project name 350 + app.get("/api/session/:name", async (c) => { 351 + const name = c.req.param("name") as string; 352 + 353 + // get latest session 354 + const latest = await database.query.sessions.findFirst({ 355 + where: eq(schema.sessions.projectName, name), 356 + orderBy: [desc(schema.sessions.start)] 357 + }); 358 + 359 + // return null if latest is undefined 360 + return c.json({ result: latest ?? null }); 361 + }); 362 + 363 + // POST create a new session under project name 364 + app.post("/api/session/:name", async (c) => { 365 + const name = c.req.param("name") as string; 366 + 367 + // get latest session 368 + const latest = await database.query.sessions.findFirst({ 369 + where: eq(schema.sessions.projectName, name), 370 + orderBy: [desc(schema.sessions.start)] 371 + }); 372 + 373 + // if no session OR latest already has an end time, then create a new session 374 + // else end the current session 375 + if (!latest || latest.end !== null) { 376 + const result = await database 377 + .insert(schema.sessions) 378 + .values({ projectName: name }) 379 + .returning(); 380 + 381 + return c.json({ result: result[0] }); 382 + } 383 + else { 384 + const updated = await database 385 + .update(schema.sessions) 386 + .set({ end: new Date }) 387 + .where( eq(schema.sessions.id, latest.id) ) 388 + .returning(); 389 + 390 + return c.json({ result: updated[0] }); 391 + } 392 + }); 393 + ``` 394 + 395 + Now we can test our application by running a local development (dev) server with `pnpm run start` in a terminal, and then using another to make `curl` requests. The following will make `POST` requests to create a project and session, `GET` the current session, and lastly `POST` to end the latest session. These should give you back JSON responses like those below on each request. 396 + 397 + ```bash 398 + > curl -X POST http://localhost:3000/api/project/coding 399 + {"result":{"id":1,"name":"coding"}} 400 + 401 + > curl -X POST http://localhost:3000/api/session/coding 402 + {"result":{"id":2,"start":"2023-10-29T22:43:25.588Z","end":null,"projectName":"coding"}} 403 + 404 + > curl -X POST http://localhost:3000/api/session/coding 405 + {"result":{"id":2,"start":"2023-10-29T22:43:25.588Z","end":"2023-10-29T22:44:17.350Z","projectName":"coding"}}% 406 + ``` 407 + 408 + ### Git & Github Repository Setup 409 + 410 + We can easily deploy this application by putting this project in a repository on Github and then hosting it in our Railway project alongside our postgres database. Here’s the step by step (according to Notion AI): 411 + 412 + 1. Create a new repository on GitHub. 413 + 2. In your terminal, navigate to the root directory of your project. 414 + 3. Initialize Git in the project folder by running the command: `git init`. 415 + 4. Add all the files in your project to the Git repository by running the command: `git add .`. 416 + 5. Commit the changes by running the command: `git commit -m "Initial commit"`. 417 + 6. Add the remote repository URL as the origin by running the command: `git remote add origin <remote_repository_url>`. 418 + 7. Push the changes to the remote repository by running the command: `git push -u origin master`. 419 + 8. Provide your GitHub username and password when prompted. 420 + 421 + After following these steps, your project will be pushed to GitHub and will be visible in your repository. 422 + 423 + ### Deploying the Node.js Web Application on Railway 424 + 425 + From here, go back to the Railway project and press ‘Add’. Choose ‘Deploy from Github’ and find your repository. It should start deploying right away, **but** we need to change a few settings to get it working properly. 426 + 427 + To connect to our website publicly, we want to go to service’s ‘Settings’, go down to ‘Networking’ and press the ‘Generate Domain’ button. This should give you a URL you can enter with your browser. 428 + 429 + ![2023-10-30_14-49.png](/Back%20to%20Basics%20Making%20a%20Node%20js%20Web%20Application%209447f567860d464283ee35f0bda5f2d2/2023-10-30_14-49.png) 430 + 431 + We also need to give the website access to our postgres database. Before we added the `DATABASE_URL` to a `.env` file, but since that isn’t in our repository (because it can be leaked on Github), Railway makes this easy for us by going to the ‘Variables’ tab and adding a ‘Variable Reference’, where we can add our `DATABASE_URL` variable from the database automatically. 432 + 433 + ![Untitled](/Back%20to%20Basics%20Making%20a%20Node%20js%20Web%20Application%209447f567860d464283ee35f0bda5f2d2/Untitled%203.png) 434 + 435 + And now the project is live online! No need to run a local server, you can now access your endpoint as long as you have internet connection. For example, you can run the same `curl` requests, but now with the live URL (**note**: use `https` , not `http` when using the live URL). 436 + 437 + ```bash 438 + > curl -X POST https://robin-tutorial-production.up.railway.app/api/project/coding 439 + {"result":{"id":1,"name":"coding"}} 440 + ``` 441 + 442 + ### That’s It…. FOR NOW 443 + 444 + We now have a working CRUD web application online! Next steps is to get the TSX setup to use with a new blog on how to use HTMX. This will turn our application to an actual, honest to goodness, functional **website,** like with inputs, buttons, and styling! I’m working hard behind the scenes to learn how to implement HTMX and keep it understandable for you and me 😅 445 + 446 + That’s in the future though! For now, I’d like to thank you for reading this blog. I very much appreciate it, and if you can do me a favor, take a look at the links down below. Catch you in the next one! 447 + 448 + ### Shameless Plugs 449 + 450 + - If you’d like to clone the source code for this project, it is public with a commented repository on my Github [here](https://github.com/zeucapua/robin-tutorial). 451 + - This project was made live on my Twitch stream. Code new projects with me weekly on [twitch.tv/zeu_dev](http://twitch.tv/zeu_dev). 452 + - Any comments or questions can reach me on Twitter. Follow me at [twitter.com/zeu_dev](http://twitter.com/zeu_dev). 453 + - Interested on other stuff? Visit my personal website at [zeu.dev](http://zeu.dev) and my other blogs on [thoughts.zeu.dev](http://thoughts.zeu.dev)!
+92
src/content/blog/indexed-1.mdx
··· 1 + --- 2 + title: "This is HTML: Indexed - Web Development Explained" 3 + description: An overview on what HTML is and how to use them while making websites. Part 1 of the Indexed - Web Development Explained series. 4 + date: 01-06-2024 5 + draft: false 6 + --- 7 + import CodePreview from "../../components/CodePreview.astro"; 8 + 9 + ## Elements, Tags, and the Properties Within 10 + 11 + HTML is the programming language used to layout the content inside a webpage. Writing in HTML involves using 12 + elements, also known as tags (throughout the series, I will be using these interchangeably), and putting them in 13 + order for your design. 14 + 15 + Most tags are used in twos, both with angle brackets with the type inside, with the ending tag's type starting 16 + with a slash. Between the two is where we'd put content, which can also include more elements. Some elements, 17 + like `<img />`, are what's called "self-closing tags", denoted with the slash at the end. 18 + 19 + <CodePreview> 20 + <div slot="preview"> 21 + <h2>An error has occurred??</h2> 22 + <img src="https://media.giphy.com/media/ltx2rcXk8sE4OCHnFB/giphy.gif" /> 23 + </div> 24 + <div slot="code"> 25 + ```html 26 + <div> 27 + <h2>An error has occurred??</h2> 28 + <img src="https://media.giphy.com/media/ltx2rcXk8sE4OCHnFB/giphy.gif" /> 29 + </div> 30 + ``` 31 + </div> 32 + </CodePreview> 33 + 34 + We can 35 + configure tags like these with its properties, or "props" for short. For example, to have this image tag to show a 36 + picture, we'll set its `src` property a link, as well as state an alternate text to make it accessible to those 37 + hard of seeing. 38 + 39 + ## So Common You Should Memorize Them 40 + 41 + Mozilla Developer Network (MDN), a definitive web development resource online, has a list of all HTML tags. All in 42 + all, there are 142 elements, 115 of which are functional today with the rest being deprecated. Now you 43 + aren't expected to learn every single tag, but when you make enough websites there will be elements that 44 + comes up often. 45 + 46 + Here's my list of tags I use everyday: 47 + 48 + - `<div>`, `<section>`, `<main>`: containers to label off parts of the page. I use `<main>` for the page's 49 + content, `<section>` when there's a defined part (like the code preview above), and `<div>` when I need 50 + to group elements, but don't need to label them, usually for layout and styling reasons. 51 + - `<h1>`->`<h6>`, `<p>`: headers and paragraph text elements. These come with default styles, 52 + but since I use TailwindCSS, they all look the same. No matter how they look, they do help me 53 + distinguish between a wall of text and something to highlight. 54 + - `<img>`: images (and gifs)! Make sure to use the `alt` prop to describe the image to those who might 55 + need to use screen users. Accessibility is great! 56 + - `<input>`, `<button>`: interactive elements, connect these with a `<form>` or script function to 57 + allow people to use your websites. 58 + - `<a>`: the link tag. Go to another page (either on the same website or another on the internet) by 59 + adding the link as its `href` prop. 60 + 61 + There are plenty more that I'm missing as I rattle these off at the top of my head. I memorize these since 62 + I use these everyday no matter the kind of website I'm creating, but if there's anything I need that I 63 + don't remember (for example `<video>` or `<track>`), MDN and Google are always there for me. 64 + 65 + 66 + ## Just the beginning 67 + 68 + HTML can only get you so far. Over the years, a lot of functionality has been baked into these elements. 69 + A recent example can be seen with the `<dialog>` tag that makes modals native to the browser, a feature 70 + that's been in use for decades before and practically only available in Javascript packages and frameworks. 71 + 72 + With that in mind, dialogs and buttons can't work without some scripting, so in the next part of the 73 + Indexed series, we'll start to take a look at the basics of Javascript ("JS") and how to use them 74 + with HTML. First up is using vanilla JS, before moving onto JS UI frameworks that are popular today, 75 + like React and Svelte. 76 + 77 + ## Thanks for reading 78 + 79 + Indexed is a series to write down all I've learned about web development. Think of this as my notebook 80 + scribbled with tips and lessons about how I think about websites. We'll see if I can make one of these 81 + blogs every week. 82 + 83 + That’s in the future though! For now, I’d like to thank you for reading this blog. 84 + I very much appreciate it, and if you can do me a favor, take a look at the links down below. 85 + Catch you in the next one! 86 + 87 + ## Shameless Plugs 88 + 89 + - If you'd like to look at any of my code, they are all open sourced on my Github [here](https://github.com/zeucapua). 90 + - Most of my projects are made live on my Twitch stream! Code new projects with me weekly on [twitch.tv/zeu_dev](https://twitch.tv/zeu_dev). 91 + - Any comments or questions can reach me on Twitter. Follow me at [twitter.com/zeu_dev](https://twitter.com/zeu_dev). 92 + - Interested on other stuff? Visit my personal website at [zeu.dev](https://zeu.dev) and my other blogs here on [thoughts.zeu.dev](https://thoughts.zeu.dev).
+163
src/content/blog/rnlive-partykit.md
··· 1 + --- 2 + title: Live Audience Interactions for Streamers Using PartyKit 3 + description: An OBS overlay platform created with SvelteKit and PartyKit's multiplayer platform. 4 + date: 10-02-2023 5 + draft: false 6 + link: https://github.com/zeucapua/rnlive.club 7 + --- 8 + 9 + Making RNLIVE, a live reaction streamer overlay was fun and easy. The secret? A new(er) multiplayer platform by Sunil and friends called [PartyKit](http://partykit.io). Let’s look into what I made, how I made it, and some thoughts about where to go from here. 10 + 11 + ## Inspiration 12 + 13 + ![Vercel Ship’s livestream emoji reaction experience, powered by Liveblocks ([source](https://liveblocks.io/blog/how-vercel-used-live-reactions-to-improve-engagement-on-their-vercel-ship-livestream))](/rnlive-partykit%204fb551f2d806451192762f4484efdba2/Untitled.png) 14 + 15 + Vercel Ship’s livestream emoji reaction experience, powered by Liveblocks ([source](https://liveblocks.io/blog/how-vercel-used-live-reactions-to-improve-engagement-on-their-vercel-ship-livestream)) 16 + 17 + RNLIVE ([rnlive.club](http://rnlive.club)) was made as a combination of two wants: making [my livestream](http://twitch.tv/zeu_dev) more fun with those emotes coming onto the screen I’ve seen other streamers have; and seeing if I can recreate Vercel Ship’s live audience interactions built with Liveblocks’ platform. 18 + 19 + Now I’ve used Liveblocks before, so for this project I wanted to use [PartyKit](http://partykit.io) since I’ve been following its creator, Sunil, since he announced he was working on it earlier this year and it’s fun to learn new technology. Also shoutout to [Jason Lengstorf](https://x.com/jlengstorf?s=20) on making basically the [same thing in Astro first](https://x.com/jlengstorf/status/1691164021410664449?s=20). 20 + 21 + ## What is it? 22 + 23 + Before we get into [PartyKit](http://partykit.io) and the code implementing it, we should take a step back and see what this project does. A streamer logs in with their Twitch account, and add emotes with a name and link to the image or gif source. Opening the overlay creates a new browser window that they can add as an OBS source for their stream. Viewers can then go to the site for their favorite streamer to press buttons corresponding to emotes which will show on the overlay, and therefore the stream. 24 + 25 + ## The Plan 26 + 27 + Pretty straightforward as a product, so the main obstacle in making it is learning the technology behind it, particularly WebSockets and PartyKit’s implementation of it. As a flow chart, we can imagine it step by step connecting where our message goes between our clients and our server. 28 + 29 + ![shapes.png](/rnlive-partykit%204fb551f2d806451192762f4484efdba2/shapes.png) 30 + 31 + In this example we have one viewer and one streamer. The viewer sends a message to our server using a WebSocket, which sends it back client side for the streamer. To see this in action, let’s look at RNLIVE’s source code. 32 + 33 + >I’m **not** going to go over authentication, databases, and other website stuff. The entire codebase should be annotated with comments so if you want to know more, definitely check the Github repository linked [here](http://github.com/zeucapua/rnlive.club). 34 + 35 + But for now all we need to focus our attention to are three files: the `partykit.ts` file that will be deployed as our server, the viewer page with our buttons, and the overlay page that will take those inputs. 36 + 37 + ## Code Review 38 + 39 + First the viewer page. To connect and send stuff to our PartyKit server, we have to create a PartySocket object that has our server’s host URL and a room ID to join (which is important to make sure we’re only sending emotes to the correct streamer, indicated by using the streamer’s ID). We can then connect a button to run `socket.send()` with the name of the button’s emote as a stringified JSON object. 40 + 41 + ```jsx 42 + const socket = new PartySocket({ 43 + // 'localhost:1999' is the host URL to connect to when running 'npx partykit dev' 44 + // '<party-name>.<username>.partykit.dev/party/:id' will be live to connect to after running 'npx partykit deploy' 45 + host: dev ? "localhost:1999" : `https://rnlive-club.zeucapua.partykit.dev/party/${user_info.id}`, 46 + room: user_info.id 47 + }); 48 + 49 + function sendToPartyServer(message : string) { 50 + if (socket) { 51 + const ping = JSON.stringify({ 52 + type: "ping", 53 + content: message 54 + }); 55 + 56 + // server can listen to this via 'onMessage' function 57 + socket.send(ping); 58 + } 59 + } 60 + ``` 61 + 62 + Once that is sent, we can have our PartyKit server listen to it. We create this server with it’s own Typescript file that exports a class that implements the PartyServer interface. (Shoutout to @jevakallio for the class refactor. It made the DX jump ten fold). 63 + 64 + >This code **must be deployed** as a server using the PartyKit CLI, either locally (`npx partykit dev`) or live production (`npx partykit deploy`). Since this is nested inside a few folder, you will need to be explicit with the file path when deploying. To learn more, go to [PartyKit’s docs.](https://docs.partykit.io/) 65 + 66 + ```jsx 67 + import type { 68 + Party, 69 + PartyServer, 70 + PartyWorker 71 + } from "partykit/server"; 72 + 73 + export default class RnLiveParty implements PartyServer { 74 + // can access to Party's state within this class using 'this.party' 75 + constructor(public party : Party) {} 76 + 77 + // runs when a connection SENDS a message using 'socket.send(message)' 78 + onMessage(message : string) { 79 + 80 + // from /[username] (aka viewer): { type: 'ping', content: 'emoteName' } 81 + const message_data = JSON.parse(message); 82 + switch (message_data.type) { 83 + case "ping": { 84 + 85 + // create a response to send to /overlay 86 + const response = JSON.stringify({ 87 + type: "pong", 88 + content: message_data.content 89 + }); 90 + 91 + // 'this.party.broadcast' sends a message from server to client 92 + // can be caught on client with 'socket.addEventListener('message', (event) => {})' 93 + this.party.broadcast(response); 94 + break; 95 + } 96 + 97 + default: { 98 + console.log({ message_data }); 99 + break; 100 + } 101 + } 102 + } 103 + } 104 + 105 + RnLiveParty satisfies PartyWorker; 106 + ``` 107 + 108 + Inside is a party property that we can call to get information and methods to use the WebSocket. To listen to the message sent by our viewer, we can implement the `onMessage` function and check the message parameter for the information. Parsing that, we can pass the emote name back client side using `this.party.broadcast()`. Since broadcasting sends this information to all connected sockets, we are going to pass an additional “response” type so the client knows what’s happening. 109 + 110 + ```jsx 111 + // listen to party's broadcasts (this.party.broadcast) from server 112 + socket.addEventListener("message", (event) => { 113 + // from server (/lib/server/partykit.ts): { type: 'pong', content: 'emoteName' } 114 + const message_data = JSON.parse(event.data); 115 + 116 + switch (message_data.type) { 117 + case "pong": { 118 + displayEmote(message_data.content); 119 + break; 120 + } 121 + default: { 122 + console.log("DEFAULT:", event.data); 123 + } 124 + } 125 + }); 126 + 127 + async function displayEmote(name : string) { 128 + for (const s of sources) { 129 + if (s.name === name) { 130 + // create emote with url 131 + let e = { source: s.source, fading: false }; 132 + 133 + // add to list that's rendered below 134 + emotes = [...emotes, e]; 135 + 136 + // wait a tick 137 + await tick(); 138 + 139 + // make fading true to trigger out:transition 140 + emotes[emotes.indexOf(e)].fading = true; 141 + break; 142 + } 143 + } 144 + } 145 + ``` 146 + 147 + With that in mind, let’s get this broadcast by going to our overlay page. We’ll do the same thing that we did in the viewer page and create a socket variable to connect to the server. The only difference here is adding an Event Listener. We can parse the data being broadcasted to get the emote name and ultimately display it. 148 + 149 + ## The Future? 150 + 151 + The way [RNLIVE](http://rnlive.club) is set up allows for future expansion. Since it’s only JSON objects being passed around as strings, we can additional “response” types to connect custom animations, donations, and the Twitch API itself for more interactions. The sky is the limit. If there are any features that’d be useful for a streamer or fun for a viewer to do during a stream, please let me know. 152 + 153 + ## Available TODAY! 154 + 155 + And with that, a reminder that you can use this tool right now. It is live at [rnlive.club](http://rnlive.club) and if you are a streamer, all you need is your Twitch account to log in and try it out. Open the overlay screen and add it to your OBS (or equivalent) software with a chroma key for the purple background. You can direct viewers to **rnlive.club/*\<username\>*** (e.g. [rnlive.club/zeu_dev](http://rnlive.club/zeu_dev)) to start having emotes sent by them bounce around your screen. 156 + 157 + ## Shameless Plugs 158 + 159 + - If you’d like to clone the source code for this project, there is a commented repository on my Github [here](https://github.com/zeucapua/rnlive.club). 160 + - This project was made live on my Twitch stream. Code new projects with me weekly on [twitch.tv/zeu_dev](http://twitch.tv/zeu_dev). 161 + - Any comments or questions can reach me on Twitter. Follow me at [twitter.com/zeu_dev](http://twitter.com/zeu_dev). 162 + 163 + Thank you for reading and I’ll see you with the next project.
+687
src/content/blog/twitter-clone-sveltekit.md
··· 1 + --- 2 + title: Build a Twitter Clone with SvelteKit, Auth.js, and Prisma 3 + description: Learn SvelteKit routing, authentication, and database management with a public post board. 4 + date: "2023-05-01" 5 + draft: false 6 + link: https://github.com/zeucapua/veranda-app 7 + --- 8 + 9 + ### Table of Contents 10 + - [Prerequisites](#Prerequisites) 11 + - [Installation](#Installation) 12 + - [Project Configuration](#Configuration) 13 + - [Authentication with Auth.js](#Authentication) 14 + - [Databases with Prisma](#Databases) 15 + - [Dynamic Routing](#Dynamic-Routing) 16 + 17 + ## Prerequisites <a name="Prerequisites"></a> 18 + 19 + Before getting started with the installation, we will need a database to store our data in, as well as get our Discord developer 20 + application ready to use for our OAuth solution. For this tutorial, we will use Railway to spin up a PostgreSQL database. 21 + You can use other solutions like Supabase (which have their own client library), but since we are going to be using Prisma in this 22 + tutorial, we just need to connect to a database using a URL, no matter where it is hosted. 23 + 24 + As said earlier, we will use Discord as our OAuth provider, which means we will need the client ID and secret from a Discord 25 + application. Create an application from the [Discord Developer Page](https://discord.com/developers) using your account. 26 + 27 + Keep track of your PostgreSQL `DATABASE_URL`, as well as your Discord `CLIENT_ID` and `CLIENT_SECRET`. They will be stored inside 28 + our `.env` file, as well as a `AUTH_SECRET` for Auth.js (which you can generate randomly by going to the terminal and typing in 29 + `openssl rand -base64 32`. 30 + 31 + With that said, also ensure that you have Node.js and NPM installed. Find the instructions for your OS 32 + [here](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) 33 + 34 + ## Installation <a name="Installation"></a> 35 + 36 + Let's create our SvelteKit project and install our packages (TailwindCSS, DaisyUI, Prisma, and Auth.js) using npm: 37 + 38 + ```bash 39 + // Skeleton Project, with Typescript, no additional options 40 + npm create svelte@latest veranda-tutorial 41 + 42 + // go inside the project folder 43 + cd veranda-tutorial 44 + 45 + // optional CSS-in-JS solution 46 + npm install -D tailwindcss postcss autoprefixer 47 + npx tailwindcss init -p 48 + npm install daisyui 49 + 50 + // install Auth.js and Prisma ORM 51 + npm install @auth/core @auth/sveltekit 52 + npm install @prisma/client @next-auth/prisma-adapter 53 + npm install prisma --save-dev 54 + ``` 55 + 56 + ## Project Configuration <a name="Configuration"></a> 57 + 58 + After installing our packages, we're going to do some housekeeping before able to actually use them. 59 + 60 + ### TailwindCSS and DaisyUI 61 + 62 + > This is completely optional. You can use plain CSS or other CSS-in-JS solutions 63 + (like Bootstrap, Twind, UnoCSS, PicoCSS, etc.) instead. 64 + 65 + Inside our project folder, find the `tailwind.config.js` and change the `content` and `plugins` lines to the following: 66 + 67 + ```js 68 + /** @type {import('tailwindcss').Config} */ 69 + export default { 70 + content: ['./src/**/*.{html,js,svelte,ts}'], 71 + theme: { 72 + extend: {} 73 + }, 74 + plugins: [require("daisyui")] 75 + }; 76 + ``` 77 + 78 + This will ensure that Tailwind can find what files to look through when generating CSS styles using class names, as well as 79 + being able to use the DaisyUI plugin to make styling easier. To actually use it however, we need to create some files. 80 + 81 + First, inside your project folder, create a `/src/app.css` file that includes the following: 82 + 83 + ```css 84 + @tailwind base 85 + @tailwind components 86 + @tailwind utilities 87 + ``` 88 + 89 + To use the CSS we just made, create a `/src/routes/+layout.svelte` file that imports it: 90 + 91 + ```svelte 92 + <script> 93 + import "../app.css"; 94 + </script> 95 + 96 + <slot /> 97 + ``` 98 + 99 + Layout files are used to wrap its children pages with shared data and components, and since this `+layout.svelte` file lives in 100 + the root `routes` folder, it will be used throughout the entire SvelteKit application (unless otherwise specified). Keep this in mind 101 + if you're using SvelteKit's [advanced layout techniques](https://kit.svelte.dev/docs/advanced-routing#advanced-layouts) 102 + like `(group)` and `+page@` files. We'll talk more about layouts and pages later on. 103 + 104 + ### Prisma ORM (Schema and Database Connection) 105 + 106 + Next is to ensure we can use our PostgreSQL database with Prisma. Using the terminal, type in `npx prisma init`. This will generate 107 + a new `prisma` folder with a `schema.prisma` file, as well as a `.env` file. First, go inside the `.env` and we will set a few 108 + variables from before: 109 + 110 + ``` 111 + DATABASE_URL="<from Railway PostgeSQL Database>" 112 + DISCORD_CLIENT_ID="<from Discord Developer Application>" 113 + DISCORD_CLIENT_SECRET="<from Discord Developer Application>" 114 + AUTH_SECRET="<from terminal (openssl rand -base64 32)>" 115 + ``` 116 + 117 + Once that is set, go back to the `/prisma/schema.prisma` file and add a model for our posts: 118 + 119 + ```prisma 120 + model Post { 121 + id String @id @default(cuid()) 122 + content String 123 + claps Int @default(0) 124 + } 125 + ``` 126 + 127 + We will add more onto this `schema.prisma` file, but for now we just need to create the shape of our `Post` data. We can then 128 + use `npx prisma format` to ensure that there are no errors in our schema, and then `npx prisma db push` to create a table on our 129 + PostgreSQL database to hold our `Posts` and generate a Prisma Client that we can use to send and recieve data in our application. 130 + 131 + With that said, we need to create said Prisma Client with a `/src/lib/prisma.ts` file that exports one for us to use in other 132 + parts of our project: 133 + 134 + ```js 135 + import { PrismaClient } from "@prisma/client"; 136 + 137 + export const prisma = new PrismaClient(); 138 + ``` 139 + 140 + Now, whenever we create any changes to our Prisma schema (which is what we are going to do in the next step), always use 141 + `npx prisma format` and `npx prisma db push` to make sure our application and database are in sync with no errors. 142 + 143 + ### Auth.js (with PrismaAdapter) 144 + 145 + > Please note that Auth.js for SvelteKit is in **experimental** status as of this tutorial. 146 + There might be changes to the package and API in the future that 147 + can cause errors that I cannot know now. Keep that in mind when thinking of using Auth.js and know that there are other 148 + alternatives that you can consider (Auth0, ClerkJS, Authorizer, etc.) that may be more stable. 149 + 150 + To start authenticating, let's add more onto our `schema.prisma` file to include all the information from Auth.js. 151 + 152 + ```prisma 153 + model Account { 154 + id String @id @default(cuid()) 155 + userId String 156 + type String 157 + provider String 158 + providerAccountId String 159 + refresh_token String? @db.Text 160 + access_token String? @db.Text 161 + expires_in Int? 162 + token_type String? 163 + scope String? 164 + id_token String? @db.Text 165 + session_state String? 166 + 167 + user User @relation(fields: [userId], references: [id], onDelete: Cascade) 168 + 169 + @@unique([provider, providerAccountId]) 170 + } 171 + 172 + model Session { 173 + id String @id @default(cuid()) 174 + sessionToken String @unique 175 + userId String 176 + expires DateTime 177 + user User @relation(fields: [userId], references: [id], onDelete: Cascade) 178 + } 179 + 180 + model User { 181 + id String @id @default(cuid()) 182 + name String? 183 + email String? @unique 184 + emailVerified DateTime? 185 + image String? 186 + accounts Account[] 187 + sessions Session[] 188 + posts Post[] 189 + } 190 + 191 + model VerificationToken { 192 + identifier String 193 + token String @unique 194 + expires DateTime 195 + 196 + @@unique([identifier, token]) 197 + } 198 + 199 + model Post { 200 + id String @id @default(cuid()) 201 + content String 202 + createdAt DateTime @default(now()) 203 + user User @relation(fields: [userId], references: [id]) 204 + userId String 205 + claps Int @default(0) 206 + } 207 + 208 + ``` 209 + 210 + > Due to a current issue with Auth.js, when using Discord (and Google?) as our OAuth provider, using the default schema given 211 + by the documentation will result in an error because of a different naming convention for the expiry duration. Under the 212 + `Account` model, ensure you have `expires_in` and NOT `expires_at`. 213 + 214 + Also note that our `Post` model has changed to be connected to a `User`'s posts field. That way we know who made which posts later 215 + on in the application. Remember that now we have changed the schema to use `npx prisma format` and `npx prisma db push`. 216 + 217 + We are going to be using Auth.js to get a session, but by default it does not include enough `User` information, 218 + more specifically, no ID to refer to. To do so will require us to extend the initial package to include the 219 + data with a Prisma call. First, we need to create a `types.d.ts` file in our root directory and extend the `Session` type from 220 + Auth.js to include a ID in connection to the User. 221 + 222 + ```ts 223 + // shoutout to Coding Garden for types.d.ts and auth handler w/ callbacks 224 + import type { 225 + Session as OGSession, 226 + DefaultSession, 227 + } from "@auth/sveltekit/node_modules/@auth/core/types"; 228 + 229 + // TODO: change package to "@auth/core/types" when fixed. above fixes a bug! 230 + 231 + declare module "@auth/sveltekit/node_modules/@auth/core/types" { 232 + interface Session extends OGSession { 233 + user?: { 234 + id : string, 235 + } & DefaultSession["user"], 236 + } 237 + } 238 + ``` 239 + 240 + Now with the database shape and type definitions are done, let's actually use Auth.js for authentication by writing a Handle in a 241 + new `/src/hooks.server.ts` file. SvelteKit will use this file to intercept a request from the client and generates a response. We are 242 + going to declare the authentication handle separately and then use `sequence()` from SvelteKit to run it. That way you can create 243 + other Handles in the future and then use in conjunction with what we have by adding to the `sequence()` function. 244 + 245 + ```ts 246 + import { SvelteKitAuth } from "@auth/sveltekit"; 247 + import Discord from "@auth/core/providers/discord"; 248 + import { PrismaAdapter } from "@next-auth/prisma-adapter"; 249 + 250 + import { prisma } from "$lib/prisma"; 251 + import { DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET } from "$env/static/private"; 252 + 253 + import type { Handle } from "@sveltejs/kit"; 254 + import { sequence } from "@sveltejs/kit/hooks"; 255 + 256 + export const auth = (async (...args) => { 257 + const [{event}] = args; 258 + return SvelteKitAuth({ 259 + adapter: PrismaAdapter(prisma), 260 + providers: [ 261 + Discord({ clientId: DISCORD_CLIENT_ID, clientSecret: DISCORD_CLIENT_SECRET }), 262 + ], 263 + callbacks: { 264 + async session({ user, session }) { 265 + session.user = { 266 + id: user.id, 267 + name: user.name, 268 + image: user.image, 269 + }; 270 + 271 + event.locals.session = session 272 + return session 273 + } 274 + } 275 + })(...args); 276 + }) satisfies Handle; 277 + 278 + export const handle = sequence(auth); 279 + ``` 280 + 281 + There are few things going on here, but let's just highlight a few of them. The `auth` function returns a Handle by `SvelteKitAuth` 282 + that uses the PrismaAdapter that takes in our Prisma client from earlier. Also inside is the Discord provider that requires 283 + our `DISCORD_CLIENT_ID` and `DISCORD_CLIENT_SECRET` environment variables, which is being imported using SvelteKit's `$env` module. 284 + 285 + To make use of the extended `Session` type definition we made earlier, we ensure that we change the `session()` callback to 286 + include set the `User` property with the ID and return the session. 287 + 288 + With that said, we are done with all the prep work and now we can start making pages happen! 289 + 290 + ## Authentication with Auth.js <a name="Authentication"></a> 291 + 292 + For this application, we are going to use site-wide authentication so we know the state no matter where we are. That said means we 293 + are going to use our `/src/routes/+layout.svelte` file from earlier. Before rendering the actual layout component, 294 + we need to get the current session by using the `load()` function inside a new `/src/routes/+layout.server.ts` file. 295 + 296 + ```ts 297 + export async function load({ locals }) { 298 + const session = await locals.getSession(); 299 + return { session } 300 + } 301 + ``` 302 + 303 + Now that `session` object is now accessible via SvelteKit's `LayoutData` prop inside our `+layout.svelte` component. We can then 304 + check if there is a user currently logged in, which we can use to render their information as well as a "Sign In" and "Sign Out" 305 + button when appropriate. Those buttons will call the `signIn()` and `signOut()` functions respectively that we will import from 306 + the Auth.js package: 307 + 308 + ```svelte 309 + <script> 310 + import { signIn, signOut } from "@auth/sveltekit/client"; 311 + import "../app.css"; 312 + 313 + export let data; 314 + const user = data.session?.user; 315 + 316 + let show_menu = false; 317 + </script> 318 + 319 + <main class="flex flex-col w-full h-full min-w-screen min-h-screen p-16 gap-8"> 320 + 321 + <section class="navbar bg-base-100"> 322 + <div class="flex-1"> 323 + <a href="/"> 324 + <button class="btn btn-ghost normal-case text-white text-2xl font-bold">Veranda</button> 325 + </a> 326 + </div> 327 + 328 + <div class="flex-none"> 329 + {#if !user} 330 + <button on:click={() => signIn("discord")} class="btn btn-primary"> 331 + Log in with Discord 332 + </button> 333 + {:else} 334 + <div class="flex flex-row justify-center gap-8"> 335 + {#if show_menu} 336 + <button on:click={() => signOut()} class="btn btn-outline btn-error"> 337 + Log out 338 + </button> 339 + {/if} 340 + <button on:click={() => show_menu = !show_menu} class="btn btn-ghost btn-circle avatar"> 341 + <img 342 + src={user.image} alt={`${user.name} Profile Picture`} 343 + class="w-16 h-16 rounded-full"/> 344 + </button> 345 + </div> 346 + {/if} 347 + </div> 348 + </section> 349 + 350 + <slot /> 351 + 352 + </main> 353 + ``` 354 + 355 + As you can see, conditional rendering of the DOM is easy with Svelte using their `{#if}` blocks. Now this navigation bar is 356 + available throughout all the pages since its in the root `+layout.svelte` file. 357 + 358 + The cool thing about the `LayoutData` prop is that it is available to its page children by the `$page` store given in SvelteKit. 359 + We can use this to decide whether or not to render a form text input for creating posts in our root `+page.svelte` 360 + file, which will be our index page, depending on whether there is a user logged in: 361 + 362 + ```svelte 363 + <script lang="ts"> 364 + import { page } from "$app/stores"; 365 + 366 + const user = $page.data.session?.user; 367 + </script> 368 + 369 + {#if user} 370 + <form method="POST" action="?/createPost"> 371 + <!-- will be implemented --> 372 + </form> 373 + {/if} 374 + ``` 375 + 376 + ## Databases with Prisma <a name="Databases"></a> 377 + 378 + Next, let's actually connect to the database to read and create posts. Most of this will need to import the Prisma client that 379 + we made in the `/src/lib` directory. 380 + 381 + ### Creating Posts with Prisma 382 + 383 + First, let's finish the form above for creating our post: 384 + 385 + ```svelte 386 + {#if user} 387 + <form method="POST" action="?/createPost" class="flex flex-row gap-8 items-center"> 388 + <img src={user?.image} alt={`${user?.name} Profile Picture`} 389 + class="w-0 h-0 md:w-16 md:h-16 md:rounded-full" 390 + /> 391 + <input name="content" type="text" placeholder="Say something..." 392 + class="grow input input-bordered input-primary" 393 + /> 394 + </form> 395 + {/if} 396 + ``` 397 + 398 + Here we have a form with a method of **POST** with the action of **createPost**. Inside is an input tag with the name **content**, 399 + which we can use to grab its value. You can add a button inside the form to submit it, but for now, pressing Enter will do the same. 400 + To use this form, we have to create and export an `actions` variable in a `/src/routes/+page.server.ts` file: 401 + 402 + ```ts 403 + import { prisma } from "$lib/prisma"; 404 + import { fail } from "@sveltejs/kit"; 405 + 406 + export const actions = { 407 + createPost: async ({ locals, request }) => { 408 + const data = await request.formData(); 409 + const content = data.get("content"); 410 + 411 + const session = await locals.getSession(); 412 + const user = session.user; 413 + 414 + const post = await prisma.post.create({ 415 + data: { 416 + content, 417 + user: { connect: { id: user.id } } 418 + } 419 + }); 420 + 421 + if (!post) { 422 + throw fail( 423 + 503, 424 + message: "There's been an error when posting. Try again." 425 + ); 426 + } 427 + }, 428 + } 429 + ``` 430 + 431 + Note that the action is named the same from our form, which holds an async function that allows us to access the `locals` 432 + from our application that has our session, and a `request` parameter that can get our `formData()`. To get the actual input value, 433 + we use `data.get()` with the name of the input tag (e.g. **content**). We then use `locals.getSession()` like we did in our 434 + `+layout.server.ts` file to get our user. Don't forget to import our Prisma client using the `$lib` module so that we can use it 435 + to create our post in the database and connect it to the corresponding user. If post returns undefined, that means there was an error 436 + in our Prisma call, so we can throw a `fail`. We can take that fail and show a message to the user, but for now, it will just 437 + act like an error. 438 + 439 + ### Reading Posts with Prisma 440 + 441 + Now that we can create posts, we should actually get the posts, so we can read them in a column in our index page. To get the posts, 442 + let's go back into our `/src/routes/+page.server.ts` file and add a `load` function that will use our Prisma client and return it: 443 + 444 + ```ts 445 + export async function load() { 446 + const posts = await prisma.post.findMany({ 447 + orderBy: { createdAt: 'desc' }, 448 + include: { user: true } 449 + }); 450 + 451 + return { posts }; 452 + } 453 + ``` 454 + 455 + The Prisma client call above will have a list posts that is ordered by descending timestamps, as well as the associated user. The data 456 + is then returned into a JSON object, which can access in our `+page.svelte` file by using its `PageData` with `export let data`: 457 + 458 + ```svelte 459 + <script> 460 + import PostView from "$lib/PostView.svelte"; 461 + export let data; 462 + const posts = data.posts; 463 + </script> 464 + 465 + {#if posts} 466 + {#each posts as { user, ...post }} 467 + <PostView {user} {post} /> 468 + {/each} 469 + {/if} 470 + ``` 471 + 472 + Once we get the posts, we use another Svelte `{#if}` block to ensure we will on render when it is available. After, we need to 473 + turn each element in our **posts** variable into two parts: the **user** and the **post**. The user is already a property, so we 474 + can declare it separately, but the rest of the properties are a `Post` object, so we use `...post` to put them all in one variable. 475 + Those variables then gets passed as props in a new `PostView` component, which we can declare as a Svelte file in our `lib` 476 + directory (the same place where our Prisma client lives): 477 + 478 + ```svelte 479 + <script lang="ts"> 480 + import { format } from "timeago.js"; 481 + import type { Post, User } from "@prisma/client" 482 + 483 + export let post : Post; 484 + export let user : User; 485 + 486 + let duration = format(post.createdAt); 487 + </script> 488 + 489 + <div class="flex flex-row gap-8 items-center"> 490 + <a href={`/u/${user.id}`} class="btn btn-ghost btn-circle avatar"> 491 + <img src={user.image} alt={`${user.name}`} class="w-16 h-16 rounded-full" /> 492 + </a> 493 + <div class="flex flex-col gap-2"> 494 + <a href={`/p/${post.id}`}> 495 + <p class="text-neutral-400 pb-2"> 496 + <a href={`/u/${user.id}`}>@{user.name}</a> 497 + | { duration } 498 + </p> 499 + </a> 500 + <p class="text-xl text-white">{post.content}</p> 501 + </div> 502 + </div> 503 + 504 + ``` 505 + 506 + > Above there is a `duration` variable that takes in the **createdAt** property from the post. To format it like 507 + how it is seen on Twitter, I've installed **timeago.js** (via `npm i timeago.js`) and used its `format()` function. 508 + This is optional, but it does make it easier to render the time. Another option that I haven't explored yet is 509 + `Intl.RelativeTimeFormat`, so if you don't want to install another package, try that one out! 510 + 511 + As you can see above, we are going to be creating new pages for the user and post using their IDs. But before 512 + getting that done, let's keep working on our `PostView` by adding a *clapping* feature. 513 + 514 + ### Clapping Posts (aka Likes) 515 + 516 + I'll explain the following after we add to our `PostView`: 517 + 518 + ```svelte 519 + <script lang="ts"> 520 + import { onMount } from "svelte"; 521 + import { enhance } from "$app/forms"; 522 + // other imports 523 + 524 + // other variables 525 + let claps : number; 526 + 527 + onMount(() => { claps = post.claps; }); 528 + 529 + function onClap() { claps += 1; } 530 + </script> 531 + 532 + <div class="flex flex-col gap-8 items-center"> 533 + <!-- the other stuff from before --> 534 + <div class="flex flex-col gap-2"> 535 + <!-- the other stuff from before --> 536 + <form method="POST" action="?/clapPost" use:enhance> 537 + <input name="post_id" type="hidden" value={post.id} /> 538 + <button 539 + on:click={onClap} 540 + class="btn btn-outline btn-secondary rounded-full" 541 + > 542 + 👏 {#if !claps}...{:else} {claps} {/if} 543 + </button> 544 + </form> 545 + </div> 546 + </div> 547 + ``` 548 + 549 + So first things first, let's address the form. There is a hidden input that carries the **post.id**, which will be 550 + used to determine, which post to update later on in the action. Next is the new `use:enhance` prop on the form. This 551 + is from SvelteKit that allows the form to submit, but crucially it doesn't do full-page reloads. We don't want to 552 + refresh the screen every time we clap a post, so this is a great solution for that. 553 + 554 + But that also means the claps won't update because the Prisma call that finds the post won't rerun. And so to combat 555 + this, we can use `onMount(() => { claps = post.claps })` to hold the count locally to the component when first 556 + rendering, which we can update everytime we press the form button via the `onClap()` function. 557 + 558 + > That's all good, but how can we have a form in a separate component when we need a 559 + `+page.server.ts` file to create an action? 560 + 561 + Well, as far as I know, since this component is being rendered as a part of the `+page.svelte`, the form will 562 + find the appropriate action given it's rendered location. With that said let's go to our `/src/routes/+page.server.ts` 563 + file and create that `clapPost` action: 564 + 565 + ```ts 566 + export const actions = { 567 + createPost: ..., 568 + clapPost: async ({ request }) => { 569 + const data = await request.formData(); 570 + const post_id = data.get("post_id"); 571 + 572 + const post = await prisma.post.update({ 573 + where: { id: post_id }, 574 + data: { claps: { increment: 1 } } 575 + }); 576 + 577 + if (!post) { 578 + return fail(502, { message: "Cannot clap right now. Try again." }); 579 + } 580 + } 581 + } 582 + ``` 583 + 584 + ## Dynamic Routing <a name="Dynamic-Routing"></a> 585 + 586 + Last thing we can add to our application are dynamic routes to have pages for every post and user. With SvelteKit's 587 + file-based routing, we can create them in our `src/routes/` folder with brackets, which in our case 588 + will be under `/u/[id]/+page.svelte` and `/p/[id]/+page.svelte`. We will also add a `+page.server.ts` file under 589 + each route to get the appropriate data from Prisma based on the dynamic route paramater (`id`). They have similar 590 + code, so I'll put them all below: 591 + 592 + ### /u/[id] 593 + 594 + #### +page.server.ts 595 + 596 + ```ts 597 + import { prisma } from "$lib/prisma"; 598 + import { error } from "@sveltejs/kit"; 599 + 600 + export async function load({ params }) { 601 + const id = params.id; // corresponds to [id] 602 + 603 + const user_data = await prisma.user.findUnique({ 604 + where: { id }, 605 + include: { posts: true }, 606 + }); 607 + 608 + if (!user_data) { return error(404, "User not found"); } 609 + 610 + const { posts, ...user } = user_data; 611 + 612 + return { user, posts } 613 + } 614 + ``` 615 + 616 + #### +page.svelte 617 + 618 + ```svelte 619 + <script> 620 + import PostView from "$lib/PostView.svelte"; 621 + import type { User, Post } from "@prisma/client"; 622 + 623 + export let data; 624 + const user : User = data.user; 625 + const posts : Post[] = data.posts; 626 + </script> 627 + 628 + <section class="flex flex-row gap-8 items-center"> 629 + <img src={user.image} alt={`${user.name} Profile`} class="w-18 h-18 rounded-full" /> 630 + <p class="text-4xl text-white">@{user.name}</p> 631 + </section> 632 + 633 + {#if !posts} 634 + <p>This user hasn't posted anything yet.</p> 635 + {:else} 636 + {#each posts as post} 637 + <PostView {post} {user} /> 638 + {/each} 639 + {/if} 640 + ``` 641 + 642 + ### /p/[id] 643 + 644 + #### +page.server.ts 645 + 646 + ```ts 647 + import { prisma } from "$lib/prisma"; 648 + import { error } from "@sveltejs/kit"; 649 + 650 + export async function load({ params }) { 651 + const id = params.id; 652 + 653 + const post_data = await prisma.post.findUnique({ 654 + where: { id }, 655 + include: { user: true }, 656 + }); 657 + 658 + if (!post_data) { return error(404, "Post not found"); } 659 + 660 + const { user, ...post } = post_data; 661 + 662 + return { user, post } 663 + } 664 + ``` 665 + 666 + #### +page.svelte 667 + 668 + ```svelte 669 + <script lang="ts"> 670 + import PostView from "$lib/PostView.svelte"; 671 + import type { User, Post } from "@prisma/client"; 672 + 673 + export let data; 674 + const post : Post = data.post; 675 + const user : User = data.user; 676 + </script> 677 + 678 + <PostView {user} {post} /> 679 + ``` 680 + 681 + ## That's all folks 682 + 683 + Congratulations, you've completed a full stack SvelteKit application! Built with Auth.js and Prisma allows us to 684 + create a website with authentication and database manipulation. Thank you if you'd read this far and thanks to 685 + Theo for the inspiration. Hopefully this information is useful for you to get started with Svelte and SvelteKit. 686 + To learn more, I recommend joining the Svelte discord and read the official documentation to get all the help you 687 + might need. 'Till next time :)
+15
src/content/config.ts
··· 1 + import { z, defineCollection } from "astro:content"; 2 + 3 + const blog_collection = defineCollection({ 4 + schema: z.object({ 5 + title: z.string(), 6 + description: z.string(), 7 + date: z.string(), 8 + draft: z.boolean(), 9 + link: z.string().optional() 10 + }) 11 + }); 12 + 13 + export const collections = { 14 + blog: blog_collection 15 + }
+35
src/pages/blog/[...slug].astro
··· 1 + --- 2 + import type { GetStaticPaths } from "astro"; 3 + import { CollectionEntry, getCollection } from "astro:content"; 4 + import SiteLayout from "../../components/SiteLayout.astro"; 5 + 6 + export interface Props { 7 + blog : CollectionEntry<'blog'> 8 + } 9 + 10 + export const getStaticPaths = (async () => { 11 + const blogs = await getCollection("blog"); 12 + return blogs.map((blog) => { 13 + return { 14 + params: { slug: blog.slug }, 15 + props: { blog } 16 + }}); 17 + }) satisfies GetStaticPaths; 18 + 19 + const { blog } = Astro.props; 20 + 21 + const { Content } = await blog.render(); 22 + --- 23 + 24 + <SiteLayout> 25 + <main class="flex flex-col gap-4 w-full py-8"> 26 + <article class="prose prose-invert w-full max-w-6xl prose-code:text-purple-400"> 27 + <h1 class="text-yellow-500">{blog.data.title}</h1> 28 + <p class="text-yellow-500">{blog.data.description}</p> 29 + <time>Latest: {new Date(blog.data.date).toLocaleDateString()}</time> 30 + 31 + <Content /> 32 + </article> 33 + </main> 34 + 35 + </SiteLayout>
+17
src/pages/blog/index.astro
··· 1 + --- 2 + import { getCollection } from "astro:content"; 3 + import BlogCard from "../../components/BlogCard.astro"; 4 + import SiteLayout from "../../components/SiteLayout.astro"; 5 + 6 + const blogs = await getCollection("blog", ({ data }) => { 7 + return data.draft === false; 8 + }); 9 + --- 10 + 11 + <SiteLayout title="Blogs | zeu.dev"> 12 + <main class="flex flex-col gap-8 py-8 max-w-3xl"> 13 + <h1 class="text-5xl text-white font-bold">Blog Posts</h1> 14 + 15 + { blogs.map((blog) => <BlogCard {blog} />) } 16 + </main> 17 + </SiteLayout>
+18 -1
src/pages/index.astro
··· 4 4 5 5 <SiteLayout title="zeu.dev"> 6 6 <main class="flex flex-col gap-4 px-4 py-8 text-white text-lg"> 7 + <pre aria-label="zeu in ASCII NV Script" class="text-yellow-500"> 8 + 9 + 10 + ,gggg, ,ggg, gg gg 11 + d8" Yb i8" "8i I8 8I 12 + dP dP I8, ,8I I8, ,8I 13 + ,dP ,adP' `YbadP' ,d8b, ,d8b, 14 + 8" ""Y8d8888P"Y8888P'"Y88P"`Y8 15 + ,d8I' 16 + ,dP'8I 17 + ,8" 8I 18 + I8 8I 19 + `8, ,8I 20 + `Y8P" 21 + 22 + 23 + </pre> 7 24 <h1 class="text-5xl font-bold text-yellow-500">hey hi hello!</h1> 8 - <h2>welcome to my site!</h2> 25 + <h2 class="text-yellow-500 font-medium text-2xl">welcome to my site!</h2> 9 26 10 27 <p> 11 28 I have plenty of ideas and projects I want to talk to you guys about
+1 -1
tailwind.config.mjs
··· 8 8 } 9 9 }, 10 10 }, 11 - plugins: [], 11 + plugins: [require("@tailwindcss/typography")] 12 12 }
+5 -2
tsconfig.json
··· 1 1 { 2 - "extends": "astro/tsconfigs/strict" 3 - } 2 + "extends": "astro/tsconfigs/strict", 3 + "compilerOptions": { 4 + "strictNullChecks": true 5 + } 6 + }