Compare commits

..

No commits in common. "main" and "6.9.0" have entirely different histories.
main ... 6.9.0

135 changed files with 3804 additions and 1278 deletions

BIN
.DS_Store vendored

Binary file not shown.

46
.air.toml Normal file
View file

@ -0,0 +1,46 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = ["-ip", "127.0.0.1", "-port", "3000"]
bin = "./tmp/main"
pre_cmd = []
cmd = "go build -o ./tmp/main . & cd lib/stylegen && ./gen.sh"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "lib/stylegen"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "css"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
**/atri.dad
**/.env
**/airbin
**/tmp
**/*.rdb
stylegen
fly.toml
tailwind.config.*.js

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
CLERK_SECRET_KEY=""
CLERK_WEBHOOK_SECRET=""
SMTP_HOST=""
SMTP_PORT=""
SMTP_USERNAME=""
SMTP_PASSWORD=""
STRIPE_SECRET_KEY=""
REDIS_URL=""
BUCKET_NAME=
AWS_REGION=
AWS_ENDPOINT_URL_S3=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REFRESH_TOKEN=

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
*.css linguist-vendored
*.js linguist-vendored

View file

@ -1,35 +0,0 @@
name: Docker Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Container Registry
uses: docker/login-action@v2
with:
registry: ${{ secrets.REPO_HOST }}
username: ${{ github.repository_owner }}
password: ${{ secrets.DEPLOY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:${{ github.sha }}
${{ secrets.REPO_HOST }}/${{ github.repository_owner }}/${{ github.event.repository.name }}:latest

16
.gitignore vendored
View file

@ -1,11 +1,7 @@
# dotenv environment variable files atri.dad
.env .env
.env.development.local airbin
.env.test.local tmp/
.env.production.local *.rdb
.env.local .DS_Store
tailwind.config.*.js
# Fresh build directory
_fresh/
# npm dependencies
node_modules/

8
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,8 @@
{
"recommendations": [
"golang.go",
"bradlc.vscode-tailwindcss",
"redhat.vscode-yaml",
"a-h.templ"
]
}

View file

@ -1,3 +0,0 @@
{
"deno.enable": true
}

View file

@ -1,35 +0,0 @@
FROM denoland/deno:alpine AS builder
WORKDIR /app
# Install build dependencies for native modules
RUN apk add --no-cache build-base python3
COPY . .
# Create node_modules directory and install dependencies
RUN deno cache -r main.ts
# Build Fresh application in a more controlled way (without task)
RUN deno run -A dev.ts build || deno run -A --unstable-worker-options --node-modules-dir main.ts build
FROM denoland/deno:alpine
WORKDIR /app
# Copy the Deno cache and node_modules
COPY --from=builder /deno-dir/ /deno-dir/
COPY --from=builder /app/node_modules/ /app/node_modules/
# Copy application code
COPY --from=builder /app/ /app/
# Ensure static assets directories permissions are set correctly
RUN chmod -R 755 /app/static /app/_fresh
ENV DENO_DEPLOYMENT=production
EXPOSE 8000
# Run with appropriate flags for static file serving
CMD ["run", "--allow-net", "--allow-read", "--allow-env", "--node-modules-dir", "main.ts"]

View file

@ -1,16 +1,24 @@
# Fresh project # atri.dad
This is my personal website!
Your new Fresh project is ready to go. You can follow the Fresh "Getting ## Stack:
Started" guide here: https://fresh.deno.dev/docs/getting-started - Backend: Golang + Echo
- Rendering: Golang templates
- Style: TailwindCSS + DaisyUI (No JS Needed)
- Content format: Markdown
### Usage ## Requirements:
- Golang 1.22.0
Make sure to install Deno: https://deno.land/manual/getting_started/installation ## Instructions:
1. Run go get
2. Duplicate the .env.example file and call it .env
3. Fill out the .env values
4. Run ```go install github.com/cosmtrek/air@latest``` to download Air for live reload
5. Run ```air``` to start the dev server
Then start the project: _Note that on MacOS, you need to right click and open the appropriate tailwind executable before you can run StyleGen. This is a limitation of running unsigned binaries in MacOS. Blame Tim Apple._
``` ## Tests
deno task start Without Coverage: `go test atri.dad/lib`
``` With Coverage: `go test atri.dad/lib -cover`
This will watch the project directory and restart as necessary.

42
api/authed.go Normal file
View file

@ -0,0 +1,42 @@
package api
import (
"net/http"
"os"
"strings"
"github.com/clerkinc/clerk-sdk-go/clerk"
"github.com/labstack/echo/v4"
)
func Authed(c echo.Context) error {
apiKey := os.Getenv("CLERK_SECRET_KEY")
client, err := clerk.NewClient(apiKey)
if err != nil {
// handle error
println(err.Error())
}
// get session token from Authorization header
sessionToken := c.Request().Header.Get("Authorization")
sessionToken = strings.TrimPrefix(sessionToken, "Bearer ")
println(sessionToken)
// verify the session
sessClaims, err := client.VerifyToken(sessionToken)
if err != nil {
println(err.Error())
return c.String(http.StatusUnauthorized, "Unauthorized!")
}
// get the user, and say welcome!
user, err := client.Users().Read(sessClaims.Claims.Subject)
if err != nil {
panic(err)
}
return c.String(http.StatusOK, "Welcome "+*user.FirstName)
}

11
api/ping.go Normal file
View file

@ -0,0 +1,11 @@
package api
import (
"net/http"
"github.com/labstack/echo/v4"
)
func Ping(c echo.Context) error {
return c.String(http.StatusOK, "Pong!")
}

11
api/post.copy.go Normal file
View file

@ -0,0 +1,11 @@
package api
import (
"net/http"
"github.com/labstack/echo/v4"
)
func PostCopy(c echo.Context) error {
return c.String(http.StatusOK, `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>`)
}

54
api/rss.go Normal file
View file

@ -0,0 +1,54 @@
package api
import (
"io/fs"
"net/http"
"strings"
"time"
contentfs "atri.dad/content"
"atri.dad/lib"
"github.com/gorilla/feeds"
"github.com/labstack/echo/v4"
)
func RSSFeedHandler(c echo.Context) error {
files, err := fs.ReadDir(contentfs.FS, ".")
protocol := "http"
if c.Request().TLS != nil {
protocol = "https"
}
feed := &feeds.Feed{
Title: "Atridad Lahiji's Blog",
Link: &feeds.Link{Href: protocol + "://" + c.Request().Host + "/api/rss"},
}
if err != nil {
http.Error(c.Response().Writer, "There was an issue finding posts!", http.StatusInternalServerError)
return nil
}
for _, file := range files {
if !file.IsDir() && strings.HasSuffix(file.Name(), ".md") {
frontMatter, err := lib.ExtractFrontMatter(file, contentfs.FS)
if err != nil {
http.Error(c.Response().Writer, "There was an issue rendering the posts!", http.StatusInternalServerError)
return nil
}
date, _ := time.Parse("January 2 2006", frontMatter.Date)
feed.Add(&feeds.Item{
Title: frontMatter.Name,
Link: &feeds.Link{Href: protocol + "://" + c.Request().Host + "/post/" + strings.TrimSuffix(file.Name(), ".md")},
Created: date,
})
}
}
rss, _ := feed.ToRss()
return c.Blob(http.StatusOK, "application/rss+xml", []byte(rss))
}

30
api/spotify.nowplaying.go Normal file
View file

@ -0,0 +1,30 @@
package api
import (
"net/http"
"os"
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
func NowPlayingHandler(c echo.Context) error {
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
refreshToken := os.Getenv("SPOTIFY_REFRESH_TOKEN")
playing, err := lib.GetCurrentlyPlayingTrack(clientID, clientSecret, refreshToken)
if err != nil {
http.Error(c.Response().Writer, err.Error(), http.StatusInternalServerError)
return err
}
if playing.Item != nil && playing.Playing {
songName := lib.NowPlayingTextFilter(playing.Item.Name)
artistName := lib.NowPlayingTextFilter(playing.Item.Artists[0].Name)
return c.String(http.StatusOK, `<div class="indicator-item badge badge-success"><a _='on mouseover put "🔥 Listening to `+songName+" by "+artistName+` 🔥" into my.textContent on mouseout put "🔥" into my.textContent' href="`+playing.Item.ExternalURLs["spotify"]+`" rel="noreferrer" target="_blank">🔥</a></div>`)
} else {
return c.String(http.StatusOK, "")
}
}

46
api/sse.go Normal file
View file

@ -0,0 +1,46 @@
package api
import (
"errors"
"fmt"
"atri.dad/lib"
"atri.dad/lib/pubsub"
"github.com/labstack/echo/v4"
)
func SSE(c echo.Context, pubSub pubsub.PubSub) error {
if pubSub == nil {
return errors.New("pubSub is nil")
}
channel := c.QueryParam("channel")
if channel == "" {
channel = "default"
}
// Use the request context, which is cancelled when the client disconnects
ctx := c.Request().Context()
pubsub, err := pubSub.SubscribeToChannel(channel)
if err != nil {
return fmt.Errorf("failed to subscribe to channel: %w", err)
}
lib.SetSSEHeaders(c)
// Create a client channel and add it to the SSE server
client := make(chan string)
lib.SSEServer.AddClient(channel, client)
defer lib.SSEServer.RemoveClient(channel, client)
go lib.HandleIncomingMessages(c, pubsub, client)
for {
select {
case <-ctx.Done():
// If the client has disconnected, stop the loop
return nil
}
}
}

26
api/stripe.pay.go Normal file
View file

@ -0,0 +1,26 @@
package api
import (
"net/http"
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
type PayRequest struct {
SuccessUrl string `json:"successUrl"`
CancelUrl string `json:"cancelUrl"`
PriceId string `json:"priceId"`
}
func Pay(c echo.Context) error {
payReq := new(PayRequest)
if err := c.Bind(payReq); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request payload"})
}
lib.CreateCheckoutSession(c.Response().Writer, c.Request(), payReq.SuccessUrl, payReq.CancelUrl, payReq.PriceId)
return c.String(http.StatusOK, "Checkout session created")
}

45
api/tools.resize.go Normal file
View file

@ -0,0 +1,45 @@
package api
import (
"fmt"
"net/http"
"strconv"
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
func ResizeHandler(c echo.Context) error {
// Extract file from request
file, _, err := c.Request().FormFile("image")
if err != nil {
return c.String(http.StatusBadRequest, "Error getting image file")
}
defer file.Close()
// Get dimensions from form data parameters
widthStr := c.FormValue("width")
heightStr := c.FormValue("height")
// Validate and convert dimensions to integers
width, err := strconv.Atoi(widthStr)
if err != nil {
return c.String(http.StatusBadRequest, "Invalid width parameter")
}
height, err := strconv.Atoi(heightStr)
if err != nil {
return c.String(http.StatusBadRequest, "Invalid height parameter")
}
fileBlob, err := lib.ResizeImg(file, width, height)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", "resized.png"))
return c.Blob(http.StatusOK, "image/png", fileBlob)
}

37
api/tools.sendsse.go Normal file
View file

@ -0,0 +1,37 @@
package api
import (
"net/http"
"atri.dad/lib"
"atri.dad/lib/pubsub"
"github.com/labstack/echo/v4"
)
func SSEDemoSend(c echo.Context, pubSub pubsub.PubSub) error {
channel := c.QueryParam("channel")
if channel == "" {
channel = "default"
}
// Get message from query parameters, form value, or request body
message := c.QueryParam("message")
if message == "" {
message = c.FormValue("message")
if message == "" {
var body map[string]string
if err := c.Bind(&body); err != nil {
return err
}
message = body["message"]
}
}
if message == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "message parameter is required"})
}
lib.SendSSE(c.Request().Context(), pubSub, "default", message)
return c.JSON(http.StatusOK, map[string]string{"status": "message sent"})
}

78
api/webhooks/clerk.go Normal file
View file

@ -0,0 +1,78 @@
package webhooks
import (
"encoding/json"
"io"
"net/http"
"os"
"atri.dad/lib"
"github.com/labstack/echo/v4"
svix "github.com/svix/svix-webhooks/go"
)
// Types
type ClerkEventEmail struct {
EmailAddress string `json:"email_address"`
}
type ClerkEventData struct {
EmailAddresses []ClerkEventEmail `json:"email_addresses,omitempty"`
Id string `json:"id"`
}
type ClerkEvent struct {
Data ClerkEventData
Type string
}
// Event Handlers
func userCreatedHandler(event ClerkEvent) {
welcomeEmail := `
<h1>Thank you for making an atri.dad account!</h1>
<h2>There are a number of apps this account give you access to!</h2>
<br/>
<ul>
<li>Atash Demo: https://atash.atri.dad/</li>
<li>Commodore: https://commodore.atri.dad/</li>
</ul>
`
lib.SendEmail(event.Data.EmailAddresses[0].EmailAddress, "apps@atri.dad", "Atri's Apps", welcomeEmail, "Welcome to Atri's Apps!")
}
// Main Handler/Router
func ClerkWebhookHandler(c echo.Context) error {
secret := os.Getenv("CLERK_WEBHOOK_SECRET")
wh, err := svix.NewWebhook(secret)
if err != nil {
return c.String(http.StatusBadRequest, "Unknown Validation Error")
}
headers := c.Request().Header
payload, err := io.ReadAll(c.Request().Body)
if err != nil {
return c.String(http.StatusBadRequest, "Failed to read request body!")
}
err = wh.Verify(payload, headers)
if err != nil {
return c.String(http.StatusBadRequest, "Cannot validate webhook authenticity!")
}
var parsed ClerkEvent
err = json.Unmarshal(payload, &parsed)
if err != nil {
return c.String(http.StatusBadRequest, "Invalid Json!")
}
switch parsed.Type {
case "user.created":
userCreatedHandler(parsed)
}
return c.String(http.StatusOK, "Success!")
}

View file

@ -1,15 +0,0 @@
export default function HomeButtonLinks() {
return (
<div class="flex flex-row gap-4 text-3xl">
<a
href="/files/Atridad_Lahiji_Resume_Public.pdf"
target="_blank"
rel="noopener noreferrer"
aria-label="React"
class="btn btn-dash btn-primary"
>
Resumé
</a>
</div>
);
}

View file

@ -1,37 +0,0 @@
import { LuArrowRight, LuClock } from "@preact-icons/lu";
import { Post } from "../lib/posts.ts";
export default function PostCard(props: { post: Post }) {
const { post } = props;
return (
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
<div class="card-body p-4 sm:p-6">
<h2 class="card-title text-base-100 justify-center text-center break-words">
{post.title}
</h2>
<p class="text-center text-base-100 break-words">{post.blurb}</p>
<div class="flex flex-wrap items-center justify-center text-base-100 opacity-75 gap-2 text-sm sm:text-base">
<LuClock class="flex-shrink-0" />
<span>
{post.publishedAt!.toLocaleDateString("en-us", {
month: "long",
day: "numeric",
year: "numeric",
})}
</span>
</div>
<div class="card-actions justify-end mt-4">
<a
href={`/post/${post.slug}`}
class="btn btn-circle btn-sm sm:btn-md btn-primary text-accent"
>
<LuArrowRight class="text-lg" />
</a>
</div>
</div>
</div>
);
}

View file

@ -1,37 +0,0 @@
import { LuLink } from "@preact-icons/lu";
interface Project {
id: string;
name: string;
description: string;
link: string;
}
export default function ProjectCard(props: { project: Project }) {
const { project } = props;
return (
<div class="card bg-accent shadow-lg w-full sm:w-[calc(50%-1rem)] md:w-96 min-w-[280px] max-w-sm shrink">
<div class="card-body p-6">
<h2 class="card-title text-xl md:text-2xl font-bold justify-center text-center break-words text-base-100">
{project.name}
</h2>
<p class="text-center break-words my-4 text-base-100">
{project.description}
</p>
<div class="card-actions justify-end mt-4 ">
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
class="btn btn-circle btn-secondary text-accent"
aria-label={`Visit ${project.name}`}
>
<LuLink class="text-lg" />
</a>
</div>
</div>
</div>
);
}

View file

@ -1,44 +0,0 @@
import { LuMail } from "@preact-icons/lu";
import { SiBluesky, SiForgejo, SiRss } from "@preact-icons/si";
export default function SocialLinks() {
return (
<div class="flex flex-row gap-4 text-xl sm:text-3xl">
<a
href="mailto:me@atri.dad"
aria-label="Email me"
class="hover:text-primary transition-colors"
>
<LuMail />
</a>
<a
href="/feed"
aria-label="RSS Feed"
class="hover:text-primary transition-colors"
>
<SiRss />
</a>
<a
href="https://git.atri.dad/atridad"
target="_blank"
rel="noopener noreferrer"
aria-label="Forgejo (Git)"
class="hover:text-primary transition-colors"
>
<SiForgejo />
</a>
<a
href="https://bsky.app/profile/atri.dad"
target="_blank"
rel="noopener noreferrer"
aria-label="Bluesky Profile"
class="hover:text-primary transition-colors"
>
<SiBluesky />
</a>
</div>
);
}

View file

@ -1,85 +0,0 @@
import {
SiDeno,
SiDocker,
SiGo,
SiPostgresql,
SiReact,
SiRedis,
SiTypescript,
} from "@preact-icons/si";
export default function TechLinks() {
return (
<div class="flex flex-row gap-4 text-xl sm:text-3xl">
<a
href="https://react.dev/"
target="_blank"
rel="noopener noreferrer"
aria-label="React"
class="hover:text-primary transition-colors"
>
<SiReact />
</a>
<a
href="https://www.typescriptlang.org/"
target="_blank"
rel="noopener noreferrer"
aria-label="TypeScript"
class="hover:text-primary transition-colors"
>
<SiTypescript />
</a>
<a
href="https://deno.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="Deno"
class="hover:text-primary transition-colors"
>
<SiDeno />
</a>
<a
href="https://go.dev/"
target="_blank"
rel="noopener noreferrer"
aria-label="Go"
class="hover:text-primary transition-colors"
>
<SiGo />
</a>
<a
href="https://www.postgresql.org/"
target="_blank"
rel="noopener noreferrer"
aria-label="PostgreSQL"
class="hover:text-primary transition-colors"
>
<SiPostgresql />
</a>
<a
href="https://redis.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Redis"
class="hover:text-primary transition-colors"
>
<SiRedis />
</a>
<a
href="https://www.docker.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="Docker"
class="hover:text-primary transition-colors"
>
<SiDocker />
</a>
</div>
);
}

View file

@ -0,0 +1,18 @@
---
name: "Thoughts on Cognitive Load and Programming Language Syntax"
date: "February 07 2024"
tags: ["article","thoughts"]
---
Recently, I started to pick up a new language in my spare time: Go. Was it partially influenced by the surge in HTMX popularity and how often Go is used alongside it? Almost certainly. But at some point along this journey, I noticed something: This is _so_ much more fun and _so_ much less draining. This whole post won't be me gushing about how much I love Go as a language, not directly. I started to notice a few things...
# Oh, what fun...
I come from the JavaScript and TypeScript world with frameworks like Remix and Next.js. And the DX (Developer Experience) for these is lovely! For reasons I could not quite pin down, I was having much more fun with Go + HTMX despite the hacks to get close to the same DX. It came down to how darn easily I could pull off things I had previously relied on services for. Need a message queue to run on the edge? Easy, write a wrapper over Redis to use its PubSub. Need real-time updates to the UI? No problem, write a basic Server-Sent Events system with subscriptions and channels. Anything I wanted, I could build. That, and the way concurrency _"just works"_, and I could make anything I wanted happen. This brings a level of satisfaction that is hard to convey in an article.
# What changed?
So this isn't meant to be a post to bash JavaScript, as much as I do have my reservations about the language. That being said, JS makes basic things like concurrency an afterthought. Go's lightweight threads, called goroutines, make it easy to write concurrent programs without worrying about the overhead of heavyweight threads. Even better, Go has channels that provide a simple and effective way of synchronizing communication between goroutines, making it easier to write concurrent programs free from race conditions. Similar concepts are present in Java, Rust (Tokio), C#, etc. Even better, error handling! Now, this is controversial, but forcing errors to be valued and allowing functions to assert that there _may_ be errors is a game changer for writing safer code. All of this amounts to a much lower cognitive load. I am less worried that my code is inefficient, error-prone, etc. I can just _write_.
# What does this mean?
As a field, we can and should investigate and identify key points in programming languages that increase or decrease the mental load or overhead for developers. Imagine if we could identify these points and work to reduce the overhead. This has been on my mind as I reflect on my experience learning Go. I want to dive deeper into the details of what we can do to reduce this overhead and get back to writing good software without unnecessary restrictions.
# Thanks!
Do you have any thoughts on this? Do you want to have a chat about the topic? Feel free to reach out by email at [me@atri.dad](mailto:me@atri.dad). Until next time! 🫡

6
content/contentfs.go Normal file
View file

@ -0,0 +1,6 @@
package contentfs
import "embed"
//go:embed *
var FS embed.FS

78
content/goth-stack.md Normal file
View file

@ -0,0 +1,78 @@
---
name: "Introducing the GOTH Stack"
date: "January 08 2024"
tags: ["article","golang"]
---
# Enter the GOTH Stack!
The GOTH stack is something I've been trying to get to for a while now. It's not a specific repository with a fancy command that can scaffold a project for you. It's more like a set of pillars for building excellent, pleasant, full-stack web applications.
# The first pillar: Go
Go is something I learned to love later on in my career. I was mainly writing JavaScript, building on serverless platforms and growing frustrated at the performance and limitations. Go changed all of that.
What makes Go good?:
- Static types
- Incredibly easy concurrency
- Errors as values
- Incredible runtime and build time performance
- Tiny memory footprint
The tl;dr is that it is challenging to write Go code that is _not_ performant.
# The second pillar: Templates... well... Go templates
Go templates surprised me, to be completely honest. They offer just enough to get me going and perform exceptionally well. Sure, it's not as simple as a basic JSX file in Next.js since you need to make a route handler, but it works pretty well and supports basic control flow. I am interested in looking into alternatives such as TEMPL (which reads much like JSX for Go), but I need to find a real reason to move from the standard library here.
Here is an example of a route handler passing a slice over to a template for rendering:
```go
package pages
import (
"HTML/template"
"github.com/labstack/echo/v4"
"goth.stack/lib"
)
type HomeProps struct {
Socials []lib.IconLink
Tech []lib.IconLink
ContractLink string
ResumeURL string
SupportLink string
}
func Home(c echo.Context) error {
socials := []lib.IconLink{
{
Name: "Email",
Href: "mailto:example@site.com",
Icon: template.HTML(`<svg xmlns="http://www.w3.org/2000/svg" height="32" width="32" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2023 Fonticons, Inc.--><path d="M48 64C21.5 64 0 85.5 0 112c0 15.1 7.1 29.3 19.2 38.4L236.8 313.6c11.4 8.5 27 8.5 38.4 0L492.8 150.4c12.1-9.1 19.2-23.3 19.2-38.4c0-26.5-21.5-48-48-48H48zM0 176V384c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V176L294.4 339.2c-22.8 17.1-54 17.1-76.8 0L0 176z"/></svg>`),
},
}
props := HomeProps{
Socials: socials,
Tech: tech,
ContractLink: "mailto:example@site.com",
ResumeURL: "https://srv.goth.stack/Atridad_Lahiji_Resume.pdf",
SupportLink: "https://donate.stripe.com/8wMeVF25c78L0V2288",
}
// Specify the partials used by this page
partials := []string{"header", "navitems"}
// Render the template
return lib.RenderTemplate(c.Response().Writer, "base", partials, props)
}
```
As you can see, it really isn't that bad! It also comes with many of the benefits of Go and the flexibility of components!
# The third pillar: HTMX
So, up to this point, you may have been thinking: "Gee Atri... you can't do anything reactive here". Before HTMX, you would have been right. HTMX offers a more backend-centric developer a way to build complex reactivity to their front end through basic templating languages. It is one file you import in your template, and it enables anything from basic HTML swapping to WebSocket and Server-Sent Event support. It is really, really powerful and worth looking at all together.
With Go managing route handlers and API routes, the template language running the UI, and HTMX governing interactivity on the front end, you can effectively write a fully dynamic full-stack application without writing a line of JavaScript code. It even runs quite a bit faster than some of the JS world's frameworks (Astro, for instance). It is actually what powers this site right now! The fundamentals are essential here and come together for a clean and enjoyable developer experience. I encourage everyone in the JS world to give it a shot! Perhaps it is not your thing, and that's okay! But you might also just fall in love with it!
# Giving It A Shot
I have a [repository](https://github.com/atridadl/goth.stack) as well as a [demo](https://goth-stack.fly.dev/) ready to go with everything you need to start!
# Thanks!
If you found this helpful, please let me know by email at [me@atri.dad](mailto:me@atri.dad). Until next time!

View file

@ -0,0 +1,28 @@
---
name: "Build Scalable Real-time Applications on Fly.io + Remix!"
date: "December 04 2023"
tags: ["remix.js", "fly.io", "article"]
---
# Scalability... what is it?
You often think of vertical scaling for something to scale, adding more resources to whatever issue you encounter (CPU, RAM, etc.). While this is the simplest way to scale an application or service, it can only get you so far. Horizontal scaling involves adding more machines or "nodes" running the same application. These nodes sit behind a load balancer responsible for directing client traffic to the appropriate machine to optimize load. This will work well if the application is meant to do simple CRUD operations for individual users. What if you need collaboration in real time? This complicates things...
# The real-time problem:
Real-time applications require a pub/sub or publish and subscribe model. A client will send a request to the application to perform an operation. Once done, the server will broadcast an event to all subscribing clients to trigger a re-fetch of data. In the case of a multi-node application, you need to use a service outside of your nodes to synchronize messages across all nodes.
# The stack:
For this stack, I chose Remix for its close adherence to web standards and easy support for server-sent events. These web socket connections work one way: from server to client. Next, we must synchronize all Server Sent events across different requests to a single node. For this, Node.js has its own Event Emitter API, which we can use. Now, we can use something like Redis and its Pub/Sub commands for multi-node setups to broadcast across nodes.
This is what it would look like:
![Diagram](https://link.storjshare.io/s/jwmhimh32pura4pyr5h5luou6qla/atridad%2Farticles/scalability.png?wrap=0)
# How does it work?
Once a client connects to a page with real-time enabled, a persistent connection via EventStreams is made. The client would request to make a real-time update. Once the endpoint completes the request, it triggers a Node.js event, which the EventStreams endpoint listens to. Once received, the application sends an event down to the client via a server-sent event while also passing that request on to Redis pub/sub. Every node listens to this Redis event stream, so every node will receive the event and trigger the event using Node.js events, which then start server-sent events.
As you can see, there are many moving parts here, and it can get quite complicated. I have a repo called Atash, which acts as a template to get started. You can check it out [here](https://atash.atri.dad)!
If you found this helpful, please let me know by email at [me@atri.dad](mailto:me@atri.dad). Until next time!

18
content/thoughts-on-ai.md Normal file
View file

@ -0,0 +1,18 @@
---
name: "Thoughts on AI"
date: "April 09 2024"
tags: ["article","thoughts"]
---
I'm a bit late to the party, but AI has definitely taken off lately. Every product we use now seems to have some sort of AI integration or feature baked in to capture some of the hype. How do I feel about it? Well… thats complicated...
# Memes
If you dig around my GitHub you'll surely stuble upon Himbot, my Discord bot project. This is where I enjoy AI. For memes. I have two commands: "ask" and "pic" which generate text and images respectively. Now, I didn't write some complex algorithm for this. It uses Replicate to access open source models. As it turns out, this makes the bot quite fun. Have an unhinged discord conversation that needs image replies, just throw some no-context nonsense into "/pic" and see what happens! Its amazing! This, however, is where my love of it ends.
# Capitalism
Capitalism ruins all things. Including AI. Now what could be a useful and fun technology is rammed into every product ever at the _expense_ of its users just to check a box for shareholders. Notion? It has AI now. Snapchat? Yup… an AI assistant there too. Wan't to use windows? I hope you aren't planning to ignore Microsoft's new OS level AI Copilot! There is no escape it seems. Even DuckDuckGo and Brave Browser are using it now (although given Brave still clings to the trash that is Web3 I am less surprised there). My point is, there is definitely going to be AI fatigue, if there is not already. Capitalism won't let us forget about this though, since it seems to line the pockets of the ultra-rich anyways. Want to make a game but don't want to pay an artist? Just pay for some compute and generate images with **prompt engineering**! How did that model get the data to learn to do this? No need to worry your little heads over that detail if its making you money! All of this and more is enabled by this technology and encouraged by our economic systems.
# What can we do?
What can we do about this? Is there a hope for us trying to escape AI? Honestly, I am not sure. But I am definitely trying. I have removed copilot from my Windows install, and I refuse to use anything that forces me to use AI. Will I still use it for fun in my discord bot? Absolutely! But being concious about how you use AI is key. With so many of these models trained on the work of skilled creatives, it is _our_ responsibility as consumers of this technology to ensure that we are _always_ paying professionals for their work, and not using their skills for free via a heartless algorithm. Lastly, be causious of products that add AI for seemingly no reason. There is a good chance your data is being used to train it.
# Thanks!
Do you have any thoughts on AI? Do you use it? If so, how? Feel free to reach out by email at [me@atri.dad](mailto:me@atri.dad). Until next time! 🫡

View file

@ -1,34 +0,0 @@
{
"nodeModulesDir": "auto",
"lock": false,
"tasks": {
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
"manifest": "deno task cli manifest $(pwd)",
"start": "deno run -A --watch=static/,routes/ dev.ts",
"build": "deno run -A dev.ts build",
"preview": "deno run -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
"lint": { "rules": { "tags": ["fresh", "recommended"] } },
"exclude": ["**/_fresh/*"],
"imports": {
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
"@deno/gfm": "jsr:@deno/gfm@^0.11.0",
"@pakornv/fresh-plugin-tailwindcss": "jsr:@pakornv/fresh-plugin-tailwindcss@^1.0.2",
"@preact-icons/lu": "jsr:@preact-icons/lu@^1.0.13",
"@preact-icons/si": "jsr:@preact-icons/si@^1.0.13",
"@std/front-matter": "jsr:@std/front-matter@^1.0.9",
"@std/path": "jsr:@std/path@^1.0.9",
"@tailwindcss/typography": "npm:@tailwindcss/typography@^0.5.16",
"daisyui": "npm:daisyui@^5.0.27",
"preact": "npm:preact@10.22.1",
"preact/jsx-runtime": "npm:preact@10.22.1/jsx-runtime",
"preact/hooks": "npm:preact@10.22.1/hooks",
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.2",
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.5.1",
"$std/": "https://deno.land/std@0.216.0/",
"tailwindcss": "npm:tailwindcss@^4.1.4"
},
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" }
}

8
dev.ts
View file

@ -1,8 +0,0 @@
#!/usr/bin/env -S deno run -A --watch=static/,routes/
import dev from "$fresh/dev.ts";
import config from "./fresh.config.ts";
import "$std/dotenv/load.ts";
await dev(import.meta.url, "./main.ts", config);

View file

@ -1,10 +0,0 @@
version: '3.8'
services:
app:
image: ${IMAGE:-ghcr.io/yourusername/your-fresh-project:latest}
restart: unless-stopped
environment:
- DENO_DEPLOYMENT=production
ports:
- "3000:8000"

View file

@ -1,6 +0,0 @@
import { defineConfig } from "$fresh/server.ts";
import tailwind from "@pakornv/fresh-plugin-tailwindcss";
export default defineConfig({
plugins: [tailwind()],
});

View file

@ -1,41 +0,0 @@
// DO NOT EDIT. This file is generated by Fresh.
// This file SHOULD be checked into source version control.
// This file is automatically updated during development when running `dev.ts`.
import * as $_404 from "./routes/_404.tsx";
import * as $_app from "./routes/_app.tsx";
import * as $_layout from "./routes/_layout.tsx";
import * as $api_chat from "./routes/api/chat.ts";
import * as $api_ping from "./routes/api/ping.ts";
import * as $chat from "./routes/chat.tsx";
import * as $index from "./routes/index.tsx";
import * as $post_slug_ from "./routes/post/[slug].tsx";
import * as $posts from "./routes/posts.tsx";
import * as $projects from "./routes/projects.tsx";
import * as $Chat from "./islands/Chat.tsx";
import * as $NavigationBar from "./islands/NavigationBar.tsx";
import * as $ScrollUpButton from "./islands/ScrollUpButton.tsx";
import type { Manifest } from "$fresh/server.ts";
const manifest = {
routes: {
"./routes/_404.tsx": $_404,
"./routes/_app.tsx": $_app,
"./routes/_layout.tsx": $_layout,
"./routes/api/chat.ts": $api_chat,
"./routes/api/ping.ts": $api_ping,
"./routes/chat.tsx": $chat,
"./routes/index.tsx": $index,
"./routes/post/[slug].tsx": $post_slug_,
"./routes/posts.tsx": $posts,
"./routes/projects.tsx": $projects,
},
islands: {
"./islands/Chat.tsx": $Chat,
"./islands/NavigationBar.tsx": $NavigationBar,
"./islands/ScrollUpButton.tsx": $ScrollUpButton,
},
baseUrl: import.meta.url,
} satisfies Manifest;
export default manifest;

48
go.mod Normal file
View file

@ -0,0 +1,48 @@
module atri.dad
go 1.22.0
require (
github.com/alecthomas/chroma/v2 v2.13.0
golang.org/x/oauth2 v0.19.0
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/image v0.15.0 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
)
require (
github.com/aws/aws-sdk-go v1.51.23
github.com/clerkinc/clerk-sdk-go v1.49.0
github.com/disintegration/imaging v1.6.2
github.com/fatih/color v1.16.0
github.com/gorilla/feeds v1.1.2
github.com/joho/godotenv v1.5.1
github.com/labstack/echo/v4 v4.12.0
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/redis/go-redis/v9 v9.5.1
github.com/stripe/stripe-go/v76 v76.25.0
github.com/svix/svix-webhooks v1.21.0
github.com/yuin/goldmark v1.7.1
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
github.com/zmb3/spotify v1.3.0
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/sys v0.19.0 // indirect
gopkg.in/yaml.v2 v2.4.0
)

177
go.sum Normal file
View file

@ -0,0 +1,177 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
github.com/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI=
github.com/alecthomas/chroma/v2 v2.13.0/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk=
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aws/aws-sdk-go v1.51.21 h1:UrT6JC9R9PkYYXDZBV0qDKTualMr+bfK2eboTknMgbs=
github.com/aws/aws-sdk-go v1.51.21/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/aws/aws-sdk-go v1.51.23 h1:/3TEdsEE/aHmdKGw2NrOp7Sdea76zfffGkTTSXTsDxY=
github.com/aws/aws-sdk-go v1.51.23/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/brianvoe/gofakeit/v6 v6.19.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clerkinc/clerk-sdk-go v1.49.0 h1:tJLIAx3qfP2cNQJ/iPq6OF1BSB0NzI3alcOuEueexoA=
github.com/clerkinc/clerk-sdk-go v1.49.0/go.mod h1:pejhMTTDAuw5aBpiHBEOOOHMAsxNfPvKfM5qexFJYlc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/feeds v1.1.2 h1:pxzZ5PD3RJdhFH2FsJJ4x6PqMqbgFk1+Vez4XWBW8Iw=
github.com/gorilla/feeds v1.1.2/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stripe/stripe-go/v76 v76.25.0 h1:kmDoOTvdQSTQssQzWZQQkgbAR2Q8eXdMWbN/ylNalWA=
github.com/stripe/stripe-go/v76 v76.25.0/go.mod h1:rw1MxjlAKKcZ+3FOXgTHgwiOa2ya6CPq6ykpJ0Q6Po4=
github.com/svix/svix-webhooks v1.21.0 h1:ZxoPU2SJGjmRy1qMaeHY1VdZhTaEkHuh3ruy4CrxW3Y=
github.com/svix/svix-webhooks v1.21.0/go.mod h1:qGeiECF5WRQElyfF0i2CqUtWk2GQJTgL+EJZ/WRCxok=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.4.15/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ=
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I=
github.com/zmb3/spotify v1.3.0 h1:6Z2F1IMx0Hviq/dpf8nFwvKPppFEMXn8yfReSBVi16k=
github.com/zmb3/spotify v1.3.0/go.mod h1:GD7AAEMUJVYc2Z7p2a2S0E3/5f/KxM/vOnErNr4j+Tw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,161 +0,0 @@
import { useEffect, useState } from "preact/hooks";
interface ChatMessage {
text: string;
sender: string;
timestamp: string;
}
export default function Chat() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [newMessage, setNewMessage] = useState("");
const [username, setUsername] = useState("");
const [socket, setSocket] = useState<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [userCount, setUserCount] = useState(0);
useEffect(() => {
if (!username) {
const randomNum = Math.floor(Math.random() * 10000);
setUsername(`HumanGuest${randomNum}`);
}
const wsProtocol = globalThis.location.protocol === "https:"
? "wss:"
: "ws:";
const ws = new WebSocket(
`${wsProtocol}//${globalThis.location.host}/api/chat`,
);
ws.onopen = () => {
console.log("Connected to chat");
setIsConnected(true);
setSocket(ws);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "user_count") {
setUserCount(data.count);
} else {
setMessages((prev) => [...prev, data]);
// Auto-scroll to bottom on new message
const chatBox = document.getElementById("chat-messages");
if (chatBox) {
setTimeout(() => {
chatBox.scrollTop = chatBox.scrollHeight;
}, 50);
}
}
} catch (err) {
console.error("Error processing message:", err);
}
};
ws.onclose = () => {
console.log("Disconnected from chat");
setIsConnected(false);
};
return () => {
ws.close();
};
}, []);
const sendMessage = (e: Event) => {
e.preventDefault();
if (!newMessage.trim() || !socket) return;
const messageData = {
text: newMessage,
sender: username,
timestamp: new Date().toISOString(),
};
socket.send(JSON.stringify(messageData));
setNewMessage("");
};
return (
<div class="w-full max-w-4xl mx-auto bg-[#1E2127] rounded-lg shadow-lg overflow-hidden border border-gray-800 flex flex-col h-[70vh]">
{/* Header */}
<div class="p-4 bg-secondary text-white">
<h2 class="text-2xl font-bold">Live Chat</h2>
<p class="text-sm">
{isConnected
? `${userCount} online • Messages are not saved`
: "Connecting..."}
</p>
</div>
<div
id="chat-messages"
class="flex-grow overflow-y-auto bg-[#1E2127] text-gray-300 p-4"
>
{messages.length === 0
? (
<p class="text-center text-gray-500 py-8">
No messages yet.
</p>
)
: (
messages.map((msg, i) => (
<div
key={i}
class={`mb-3 max-w-[85%] ${
msg.sender === username ? "ml-auto" : ""
}`}
>
<div
class={`px-4 py-2 rounded-lg ${
msg.sender === username
? "bg-secondary text-white rounded-br-none"
: "bg-gray-800 text-gray-200 rounded-bl-none"
}`}
>
<div class="flex justify-between items-baseline mb-1">
<span class="font-bold text-sm">
{msg.sender === username ? "You" : msg.sender}
</span>
<span class="text-xs opacity-70 ml-2">
{new Date(msg.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
<p class="break-words">{msg.text}</p>
</div>
</div>
))
)}
</div>
<div class="p-3 border-t border-gray-800 pb-6 md:pb-3">
<form onSubmit={sendMessage} class="relative">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.currentTarget.value)}
placeholder="Type your message..."
class="w-full pl-4 pr-20 py-3 bg-gray-800 text-white rounded-lg border-0 focus:outline-none focus:ring-1 focus:ring-secondary placeholder-gray-500"
disabled={!isConnected}
/>
<button
type="submit"
class="absolute right-0 top-0 h-full bg-secondary text-white px-5 rounded-r-lg font-medium"
disabled={!isConnected || !newMessage.trim()}
>
Send
</button>
</form>
<p class="mt-2 text-xs text-gray-500">
You are connected as{" "}
<span class="font-medium text-gray-400">{username}</span>
</p>
</div>
</div>
);
}

View file

@ -1,102 +0,0 @@
import { useComputed, useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import {
LuCodeXml,
LuHouse,
LuMessageCircle,
LuNotebookPen,
} from "@preact-icons/lu";
interface NavigationBarProps {
currentPath: string;
}
export default function NavigationBar({ currentPath }: NavigationBarProps) {
const isScrolling = useSignal(false);
const prevScrollPos = useSignal(0);
const isVisible = useComputed(() => {
if (prevScrollPos.value < 50) return true;
const currentPos = typeof window !== "undefined" ? globalThis.scrollY : 0;
return prevScrollPos.value > currentPos;
});
const isPostsPath = (path: string) => {
return path.startsWith("/posts") || path.startsWith("/post/");
};
useEffect(() => {
let scrollTimer: number | undefined;
const handleScroll = () => {
isScrolling.value = true;
prevScrollPos.value = globalThis.scrollY;
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
isScrolling.value = false;
}, 200);
};
globalThis.addEventListener("scroll", handleScroll);
return () => {
globalThis.removeEventListener("scroll", handleScroll);
if (scrollTimer) clearTimeout(scrollTimer);
};
}, []);
return (
<div
class={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-20 transition-all duration-300 ${
isScrolling.value ? "opacity-30" : "opacity-100"
} ${isVisible.value ? "translate-y-0" : "translate-y-20"}`}
>
<div class="overflow-visible">
<ul class="menu menu-horizontal bg-base-200 rounded-box p-2 shadow-lg flex flex-nowrap whitespace-nowrap">
<li class="mx-1">
<a href="/" class={currentPath === "/" ? "menu-active" : ""}>
<div class="tooltip" data-tip="Home">
<LuHouse class="text-xl" />
</div>
</a>
</li>
<li class="mx-1">
<a
href="/posts"
class={isPostsPath(currentPath) ? "menu-active" : ""}
>
<div class="tooltip" data-tip="Posts">
<LuNotebookPen class="text-xl" />
</div>
</a>
</li>
<li class="mx-1">
<a
href="/projects"
class={currentPath.startsWith("/projects") ? "menu-active" : ""}
>
<div class="tooltip" data-tip="Projects">
<LuCodeXml class="text-xl" />
</div>
</a>
</li>
<li class="mx-1">
<a
href="/chat"
class={currentPath.startsWith("/chat") ? "menu-active" : ""}
>
<div class="tooltip" data-tip="Chat">
<LuMessageCircle class="text-xl" />
</div>
</a>
</li>
</ul>
</div>
</div>
);
}

View file

@ -1,45 +0,0 @@
import { useSignal } from "@preact/signals";
import { useEffect } from "preact/hooks";
import { LuArrowUp } from "@preact-icons/lu";
export default function ScrollUpButton() {
const isVisible = useSignal(false);
useEffect(() => {
const checkScroll = () => {
isVisible.value = globalThis.scrollY > 300;
};
checkScroll();
globalThis.addEventListener("scroll", checkScroll);
return () => {
globalThis.removeEventListener("scroll", checkScroll);
};
}, []);
const scrollToTop = () => {
globalThis.scrollTo({
top: 0,
behavior: "smooth",
});
};
return (
<button
type="button"
onClick={scrollToTop}
class={`fixed bottom-20 right-4 z-20 bg-secondary hover:bg-primary
p-3 rounded-full shadow-lg transition-all duration-300
${
isVisible.value
? "opacity-70 translate-y-0"
: "opacity-0 translate-y-10 pointer-events-none"
}`}
aria-label="Scroll to top"
>
<LuArrowUp class="text-lg" />
</button>
);
}

102
lib/email.go Normal file
View file

@ -0,0 +1,102 @@
package lib
import (
"crypto/tls"
"log"
"net/smtp"
"os"
)
func SendEmail(to_email string, from_email string, from_name string, html string, subject string) {
log.Println("Starting email sending process")
// Set up authentication information.
auth := smtp.PlainAuth(
"",
os.Getenv("SMTP_USERNAME"),
os.Getenv("SMTP_PASSWORD"),
os.Getenv("SMTP_HOST"),
)
log.Println("Authentication set up")
// Connect to the server, authenticate, set the sender and recipient,
// and send the email all in one step.
msg := []byte("From: " + from_name + " <" + from_email + ">\r\n" +
"To: " + to_email + "\r\n" +
"Subject: " + subject + "\r\n" +
"Content-Type: text/html; charset=UTF-8" + "\r\n" +
"\r\n" +
html + "\r\n")
tlsconfig := &tls.Config{
InsecureSkipVerify: false,
ServerName: os.Getenv("SMTP_HOST"),
}
log.Println("TLS configuration set up")
c, err := smtp.Dial(os.Getenv("SMTP_HOST") + ":587")
if err != nil {
log.Println("Error dialing SMTP server:", err)
return
}
log.Println("Connected to SMTP server")
if err = c.StartTLS(tlsconfig); err != nil {
log.Println("Error starting TLS:", err)
return
}
log.Println("TLS started")
if err = c.Auth(auth); err != nil {
log.Println("Error authenticating with SMTP server:", err)
return
}
log.Println("Authenticated with SMTP server")
if err = c.Mail(from_email); err != nil {
log.Println("Error setting sender address:", err)
return
}
log.Println("Sender address set")
if err = c.Rcpt(to_email); err != nil {
log.Println("Error setting recipient address:", err)
return
}
log.Println("Recipient address set")
w, err := c.Data()
if err != nil {
log.Println("Error getting write closer:", err)
return
}
log.Println("Got write closer")
_, err = w.Write(msg)
if err != nil {
log.Println("Error writing message:", err)
return
}
log.Println("Message written")
err = w.Close()
if err != nil {
log.Println("Error closing write closer:", err)
return
}
log.Println("Write closer closed")
c.Quit()
log.Println("Email sent successfully")
}

39
lib/img.go Normal file
View file

@ -0,0 +1,39 @@
package lib
import (
"bytes"
"errors"
"image"
"image/png"
"io"
"mime/multipart"
"github.com/disintegration/imaging"
)
func ResizeImg(file multipart.File, width int, height int) ([]byte, error) {
// Read file content
fileContent, err := io.ReadAll(file)
if err != nil {
return nil, errors.New("error reading image file")
}
// Decode image
img, _, err := image.Decode(bytes.NewReader(fileContent))
if err != nil {
println(err.Error())
return nil, errors.New("error decoding image")
}
// Resize the image
resizedImg := imaging.Resize(img, width, height, imaging.Lanczos)
// Encode the resized image as PNG
buf := new(bytes.Buffer)
if err := png.Encode(buf, resizedImg); err != nil {
return nil, errors.New("error encoding image to PNG")
}
// Return the resized image as response
return buf.Bytes(), nil
}

26
lib/links.go Normal file
View file

@ -0,0 +1,26 @@
package lib
import (
"html/template"
)
type IconLink struct {
Name string
Href string
Icon template.HTML
}
type CardLink struct {
Name string
Href string
Description string
Date string
Tags []string
Internal bool
}
type ButtonLink struct {
Name string
Href string
Internal bool
}

27
lib/logging.go Normal file
View file

@ -0,0 +1,27 @@
package lib
import "github.com/fatih/color"
// Error logging
var red = color.New(color.FgRed)
var LogError = red.Add(color.Bold)
// Info logging
var cyan = color.New(color.FgCyan)
var LogInfo = cyan.Add(color.Bold)
// Success logging
var green = color.New(color.FgGreen)
var LogSuccess = green.Add(color.Bold)
// Warning logging
var yellow = color.New(color.FgYellow)
var LogWarning = yellow.Add(color.Bold)
// Debug logging
var magenta = color.New(color.FgMagenta)
var LogDebug = magenta.Add(color.Bold)
// Custom logging
var white = color.New(color.FgWhite)
var LogCustom = white.Add(color.Bold)

65
lib/markdown.go Normal file
View file

@ -0,0 +1,65 @@
package lib
import (
"bufio"
"bytes"
"errors"
"fmt"
"io/fs"
"strings"
"github.com/yuin/goldmark"
"gopkg.in/yaml.v2"
)
type FrontMatter struct {
Name string
Date string
Tags []string
}
func ExtractFrontMatter(file fs.DirEntry, contentFS fs.FS) (CardLink, error) {
f, err := contentFS.Open(file.Name())
if err != nil {
return CardLink{}, fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()
scanner := bufio.NewScanner(f)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return CardLink{}, fmt.Errorf("failed to read file: %w", err)
}
content := strings.Join(lines, "\n")
splitContent := strings.SplitN(content, "---", 3)
if len(splitContent) < 3 {
return CardLink{}, fmt.Errorf("invalid file format: %s", file.Name())
}
frontMatter := CardLink{}
if err := yaml.Unmarshal([]byte(splitContent[1]), &frontMatter); err != nil {
return CardLink{}, fmt.Errorf("failed to unmarshal frontmatter: %w", err)
}
md := goldmark.New(goldmark.WithExtensions())
var buf bytes.Buffer
if err := md.Convert([]byte(splitContent[2]), &buf); err != nil {
return CardLink{}, fmt.Errorf("failed to convert markdown: %w", err)
}
return frontMatter, nil
}
func SplitFrontmatter(md []byte) (frontmatter []byte, content []byte, err error) {
parts := bytes.SplitN(md, []byte("---"), 3)
if len(parts) < 3 {
return nil, nil, errors.New("invalid or missing frontmatter")
}
return parts[1], parts[2], nil
}

View file

@ -1,45 +0,0 @@
import { extractYaml } from "@std/front-matter";
import { join } from "@std/path";
const POSTS_DIR = "./posts";
interface FrontMatter {
title: string;
published_at: string;
blurb: string;
}
export interface Post {
slug: string;
title: string;
publishedAt: Date | null;
blurb: string;
content: string;
}
export async function getPost(slug: string): Promise<Post> {
const text = await Deno.readTextFile(join(POSTS_DIR, `${slug}.md`));
const { attrs, body } = extractYaml<FrontMatter>(text);
const post = {
slug,
title: attrs.title,
publishedAt: attrs.published_at ? new Date(attrs.published_at) : null,
blurb: attrs.blurb,
content: body,
};
return post;
}
export async function getPosts(): Promise<Post[]> {
const files = Deno.readDir(POSTS_DIR);
const promises = [];
for await (const file of files) {
if (file.name.startsWith(".")) continue;
const slug = file.name.replace(".md", "");
promises.push(getPost(slug));
}
const posts = (await Promise.all(promises) as Post[])
.filter((post) => post.publishedAt instanceof Date);
posts.sort((a, b) => b.publishedAt!.getTime() - a.publishedAt!.getTime());
return posts;
}

View file

@ -0,0 +1,86 @@
package adapters
import (
"context"
"sync"
"atri.dad/lib"
"atri.dad/lib/pubsub"
)
type LocalPubSub struct {
subscribers map[string][]chan pubsub.Message
lock sync.RWMutex
}
type LocalPubSubMessage struct {
messages <-chan pubsub.Message
}
func (ps *LocalPubSub) SubscribeToChannel(channel string) (pubsub.PubSubMessage, error) {
ps.lock.Lock()
defer ps.lock.Unlock()
if ps.subscribers == nil {
ps.subscribers = make(map[string][]chan pubsub.Message)
}
ch := make(chan pubsub.Message, 100)
ps.subscribers[channel] = append(ps.subscribers[channel], ch)
lib.LogInfo.Printf("[PUBSUB/LOCAL] Subscribed to channel %s\n", channel)
return &LocalPubSubMessage{messages: ch}, nil
}
func (ps *LocalPubSub) PublishToChannel(channel string, message string) error {
subscribers, ok := ps.subscribers[channel]
if !ok {
lib.LogWarning.Printf("\n[PUBSUB/LOCAL] No subscribers for channel %s\n", channel)
return nil
}
ps.lock.Lock()
defer ps.lock.Unlock()
lib.LogInfo.Printf("\n[PUBSUB/LOCAL] Publishing message to channel %s: %s\n", channel, message)
for _, ch := range subscribers {
ch <- pubsub.Message{Payload: message}
}
return nil
}
func (ps *LocalPubSub) UnsubscribeFromChannel(channel string, ch <-chan pubsub.Message) {
ps.lock.Lock()
defer ps.lock.Unlock()
subscribers := ps.subscribers[channel]
for i, subscriber := range subscribers {
if subscriber == ch {
// Remove the subscriber from the slice
subscribers = append(subscribers[:i], subscribers[i+1:]...)
break
}
}
if len(subscribers) == 0 {
delete(ps.subscribers, channel)
} else {
ps.subscribers[channel] = subscribers
}
}
func (m *LocalPubSubMessage) ReceiveMessage(ctx context.Context) (*pubsub.Message, error) {
for {
select {
case <-ctx.Done():
// The client has disconnected. Stop trying to send messages.
return nil, ctx.Err()
case msg := <-m.messages:
// A message has been received. Send it to the client.
lib.LogInfo.Printf("\n[PUBSUB/LOCAL] Received message: %s\n", msg.Payload)
return &msg, nil
}
}
}

View file

@ -0,0 +1,71 @@
package adapters
import (
"context"
"os"
"atri.dad/lib"
"atri.dad/lib/pubsub"
"github.com/joho/godotenv"
"github.com/redis/go-redis/v9"
)
var RedisClient *redis.Client
type RedisPubSubMessage struct {
pubsub *redis.PubSub
}
// RedisPubSub is a Redis implementation of the PubSub interface.
type RedisPubSub struct {
Client *redis.Client
}
func NewRedisClient() (*redis.Client, error) {
if RedisClient != nil {
return RedisClient, nil
}
godotenv.Load(".env")
redis_url := os.Getenv("REDIS_URL")
opts, err := redis.ParseURL(redis_url)
if err != nil {
return nil, err
}
lib.LogInfo.Printf("\n[PUBSUB/REDIS]Connecting to Redis at %s\n", opts.Addr)
RedisClient = redis.NewClient(opts)
return RedisClient, nil
}
func (m *RedisPubSubMessage) ReceiveMessage(ctx context.Context) (*pubsub.Message, error) {
msg, err := m.pubsub.ReceiveMessage(ctx)
if err != nil {
return nil, err
}
lib.LogInfo.Printf("\n[PUBSUB/REDIS] Received message: %s\n", msg.Payload)
return &pubsub.Message{Payload: msg.Payload}, nil
}
func (ps *RedisPubSub) SubscribeToChannel(channel string) (pubsub.PubSubMessage, error) {
pubsub := ps.Client.Subscribe(context.Background(), channel)
_, err := pubsub.Receive(context.Background())
if err != nil {
return nil, err
}
lib.LogInfo.Printf("\n[PUBSUB/REDIS] Subscribed to channel %s\n", channel)
return &RedisPubSubMessage{pubsub: pubsub}, nil
}
func (r *RedisPubSub) PublishToChannel(channel string, message string) error {
err := r.Client.Publish(context.Background(), channel, message).Err()
if err != nil {
return err
}
lib.LogInfo.Printf("\n[PUBSUB/REDIS] Publishing message to channel %s: %s\n", channel, message)
return nil
}

16
lib/pubsub/interface.go Normal file
View file

@ -0,0 +1,16 @@
package pubsub
import "context"
type Message struct {
Payload string
}
type PubSubMessage interface {
ReceiveMessage(ctx context.Context) (*Message, error)
}
type PubSub interface {
SubscribeToChannel(channel string) (PubSubMessage, error)
PublishToChannel(channel string, message string) error
}

52
lib/s3.go Normal file
View file

@ -0,0 +1,52 @@
package lib
import (
"fmt"
"os"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
)
func GeneratePublicURL(key string) string {
bucket := os.Getenv("BUCKET_NAME")
if bucket == "" {
fmt.Println("No S3 bucket specified, skipping upload.")
return ""
}
endpoint := os.Getenv("AWS_ENDPOINT_URL_S3")
accessKeyID := os.Getenv("AWS_ACCESS_KEY_ID")
secretAccessKey := os.Getenv("AWS_SECRET_ACCESS_KEY")
region := os.Getenv("AWS_REGION")
sess, err := session.NewSession(&aws.Config{
Region: &region,
Credentials: credentials.NewStaticCredentials(
accessKeyID,
secretAccessKey,
"",
),
Endpoint: aws.String(endpoint),
})
if err != nil {
return ""
}
svc := s3.New(sess)
req, _ := svc.GetObjectRequest(&s3.GetObjectInput{
Bucket: &bucket,
Key: &key,
})
urlStr, err := req.Presign(15 * time.Minute)
if err != nil {
return ""
}
return urlStr
}

86
lib/spotify.go Normal file
View file

@ -0,0 +1,86 @@
package lib
import (
"context"
"os"
"strings"
"sync"
"atri.dad/lib/pubsub"
"github.com/zmb3/spotify"
"golang.org/x/oauth2"
)
var (
spotifyOAuth2Endpoint = oauth2.Endpoint{
TokenURL: "https://accounts.spotify.com/api/token",
AuthURL: "https://accounts.spotify.com/authorize",
}
config *oauth2.Config
once sync.Once
)
func NowPlayingTextFilter(s string) string {
s = strings.Replace(s, "'", "&#39;", -1)
s = strings.Replace(s, "\"", "&quot;", -1)
return s
}
func GetOAuth2Config(clientID string, clientSecret string) *oauth2.Config {
once.Do(func() {
config = &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: []string{spotify.ScopeUserReadCurrentlyPlaying},
Endpoint: spotifyOAuth2Endpoint,
}
})
return config
}
func GetCurrentlyPlayingTrack(clientID string, clientSecret string, refreshToken string) (*spotify.CurrentlyPlaying, error) {
// OAuth2 config
config := GetOAuth2Config(clientID, clientSecret)
// Token source
tokenSource := config.TokenSource(context.Background(), &oauth2.Token{RefreshToken: refreshToken})
// Get new token
newToken, err := tokenSource.Token()
if err != nil {
return nil, err
}
// Create new client
client := spotify.Authenticator{}.NewClient(newToken)
// Get currently playing track
playing, err := client.PlayerCurrentlyPlaying()
if err != nil {
return nil, err
}
return playing, nil
}
func CurrentlyPlayingTrackSSE(ctx context.Context, pubSub pubsub.PubSub) error {
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
refreshToken := os.Getenv("SPOTIFY_REFRESH_TOKEN")
playing, err := GetCurrentlyPlayingTrack(clientID, clientSecret, refreshToken)
if err != nil {
return err
}
if playing.Item != nil && playing.Playing {
songName := NowPlayingTextFilter(playing.Item.Name)
artistName := NowPlayingTextFilter(playing.Item.Artists[0].Name)
return SendSSE(ctx, pubSub, "spotify", `<div class="indicator-item badge badge-success"><a _='on mouseover put "🔥 Listening to `+songName+" by "+artistName+` 🔥" into my.textContent on mouseout put "🔥" into my.textContent' href="`+playing.Item.ExternalURLs["spotify"]+`" rel="noreferrer" target="_blank">🔥</a></div>`)
} else {
SendSSE(ctx, pubSub, "spotify", "")
}
return nil
}

131
lib/sse.go Normal file
View file

@ -0,0 +1,131 @@
package lib
import (
"context"
"fmt"
"log"
"net/http"
"sync"
"atri.dad/lib/pubsub"
"github.com/labstack/echo/v4"
)
type SSEServerType struct {
clients map[string]map[chan string]bool
mu sync.Mutex
}
var SSEServer *SSEServerType
var mutex = &sync.Mutex{}
func init() {
SSEServer = &SSEServerType{
clients: make(map[string]map[chan string]bool),
}
}
func NewSSEServer() *SSEServerType {
return &SSEServerType{
clients: make(map[string]map[chan string]bool),
}
}
func (s *SSEServerType) AddClient(channel string, client chan string) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.clients[channel]; !ok {
s.clients[channel] = make(map[chan string]bool)
}
s.clients[channel][client] = true
}
func (s *SSEServerType) RemoveClient(channel string, client chan string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.clients[channel], client)
if len(s.clients[channel]) == 0 {
delete(s.clients, channel)
}
}
func (s *SSEServerType) ClientCount(channel string) int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.clients[channel])
}
func SendSSE(ctx context.Context, messageBroker pubsub.PubSub, channel string, message string) error {
// Create a channel to receive an error from the goroutine
errCh := make(chan error, 1)
// Use a goroutine to send the message asynchronously
go func() {
select {
case <-ctx.Done():
// The client has disconnected, so return an error
errCh <- ctx.Err()
default:
err := messageBroker.PublishToChannel(channel, message)
errCh <- err // Send the error to the channel
}
}()
// Wait for the goroutine to finish and check for errors
err := <-errCh
if err != nil {
return err
}
return nil
}
func SetSSEHeaders(c echo.Context) {
c.Response().Header().Set(echo.HeaderContentType, "text/event-stream")
c.Response().Header().Set(echo.HeaderConnection, "keep-alive")
c.Response().Header().Set(echo.HeaderCacheControl, "no-cache")
}
func HandleIncomingMessages(c echo.Context, pubsub pubsub.PubSubMessage, client chan string) {
for {
select {
case <-c.Request().Context().Done():
// The client has disconnected. Stop trying to send messages.
return
default:
// The client is still connected. Continue processing messages.
msg, err := pubsub.ReceiveMessage(c.Request().Context())
if err != nil {
log.Printf("Failed to receive message: %v", err)
continue
}
data := fmt.Sprintf("data: %s\n\n", msg.Payload)
mutex.Lock()
_, err = c.Response().Write([]byte(data))
mutex.Unlock()
if err != nil {
log.Printf("Failed to write message: %v", err)
return // Stop processing if an error occurs
}
// Check if the ResponseWriter is nil before trying to flush it
if c.Response().Writer != nil {
// Check if the ResponseWriter implements http.Flusher before calling Flush
flusher, ok := c.Response().Writer.(http.Flusher)
if ok {
flusher.Flush()
} else {
log.Println("Failed to flush: ResponseWriter does not implement http.Flusher")
}
} else {
log.Println("Failed to flush: ResponseWriter is nil")
}
}
}
}

41
lib/stripe.go Normal file
View file

@ -0,0 +1,41 @@
package lib
import (
"log"
"net/http"
"os"
"github.com/joho/godotenv"
"github.com/stripe/stripe-go/v76"
"github.com/stripe/stripe-go/v76/checkout/session"
)
// init function
func init() {
godotenv.Load(".env")
stripe.Key = os.Getenv("STRIPE_SECRET_KEY")
}
func CreateCheckoutSession(w http.ResponseWriter, r *http.Request, successUrl string, cancelUrl string, priceId string) {
params := &stripe.CheckoutSessionParams{
LineItems: []*stripe.CheckoutSessionLineItemParams{
{
// Provide the exact Price ID (for example, pr_1234) of the product you want to sell
Price: stripe.String(priceId),
Quantity: stripe.Int64(1),
},
},
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
SuccessURL: stripe.String(successUrl),
CancelURL: stripe.String(cancelUrl),
AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{Enabled: stripe.Bool(true)},
}
s, err := session.New(params)
if err != nil {
log.Printf("session.New: %v", err)
}
http.Redirect(w, r, s.URL, http.StatusSeeOther)
}

14
lib/stylegen/base.css vendored Normal file
View file

@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html,
container,
body {
height: 100%;
width: 100%;
overflow-y: auto;
position: fixed;
}
}

79
lib/stylegen/gen.sh Executable file
View file

@ -0,0 +1,79 @@
#!/bin/sh
OS=$(uname -s)
ARCH=$(uname -m)
# Normalize OS and ARCH identifiers
case $OS in
"Darwin")
OS="macos"
;;
"Linux")
OS="linux"
;;
"CYGWIN"*|"MINGW"*|"MSYS"*)
OS="windows"
;;
*)
echo "Unknown operating system: $OS"
exit 1
;;
esac
case $ARCH in
"x86_64")
ARCH="x64"
;;
"arm64")
ARCH="arm64"
;;
*)
echo "Unsupported architecture: $ARCH"
exit 1
;;
esac
# Construct the binary file name
BINARY="./tw/${OS}-${ARCH}"
if [ "$OS" = "windows" ]; then
BINARY="${BINARY}.exe"
else
# Set execute permissions on the binary
chmod +x $BINARY
fi
echo $BINARY
# Infer pages from .html files in the pages directory
PAGES=$(ls ../../pages/templates/*.html | xargs -n 1 basename | sed 's/\.[^.]*$//')
# Run the binary for each page
for PAGE in $PAGES; do
(
# Detect which partials are being used in this page
PARTIALS=$(grep -o -E '{{template "[^"]+' ../../pages/templates/${PAGE}.html | cut -d'"' -f2 | xargs -I{} echo \"../../pages/templates/partials/{}.html\")
# Generate an array of partials and join them with commas
PARTIALS_ARRAY=$(echo $PARTIALS | tr ' ' ',')
# Always include the "header" partial and any other partials that are always used
PARTIALS_ARRAY=\"../../pages/templates/partials/header.html\",\"../../pages/templates/partials/global.html\",$PARTIALS_ARRAY
# Generate Tailwind config for this page
echo "module.exports = {
content: [\"../../pages/templates/${PAGE}.html\", \"../../pages/templates/layouts/*.html\", $PARTIALS_ARRAY],
theme: {
extend: {},
},
daisyui: {
themes: [\"night\"],
},
plugins: [require('daisyui'), require('@tailwindcss/typography')],
}" > tailwind.config.${PAGE}.js
# Run the binary with the generated config
$BINARY build -i ./base.css -c tailwind.config.${PAGE}.js -o ../../public/css/styles.${PAGE}.css --minify
) &
done
# Wait for all background processes to finish
wait

BIN
lib/stylegen/tw/linux-arm64 Executable file

Binary file not shown.

BIN
lib/stylegen/tw/linux-x64 Executable file

Binary file not shown.

BIN
lib/stylegen/tw/macos-arm64 Executable file

Binary file not shown.

BIN
lib/stylegen/tw/macos-x64 Executable file

Binary file not shown.

BIN
lib/stylegen/tw/windows-arm64.exe Executable file

Binary file not shown.

BIN
lib/stylegen/tw/windows-x64.exe Executable file

Binary file not shown.

43
lib/templates.go Normal file
View file

@ -0,0 +1,43 @@
package lib
import (
"html/template"
"log"
"net/http"
"path/filepath"
"runtime"
templatefs "atri.dad/pages/templates"
)
func RenderTemplate(w http.ResponseWriter, layout string, partials []string, props interface{}) error {
// Get the name of the current file
_, filename, _, _ := runtime.Caller(1)
page := filepath.Base(filename)
page = page[:len(page)-len(filepath.Ext(page))] // remove the file extension
// Build the list of templates
templates := []string{
"layouts/" + layout + ".html",
page + ".html",
}
for _, partial := range partials {
templates = append(templates, "partials/"+partial+".html")
}
// Parse the templates
ts, err := template.ParseFS(templatefs.FS, templates...)
if err != nil {
log.Print(err.Error())
return err
}
// Execute the layout template
err = ts.ExecuteTemplate(w, layout, props)
if err != nil {
log.Print(err.Error())
return err
}
return nil
}

BIN
main Executable file

Binary file not shown.

129
main.go Executable file
View file

@ -0,0 +1,129 @@
package main
import (
"context"
"embed"
"flag"
"fmt"
"log"
"net/http"
"time"
"atri.dad/api"
"atri.dad/api/webhooks"
"atri.dad/lib"
"atri.dad/lib/pubsub"
"atri.dad/lib/pubsub/adapters"
"atri.dad/pages"
"github.com/joho/godotenv"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
//go:embed public/*
var PublicFS embed.FS
func main() {
// Load environment variables
godotenv.Load(".env")
// Initialize Redis client
redisClient, redisError := adapters.NewRedisClient()
// Initialize pubsub
var pubSub pubsub.PubSub
if redisError != nil {
lib.LogWarning.Printf("\n[PUBSUB/INIT] Failed to connect to Redis: %v\n", redisError)
lib.LogWarning.Printf("\n[PUBSUB/INIT] Falling back to LocalPubSub\n")
pubSub = &adapters.LocalPubSub{}
} else {
adapters.RedisClient = redisClient
pubSub = &adapters.RedisPubSub{
Client: adapters.RedisClient,
}
}
// Initialize Echo router
e := echo.New()
// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Pre(middleware.RemoveTrailingSlash())
e.Use(middleware.RequestID())
e.Use(middleware.Secure())
e.Use(middleware.GzipWithConfig(middleware.GzipConfig{
Level: 5,
}))
e.Use(middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(50)))
// Static server
fs := http.FS(PublicFS)
e.GET("/public/*", echo.WrapHandler(http.FileServer(fs)))
// Page routes
e.GET("/", pages.Home)
e.GET("/projects", pages.Projects)
e.GET("/talks", pages.Talks)
e.GET("/testimonials", pages.Testimonials)
e.GET("/blog", pages.Blog)
e.GET("/post/:post", pages.Post)
e.GET("/tools", pages.Tools)
e.GET("/tools/resize", pages.Resize)
e.GET("/tools/ssedemo", pages.SSEDemo)
// API Routes:
apiGroup := e.Group("/api")
apiGroup.GET("/ping", api.Ping)
apiGroup.GET("/authed/ping", api.Authed)
apiGroup.POST("/pay", api.Pay)
apiGroup.GET("/rss", api.RSSFeedHandler)
apiGroup.GET("/post/copy", api.PostCopy)
apiGroup.GET("/sse", func(c echo.Context) error {
return api.SSE(c, pubSub)
})
apiGroup.POST("/tools/sendsse", func(c echo.Context) error {
return api.SSEDemoSend(c, pubSub)
})
apiGroup.GET("/spotify/nowplaying", api.NowPlayingHandler)
apiGroup.POST("/tools/resize", api.ResizeHandler)
// Webhook Routes:
webhookGroup := e.Group("/webhook")
webhookGroup.POST("/clerk", webhooks.ClerkWebhookHandler)
// Spotify Polling
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
// Check if there are any clients connected to the "spotify" channel
if lib.SSEServer.ClientCount("spotify") > 0 {
// Get the currently playing track
err := lib.CurrentlyPlayingTrackSSE(context.Background(), pubSub)
if err != nil {
// Handle error
continue
}
}
}
}()
// Parse command-line arguments for IP and port
ip := flag.String("ip", "", "IP address to bind the server to")
port := flag.String("port", "3000", "Port to bind the server to")
flag.Parse()
// Start server with HTTP/2 support
s := &http.Server{
Addr: fmt.Sprintf("%s:%s", *ip, *port),
Handler: e,
}
e.Logger.Fatal(e.StartServer(s))
log.Println("Server started on port", *port)
}

13
main.ts
View file

@ -1,13 +0,0 @@
/// <reference no-default-lib="true" />
/// <reference lib="dom" />
/// <reference lib="dom.iterable" />
/// <reference lib="dom.asynciterable" />
/// <reference lib="deno.ns" />
import "$std/dotenv/load.ts";
import { start } from "$fresh/server.ts";
import manifest from "./fresh.gen.ts";
import config from "./fresh.config.ts";
await start(manifest, config);

71
pages/blog.go Normal file
View file

@ -0,0 +1,71 @@
package pages
import (
"io/fs"
"log"
"net/http"
"sort"
"strings"
"time"
contentfs "atri.dad/content"
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
type BlogProps struct {
Posts []lib.CardLink
}
func Blog(c echo.Context) error {
var posts []lib.CardLink
files, err := fs.ReadDir(contentfs.FS, ".")
if err != nil {
log.Println(err)
http.Error(c.Response().Writer, "There was an issue finding posts!", http.StatusInternalServerError)
return nil
}
for _, file := range files {
if !file.IsDir() && strings.HasSuffix(file.Name(), ".md") {
frontMatter, err := lib.ExtractFrontMatter(file, contentfs.FS)
if err != nil {
log.Println(err)
http.Error(c.Response().Writer, "There was an issue rendering the posts!", http.StatusInternalServerError)
return nil
}
frontMatter.Href = "post/" + strings.TrimSuffix(file.Name(), ".md")
frontMatter.Internal = true
posts = append(posts, frontMatter)
}
}
const layout = "January 2 2006"
sort.Slice(posts, func(i, j int) bool {
iDate, err := time.Parse(layout, posts[i].Date)
if err != nil {
log.Fatal(err)
}
jDate, err := time.Parse(layout, posts[j].Date)
if err != nil {
log.Fatal(err)
}
return iDate.Before(jDate)
})
props := BlogProps{
Posts: posts,
}
// Specify the partials used by this page
partials := []string{"header", "navitems", "cardlinks"}
// Render the template
return lib.RenderTemplate(c.Response().Writer, "base", partials, props)
}

22
pages/example.go Normal file
View file

@ -0,0 +1,22 @@
package pages
import (
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
type ExampleProps struct {
ExamplePropText string
}
func Example(c echo.Context) error {
props := ExampleProps{
ExamplePropText: "EXAMPLE TEXT HERE",
}
// Specify the partials used by this page
partials := []string{"header", "navitems"}
// Render the template
return lib.RenderTemplate(c.Response().Writer, "base", partials, props)
}

172
pages/home.go Normal file

File diff suppressed because one or more lines are too long

78
pages/post.go Normal file
View file

@ -0,0 +1,78 @@
package pages
import (
"bytes"
"html/template"
"io/fs"
"net/http"
contentfs "atri.dad/content"
"atri.dad/lib"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/labstack/echo/v4"
"github.com/yuin/goldmark"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"gopkg.in/yaml.v2"
)
type PostProps struct {
Content template.HTML
Name string
Date string
Tags []string
}
func Post(c echo.Context) error {
postName := c.Param("post")
filePath := postName + ".md"
md, err := fs.ReadFile(contentfs.FS, filePath)
if err != nil {
println(err.Error())
http.Error(c.Response().Writer, "This post does not exist!", http.StatusNotFound)
return nil
}
frontmatterBytes, content, err := lib.SplitFrontmatter(md)
if err != nil {
http.Error(c.Response().Writer, "There was an issue rendering this post!", http.StatusInternalServerError)
return nil
}
var frontmatter lib.FrontMatter
if err := yaml.Unmarshal(frontmatterBytes, &frontmatter); err != nil {
http.Error(c.Response().Writer, "There was an issue rendering this post!", http.StatusInternalServerError)
return nil
}
var buf bytes.Buffer
markdown := goldmark.New(
goldmark.WithExtensions(
highlighting.NewHighlighting(
highlighting.WithStyle("fruity"),
highlighting.WithFormatOptions(
chromahtml.WithLineNumbers(true),
),
),
),
)
if err := markdown.Convert(content, &buf); err != nil {
http.Error(c.Response().Writer, "There was an issue rendering this post!", http.StatusInternalServerError)
return nil
}
props := PostProps{
Content: template.HTML(buf.String()),
Name: frontmatter.Name,
Date: frontmatter.Date,
Tags: frontmatter.Tags,
}
// Specify the partials used by this page
partials := []string{"header", "navitems"}
// Render the template
return lib.RenderTemplate(c.Response().Writer, "post", partials, props)
}

61
pages/projects.go Normal file
View file

@ -0,0 +1,61 @@
package pages
import (
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
type ProjectProps struct {
Projects []lib.CardLink
}
func Projects(c echo.Context) error {
projects := []lib.CardLink{
{
Name: "Pollo",
Description: "A dead-simple real-time voting tool.",
Tags: []string{"react", "remix.js", "product"},
Href: "https://pollo.atri.dad",
},
{
Name: "Atash",
Description: "The 🔥hottest🔥 full-stack Remix template!",
Tags: []string{"react", "remix.js", "template"},
Href: "https://github.com/atridadl/Atash",
},
{
Name: "GOTH Stack",
Description: "🚀 A Web Application Template Powered by HTMX + Go + Tailwind 🚀",
Tags: []string{"golang", "htmx", "template"},
Href: "https://github.com/atridadl/goth.stack",
},
{
Name: "Himbot",
Description: "A discord bot written in Go. Loosly named after my username online (HimbothySwaggins).",
Tags: []string{"golang", "bot"},
Href: "https://github.com/atridadl/HimBot",
},
{
Name: "Commodore",
Description: "Helpful Nightbot Helpers for Twitch",
Tags: []string{"react", "remix.js", "template"},
Href: "https://commodore.atri.dad",
},
{
Name: "loadr",
Description: "A lightweight REST load testing tool with robust support for different verbs, token auth, and performance reports.",
Tags: []string{"golang", "cli"},
Href: "https://github.com/atridadl/loadr",
},
}
props := ProjectProps{
Projects: projects,
}
// Specify the partials used by this page
partials := []string{"header", "navitems", "cardlinks"}
// Render the template
return lib.RenderTemplate(c.Response().Writer, "base", partials, props)
}

39
pages/talks.go Normal file
View file

@ -0,0 +1,39 @@
package pages
import (
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
type TalkProps struct {
Talks []lib.CardLink
}
func Talks(c echo.Context) error {
talks := []lib.CardLink{
{
Name: "How to ship less JavaScript",
Description: "A talk on building websites while being mindful of the JavaScript we ship. Presented at the Dev Edmonton July 2023 JS/Ruby/Python Meetup",
Href: "https://github.com/atridadl/devedmonton-july-2023",
Tags: []string{"astro", "ssr"},
Date: "July 06, 2023",
},
{
Name: "Hypermedia as the engine of application state - an Introduction",
Description: "A talk on building reactive websites using tools like HTMX instead of JSON + JS. Will be presented at the Dev Edmonton Fabruary 2024 JS/Ruby/Python Meetup",
Href: lib.GeneratePublicURL("hypermedia_talk_atridad.pdf"),
Tags: []string{"golang", "htmx", "ssr"},
Date: "February 01, 2024",
},
}
props := TalkProps{
Talks: talks,
}
// Specify the partials used by this page
partials := []string{"header", "navitems", "cardlinks"}
// Render the template
return lib.RenderTemplate(c.Response().Writer, "base", partials, props)
}

22
pages/templates/blog.html Normal file
View file

@ -0,0 +1,22 @@
{{define "title"}}
Atridad Lahiji // Blog
{{end}}
{{define "headercontent"}}
Atridad Lahiji // Blog
{{end}}
{{define "head"}}
<link rel="stylesheet" href="/public/css/styles.blog.css" />
{{end}}
{{define "main"}}
<section class="flex flex-row flex-wrap gap-2 justify-center align-middle">
{{range .Posts}}
{{template "cardlinks" .}}
{{end}}
</section>
{{end}}
{{define "foot"}}
{{end}}

View file

@ -0,0 +1,26 @@
{{define "title"}}
Atridad Lahiji // [SOMEPAGE]
{{end}}
{{define "headercontent"}}
Atridad Lahiji // [SOMEPAGE]
{{end}}
{{define "head"}}
<link rel="stylesheet" href="/public/css/styles.[SOMEPAGE].css" />
{{end}}
{{define "main"}}
<h1 class="text-4xl font-extrabold text-white sm:text-8xl">
New <span
class="bg-gradient-to-r from-pink-500 to-blue-500 bg-clip-text text-transparent"
>Page</span
>
</h1>
{{end}}
{{define "foot"}}
<script src="/public/js/htmx.base.js"></script>
<script src="/public/js/htmx.sse.js"></script>
<script src="/public/js/hyperscript.js"></script>
{{end}}

53
pages/templates/home.html Normal file
View file

@ -0,0 +1,53 @@
{{define "title"}}
Atridad Lahiji // Root
{{end}}
{{define "headercontent"}}
Atridad Lahiji // Root
{{end}}
{{define "head"}}
<link rel="stylesheet" href="/public/css/styles.home.css" />
{{end}}
{{define "main"}}
<h1 class="text-4xl font-extrabold text-white sm:text-8xl">
Hi, I&apos;m <span
class="text-transparent bg-clip-text bg-gradient-to-r from-pink-500 via-purple-500 to-blue-500">Atridad</span>
</h1>
<h2 class="text-2xl font-extrabold tracking-tight text-white sm:text-[2rem]">
I&apos;m a full stack developer who builds cool things for the web.
</h2>
<span>
<h2 class="mb-2 text-xl text-white sm:text-[1.5rem]">Places I exist:</h2>
<div class="flex flex-row flex-wrap items-center justify-center gap-4 text-center">
{{range .Socials}}
{{template "iconlinks" .}}
{{end}}
</div>
</span>
<span>
<h2 class="mb-2 text-xl text-white sm:text-[1.5rem]">Stuff I Use:</h2>
<div class="flex flex-row flex-wrap items-center justify-center gap-4 text-center">
{{range .Tech}}
{{template "iconlinks" .}}
{{end}}
</div>
</span>
<div class="flex flex-row flex-wrap gap-2 mx-auto justify-center">
{{range .ButtonsLinks}}
{{template "buttonlinks" .}}
{{end}}
</div>
{{end}}
{{define "foot"}}
<script src="/public/js/htmx.base.js"></script>
<script src="/public/js/htmx.sse.js"></script>
<script src="/public/js/hyperscript.js"></script>
{{end}}

View file

@ -0,0 +1,22 @@
{{define "base"}}
<!DOCTYPE html>
<html lang="en" data-theme="night">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/public/favicon.ico" />
<title>{{template "title" .}}</title>
<meta name="description" content="Just here for the vibes...">
{{template "head" .}}
</head>
<body class="block h-[100%]">
{{template "header" .}}
<main class="container flex flex-col items-center justify-center gap-3 sm:gap-6 p-4 text-center mx-auto min-h-[calc(100%-64px)]">
{{template "main" .}}
</main>
{{template "foot" .}}
</body>
</html>
{{end}}

View file

@ -0,0 +1,58 @@
{{define "post"}}
<!DOCTYPE html>
<html lang="en" data-theme="night">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/public/favicon.ico" />
<title>{{.Name}}</title>
<meta name="description" content="Just here for the vibes...">
{{template "head" .}}
</head>
<body class="block h-[100%]">
{{template "header" .}}
<main class="prose prose-invert mx-auto p-4">
<article>
<h1 class="title">{{.Name}}</h1>
<div class="flex flex-row flex-wrap gap-4">
{{if .Date}}
<p>
<div class="flex flex-row flex-wrap items-center gap-1 text-md">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock-4"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{{.Date}}
</div>
</p>
{{end}}
{{if .Tags}}
<div class="flex flex-row flex-wrap text-center items-center justify-center gap-1">
{{range .Tags}}
<div class="badge badge-accent">#{{.}}</div>
{{end}}
</div>
{{end}}
<div id="svgContainer" style="display: none;">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-circle"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><path d="m9 11 3 3L22 4"/></svg>
</div>
<button id="copyButton" aria-label="Copy Link to Post" hx-get="/api/post/copy" hx-swap="innerHTML" hx-trigger="click delay:3s" _='on click put #svgContainer.innerHTML into me.innerHTML then call navigator.clipboard.writeText(window.location.href)'>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
</button>
<a href="/blog" class="btn btn-primary btn-outline">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-undo-2"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>
Back
</a>
</div>
<hr />
{{template "main" .}}
</article>
</main>
{{template "foot" .}}
</body>
</html>
{{end}}

View file

@ -0,0 +1,15 @@
{{define "buttonlinks"}}
{{if eq true .Internal}}
<a class="btn btn-primary btn-outline btn-md lg:btn-lg" href={{.Href}}>
{{.Name}}
</a>
{{else}}
<a class="btn btn-primary btn-outline btn-md lg:btn-lg" href={{.Href}} target="_blank" rel="noreferrer">
{{.Name}}
</a>
{{end}}
{{end}}

View file

@ -0,0 +1,54 @@
{{define "cardlinks"}}
<div class="card card-compact w-64 bg-secondary shadow-xl">
<div class="card-body text-base-100 flex flex-col">
<h2 class="card-title text-base-100">{{.Name}}</h2>
{{if .Description}}
<p>{{.Description}}</p>
{{end}}
{{if .Date}}
<p>
<div class="flex flex-row flex-wrap items-center gap-1 text-md">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-clock-4"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{{.Date}}
</div>
</p>
{{end}}
{{if .Tags}}
<div class="flex flex-row flex-wrap text-center items-center justify-center gap-1">
{{range .Tags}}
<div class="badge badge-accent">#{{.}}</div>
{{end}}
</div>
{{end}}
{{if .Href}}
<div class="card-actions justify-end">
{{if eq true .Internal}}
<a
role="button"
href={{.Href}}
aria-label={{.Name}}
class="btn btn-circle btn-base-100 text-primary hover:btn-accent hover:text-neutral"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
</a>
{{else}}
<a
role="button"
href={{.Href}}
aria-label={{.Name}}
class="btn btn-circle btn-base-100 text-primary hover:btn-accent hover:text-neutral"
target="_blank"
rel="noopener noreferrer"
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
</a>
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}

View file

@ -0,0 +1,4 @@
{{define "header"}}
<div class="fill-green-500 tooltip tooltip-top badge badge-success"></div>
{{end}}

View file

@ -0,0 +1,20 @@
{{define "header"}}
<header class="navbar bg-base-100">
<div class="navbar-start">
<a class="btn btn-ghost normal-case text-lg sm:text-xl text-white" href="/">{{template "headercontent".}}</a>
</div>
<div class="navbar-end z-50">
<div class="dropdown dropdown-end">
<label tabindex="0" class="btn btn-sm btn-ghost text-white">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>
</label>
<ul
tabindex="0"
class="menu menu-compact dropdown-content gap-2 mt-3 p-2 shadow bg-base-100 rounded-box"
>
{{template "navitems" .}}
</ul>
</div>
</div>
</header>
{{end}}

View file

@ -0,0 +1,18 @@
{{define "iconlinks"}}
{{if eq .Name "Spotify"}}
<div class="indicator indicator-top indicator-end">
<div hx-ext="sse" sse-connect="/api/sse?channel=spotify" sse-swap="message">
<span class="link link-hover link-success" hx-get="/api/spotify/nowplaying" hx-trigger="load" hx-swap="self">
</span>
</div>
<a class="fill-white hover:fill-pink-500" href={{.Href}} target="_blank" rel="me" aria-label={{.Name}}>
{{.Icon}}
</a>
</div>
{{else}}
<a class="fill-white hover:fill-pink-500" href={{.Href}} target="_blank" rel="me" aria-label={{.Name}}>
{{.Icon}}
</a>
{{end}}
{{end}}

View file

@ -0,0 +1,32 @@
{{define "navitems"}}
<li>
<a class="no-underline" href="/">
Home
</a>
</li>
<li>
<a class="no-underline" href="/projects">
Projects
</a>
</li>
<li>
<a class="no-underline" href="/talks">
Talks
</a>
</li>
<li>
<a class="no-underline" href="/testimonials">
Testimonials
</a>
</li>
<li>
<a class="no-underline" href="/tools">
Tools
</a>
</li>
<li>
<a class="no-underline" href="/blog">
Blog
</a>
</li>
{{end}}

20
pages/templates/post.html Normal file
View file

@ -0,0 +1,20 @@
{{define "title"}}
Atridad Lahiji // Post
{{end}}
{{define "headercontent"}}
Atridad Lahiji // Post
{{end}}
{{define "head"}}
<link rel="stylesheet" href="/public/css/styles.post.css" />
{{end}}
{{define "main"}}
{{.Content}}
{{end}}
{{define "foot"}}
<script src="/public/js/htmx.base.js"></script>
<script src="/public/js/hyperscript.js"></script>
{{end}}

View file

@ -0,0 +1,22 @@
{{define "title"}}
Atridad Lahiji // Projects
{{end}}
{{define "headercontent"}}
Atridad Lahiji // Projects
{{end}}
{{define "head"}}
<link rel="stylesheet" href="/public/css/styles.projects.css" />
{{end}}
{{define "main"}}
<section class="flex flex-row flex-wrap gap-2 justify-center align-middle">
{{range .Projects}}
{{template "cardlinks" .}}
{{end}}
</section>
{{end}}
{{define "foot"}}
{{end}}

View file

@ -0,0 +1,22 @@
{{define "title"}}
Atridad Lahiji // Talks
{{end}}
{{define "headercontent"}}
Atridad Lahiji // Talks
{{end}}
{{define "head"}}
<link rel="stylesheet" href="/public/css/styles.talks.css" />
{{end}}
{{define "main"}}
<section class="flex flex-row flex-wrap gap-2 justify-center align-middle">
{{range .Talks}}
{{template "cardlinks" .}}
{{end}}
</section>
{{end}}
{{define "foot"}}
{{end}}

View file

@ -0,0 +1,6 @@
package templatefs
import "embed"
//go:embed *
var FS embed.FS

View file

@ -0,0 +1,32 @@
{{define "title"}}
Atridad Lahiji // Testimonials
{{end}}
{{define "headercontent"}}
Atridad Lahiji // Testimonials
{{end}}
{{define "head"}}
<link rel="stylesheet" href="/public/css/styles.testimonials.css" />
{{end}}
{{define "main"}}
<h2 class="text-2xl font-extrabold tracking-tight text-white sm:text-[2rem]">
What <a class="link link-secondary"
href="https://steamcommunity.com/app/1230140/reviews/?browsefilter=toprated&snr=1_5_100010_" target="_blank"
rel="noreferrer">People</a> Say About Me
</h2>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{{range .Images}}
<div>
<img class="object-cover object-center w-full max-w-full rounded-lg" src={{.}} alt="Review of Atri"
loading="lazy" />
</div>
{{end}}
</div>
{{end}}
{{define "foot"}}
{{end}}

View file

@ -0,0 +1,22 @@
{{define "title"}}
Atridad Lahiji // Tools
{{end}}
{{define "headercontent"}}
Atridad Lahiji // Tools
{{end}}
{{define "head"}}
<link rel="stylesheet" href="/public/css/styles.tools.css" />
{{end}}
{{define "main"}}
<section class="flex flex-row flex-wrap gap-2 justify-center align-middle">
{{range .Tools}}
{{template "cardlinks" .}}
{{end}}
</section>
{{end}}
{{define "foot"}}
{{end}}

View file

@ -0,0 +1,31 @@
{{define "title"}}
Atridad Lahiji // Tools // Resizer
{{end}}
{{define "headercontent"}}
Atridad Lahiji // Tools // Resizer
{{end}}
{{define "head"}}
<link rel="stylesheet" href="/public/css/styles.tools.resize.css" />
{{end}}
{{define "main"}}
<h2 class="text-2xl font-extrabold tracking-tight text-white sm:text-[2rem]">Image Resizer</h2>
<form action="/api/tools/resize" method="post" enctype="multipart/form-data" class="flex-col flex gap-4">
Select image to resize:
<input type="file" name="image" accept=".png,.jpg,.jpeg"
class="file-input file-input-bordered file-input-secondary w-full max-w-xs" required />
<br>
New width (px):
<input type="number" id="newWidth" name="width" min="1" class="input input-bordered w-full max-w-xs" required>
<br>
New height (px):
<input type="number" id="newHeight" name="height" min="1" class="input input-bordered w-full max-w-xs" required>
<br>
<button type="submit" class="btn btn-secondary">Resize Image</button>
</form>
{{end}}
{{define "foot"}}
{{end}}

View file

@ -0,0 +1,36 @@
{{define "title"}}Atridad Lahiji // Tools // SSE Demo{{end}}
{{define "headercontent"}}
Atridad Lahiji // Tools // SSE Demo
{{end}}
{{define "head"}}
<link rel="stylesheet" href="/public/css/styles.tools.ssedemo.css" />
{{end}}
{{define "main"}}
<h2 class="text-2xl font-extrabold tracking-tight text-white sm:text-[2rem]">Server Sent Events Demo</h2>
<p class="text-lg">This page demonstrates the use of the <a href="https://htmx.org/extensions/sse/">HTMX SSE
Extention</a> to receive Server Sent Events on the "default" channel.</p>
<p class="text-lg">Any events received on the "default" channel will appear below:</p>
<div hx-ext="sse" sse-connect="/api/sse" sse-swap="message">
Waiting for SSE Message...
</div>
<p class="text-lg">Here you can send messages on the default channel:</p>
<form hx-post="/api/tools/sendsse" hx-trigger="submit" hx-swap="none" class="flex-col flex gap-2">
<div class="label">
<span class="label-text">Message</span>
</div>
<input type="text" name="message" value="Hello world!" placeholder="Enter your message here"
class="input input-bordered input-primary w-full max-w-xs" />
<button type="submit" class="btn btn-primary">Send Event</button>
</form>
{{end}}
{{define "foot"}}
<script src="/public/js/htmx.base.js"></script>
<script src="/public/js/htmx.sse.js"></script>
{{end}}

34
pages/testimonials.go Normal file
View file

@ -0,0 +1,34 @@
package pages
import (
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
type TestimonialsProps struct {
Images []string
}
func Testimonials(c echo.Context) error {
images := []string{
"/public/img/testimonials/1.png",
"/public/img/testimonials/2.png",
"/public/img/testimonials/3.png",
"/public/img/testimonials/4.png",
"/public/img/testimonials/5.png",
"/public/img/testimonials/6.png",
"/public/img/testimonials/7.png",
"/public/img/testimonials/8.png",
"/public/img/testimonials/9.png",
}
props := TestimonialsProps{
Images: images,
}
// Specify the partials used by this page
partials := []string{"header", "navitems"}
// Render the template
return lib.RenderTemplate(c.Response().Writer, "base", partials, props)
}

37
pages/tools.go Normal file
View file

@ -0,0 +1,37 @@
package pages
import (
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
type ToolsProps struct {
Tools []lib.CardLink
}
func Tools(c echo.Context) error {
tools := []lib.CardLink{
{
Name: "Server Sent Events Demo",
Description: "Server Sent Events Demo",
Href: "/tools/ssedemo",
Internal: true,
},
{
Name: "Image Resizer",
Description: "Image Resizer Tool",
Href: "/tools/resize",
Internal: true,
},
}
props := ToolsProps{
Tools: tools,
}
// Specify the partials used by this page
partials := []string{"header", "navitems", "cardlinks"}
// Render the template
return lib.RenderTemplate(c.Response().Writer, "base", partials, props)
}

39
pages/tools.resize.go Normal file
View file

@ -0,0 +1,39 @@
package pages
import (
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
type ResizeProps struct {
Talks []lib.CardLink
}
func Resize(c echo.Context) error {
talks := []lib.CardLink{
{
Name: "How to ship less JavaScript",
Description: "A talk on building websites while being mindful of the JavaScript we ship. Presented at the Dev Edmonton July 2023 JS/Ruby/Python Meetup",
Href: "https://github.com/atridadl/devedmonton-july-2023",
Tags: []string{"astro", "ssr"},
Date: "July 06, 2023",
},
{
Name: "Hypermedia as the engine of application state - an Introduction",
Description: "A talk on building reactive websites using tools like HTMX instead of JSON + JS. Will be presented at the Dev Edmonton Fabruary 2024 JS/Ruby/Python Meetup",
Href: lib.GeneratePublicURL("hypermedia_talk_atridad.pdf"),
Tags: []string{"golang", "htmx", "ssr"},
Date: "February 01, 2024",
},
}
props := TalkProps{
Talks: talks,
}
// Specify the partials used by this page
partials := []string{"header", "navitems", "cardlinks"}
// Render the template
return lib.RenderTemplate(c.Response().Writer, "base", partials, props)
}

14
pages/tools.ssedemo.go Normal file
View file

@ -0,0 +1,14 @@
package pages
import (
"atri.dad/lib"
"github.com/labstack/echo/v4"
)
func SSEDemo(c echo.Context) error {
// Specify the partials used by this page
partials := []string{"header", "navitems"}
// Render the template
return lib.RenderTemplate(c.Response().Writer, "base", partials, nil)
}

View file

@ -1,27 +0,0 @@
---
title: Current List of Favourite Tools
published_at: 2025-01-28T15:00:00.000Z
---
I change what I use _constantly_ in order to find something that feels just
right. I wanted to share them here and update them here so when someone asks, I
can just point them to this article.
1. Sublime Text - Currently my favourite text editor. Fast, simple, and
extensible. Just a joy to use!
2. Sublime Merge - Honestly one of the fastest and best looking git GUIs around!
Awesome for visualizing changes when you have larger code changes.
3. Ghostty - A Zig based terminal emulator by one of the founders of Hashicorp.
Runs great on MacOS and Linux. No windows for those who are into that.
4. OrbStack - A faster alternative to Docker Desktop that also runs VMs!
5. Bitwarden - An open-source password manager. Easy to self host with
Vaultwarden and with the recent updates, it has SSH Agent support!
6. iA Writer - A minimalist Markdown editor. For MacOS and Windows only, but
really the MacOS version is the most mature. Awesome for focus.
7. Dataflare - A simple but powerful cross-platform database client. Supports
most common databases, including LibSQL which is rare!
8. Bruno - A simple and powerful API client, similar to Postman. An critical
tool to debug API endpoints.
I hope you found this helpful! This will be periodically updated to avoid
outdated recommendations.

View file

@ -1,58 +0,0 @@
---
title: Re-write it in Deno!
published_at: 2025-04-25T15:00:00.000Z
---
So... all new site! I use this site as something of an experiment for whatever
technology interests me. One recently got my attention:
_Deno!_
Yes... Deno the friendly re-imagining of Node.js by Ryan Dahl, the the creator
of Node.js! Now given my recent dive into Golang and HTMX, this might seem odd.
Let me explain:
## Built in TSX and Typescript support!
Deno has built in Typescript support, which has been huge for me. Lets table the
whole "Bun and new Node can do that too" discussion for now. Its simply magical
to have a Typescript codebase without a single tsconfig in sight!
## Security!
Look, the secrity model of Deno is incredible. The idea that the runtime will
default-deny permissions unless you as the developer enables them is an awesome
move.
## Imports
Being able to seamlessly pull from JSR and NPM is amazing! Having those two
ecosystems work without the frustrating package.json dance is refreshing. I
mean, just being able to import directly from URLs? Game changer!
## Built-in tooling
The tooling that ships with Deno is first-class! Formatter, linter, test runner,
doc generator... all built right in! No more spending half a day configuring
eslint, prettier, and jest just to get started on a project. Just use
`deno fmt`, `deno lint`, `deno test` and you're good to go.
## Web standards first
Fetch, web streams, event listeners and so on all work just like they do in the
browser. Coming from Node where you have to remember different APIs between
back-end and front-end JS, this is absolutely a breath of fresh air.
## What about Golang? GOTH Stack?
I still love Go as a language and will continue to use it for certain things.
Its incredibly fast and the concurrency model is awesome, but sometimes you just
want to put together a quick web application and JS frameworks are just much
easier to reach for.
## Whats next?
I'm rebuilding this entire site with Deno Fresh, which is their web framework.
It has their island architecture which means minimal JS sent to the client. Will
I stick with it? Who knows! All I know is this has definitely been a blast of a
re-write.

View file

@ -1,10 +0,0 @@
---
title: Welcome!
published_at: 2024-10-20T15:00:00.000Z
---
Welcome to my site! This is a place for me to share my thoughts and updates on
my projects. I hope you find something interesting here.
Feel free to reach out if you have any questions or comments. I'd love to hear
from you! I can be reached by email at [me@atri.dad](mailto:me@atri.dad).

1
public/css/styles.blog.css vendored Normal file

File diff suppressed because one or more lines are too long

1
public/css/styles.example.css vendored Normal file

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show more