Init
This commit is contained in:
parent
84591f3a2d
commit
3d719132f1
96 changed files with 3422 additions and 4793 deletions
46
.air.toml
Normal file
46
.air.toml
Normal 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 stylegen && ./gen.sh"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "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
8
.dockerignore
Normal file
|
@ -0,0 +1,8 @@
|
|||
**/atri.dad
|
||||
**/.env
|
||||
**/airbin
|
||||
**/tmp
|
||||
**/*.rdb
|
||||
stylegen
|
||||
fly.toml
|
||||
tailwind.config.*.js
|
14
.env.example
Normal file
14
.env.example
Normal file
|
@ -0,0 +1,14 @@
|
|||
CLERK_SECRET_KEY=""
|
||||
CLERK_WEBHOOK_SECRET=""
|
||||
RESEND_API_KEY=""
|
||||
STRIPE_SECRET_KEY=""
|
||||
REDIS_HOST=""
|
||||
REDIS_PASSWORD=""
|
||||
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
2
.gitattributes
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
*.css linguist-vendored
|
||||
*.js linguist-vendored
|
18
.github/workflows/fly.yml
vendored
Normal file
18
.github/workflows/fly.yml
vendored
Normal file
|
@ -0,0 +1,18 @@
|
|||
name: Fly Deploy
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'README.md'
|
||||
- 'stylegen/**'
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
- run: flyctl deploy --remote-only
|
||||
env:
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
24
.gitignore
vendored
24
.gitignore
vendored
|
@ -1,21 +1,7 @@
|
|||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
atri.dad
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
airbin
|
||||
tmp/
|
||||
*.rdb
|
||||
.DS_Store
|
||||
tailwind.config.*.js
|
8
.vscode/extensions.json
vendored
Normal file
8
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"recommendations": [
|
||||
"golang.go",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"redhat.vscode-yaml",
|
||||
"a-h.templ"
|
||||
]
|
||||
}
|
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
@ -0,0 +1,14 @@
|
|||
FROM golang:1.22.0 as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN go mod download
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /go/bin/app
|
||||
|
||||
FROM gcr.io/distroless/base-debian12
|
||||
|
||||
COPY --from=build /go/bin/app /
|
||||
|
||||
CMD [ "/app" ]
|
23
README.md
23
README.md
|
@ -1 +1,22 @@
|
|||
# Personal site using Astro SSR + React + Railway
|
||||
# atri.dad
|
||||
This is my personal website!
|
||||
|
||||
## Stack:
|
||||
- Backend: Golang + Echo
|
||||
- Rendering: Golang templates
|
||||
- Style: TailwindCSS + DaisyUI (No JS Needed)
|
||||
- Content format: Markdown
|
||||
|
||||
## Requirements:
|
||||
- Golang 1.22.0
|
||||
|
||||
## 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
|
||||
|
||||
## Tests
|
||||
Without Coverage: `go test atri.dad/lib`
|
||||
With Coverage: `go test atri.dad/lib -cover`
|
||||
|
|
42
api/authed.go
Normal file
42
api/authed.go
Normal 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)
|
||||
|
||||
}
|
27
api/nowplaying.go
Normal file
27
api/nowplaying.go
Normal file
|
@ -0,0 +1,27 @@
|
|||
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 {
|
||||
return c.String(http.StatusOK, `<div class="indicator-item badge badge-success"><a _="on mouseover put '🎧 Listening to `+playing.Item.Name+" by "+playing.Item.Artists[0].Name+` 🎧' 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, "")
|
||||
}
|
||||
}
|
26
api/pay.go
Normal file
26
api/pay.go
Normal 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")
|
||||
}
|
11
api/ping.go
Normal file
11
api/ping.go
Normal 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!")
|
||||
}
|
12
api/post.copy.go
Normal file
12
api/post.copy.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
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>`)
|
||||
|
||||
}
|
51
api/sse.go
Normal file
51
api/sse.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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 ticker that fires every 15 seconds
|
||||
ticker := lib.CreateTickerAndKeepAlive(c, 30*time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
37
api/ssedemosend.go
Normal file
37
api/ssedemosend.go
Normal 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"})
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
import { defineConfig } from "astro/config";
|
||||
import mdx from "@astrojs/mdx";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
import node from "@astrojs/node";
|
||||
|
||||
import react from "@astrojs/react";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: "https://atri.dad/",
|
||||
output: "server",
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
port: process.env.PORT || 3000,
|
||||
},
|
||||
integrations: [mdx(), sitemap(), tailwind(), react()],
|
||||
vite: {
|
||||
ssr: {
|
||||
noExternal: ["react-icons"],
|
||||
},
|
||||
},
|
||||
});
|
18
content/cognitive-load-of-syntax.md
Normal file
18
content/cognitive-load-of-syntax.md
Normal 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
6
content/contentfs.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package contentfs
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *
|
||||
var FS embed.FS
|
78
content/goth-stack.md
Normal file
78
content/goth-stack.md
Normal 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!
|
28
content/scalable-realtime-apps-with-fly-and-remix.md
Normal file
28
content/scalable-realtime-apps-with-fly-and-remix.md
Normal 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:
|
||||

|
||||
|
||||
# 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!
|
22
fly.toml
Normal file
22
fly.toml
Normal file
|
@ -0,0 +1,22 @@
|
|||
# fly.toml app configuration file generated for atridad on 2023-12-17T17:25:08-07:00
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = "atridad"
|
||||
primary_region = "sea"
|
||||
|
||||
[build]
|
||||
|
||||
[http_service]
|
||||
internal_port = 3000
|
||||
force_https = true
|
||||
auto_stop_machines = true
|
||||
auto_start_machines = true
|
||||
min_machines_running = 1
|
||||
processes = ["app"]
|
||||
|
||||
[[vm]]
|
||||
cpu_kind = "shared"
|
||||
cpus = 1
|
||||
memory_mb = 256
|
50
go.mod
Normal file
50
go.mod
Normal file
|
@ -0,0 +1,50 @@
|
|||
module atri.dad
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma/v2 v2.12.0
|
||||
golang.org/x/oauth2 v0.17.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/repr v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dlclark/regexp2 v1.10.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.1 // indirect
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||
github.com/golang/protobuf v1.5.3 // indirect
|
||||
github.com/google/go-cmp v0.6.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/net v0.21.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
golang.org/x/time v0.5.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.32.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/assert/v2 v2.4.1 // indirect
|
||||
github.com/clerkinc/clerk-sdk-go v1.49.0
|
||||
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.11.4
|
||||
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.4.0
|
||||
github.com/resendlabs/resend-go v1.7.0
|
||||
github.com/stripe/stripe-go/v76 v76.16.0
|
||||
github.com/svix/svix-webhooks v1.17.0
|
||||
github.com/yuin/goldmark v1.7.0
|
||||
github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc
|
||||
github.com/zmb3/spotify v1.3.0
|
||||
golang.org/x/crypto v0.19.0 // indirect
|
||||
golang.org/x/sys v0.17.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
160
go.sum
Normal file
160
go.sum
Normal file
|
@ -0,0 +1,160 @@
|
|||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/alecthomas/assert/v2 v2.4.1 h1:mwPZod/d35nlaCppr6sFP0rbCL05WH9fIo7lvsf47zo=
|
||||
github.com/alecthomas/assert/v2 v2.4.1/go.mod h1:fw5suVxB+wfYJ3291t0hRTqtGzFYdSwstnRQdaQx2DM=
|
||||
github.com/alecthomas/chroma/v2 v2.2.0/go.mod h1:vf4zrexSH54oEjJ7EdB65tGNHmH3pGZmVkgTP5RHvAs=
|
||||
github.com/alecthomas/chroma/v2 v2.12.0 h1:Wh8qLEgMMsN7mgyG8/qIpegky2Hvzr4By6gEF7cmWgw=
|
||||
github.com/alecthomas/chroma/v2 v2.12.0/go.mod h1:4TQu7gdfuPjSh76j78ietmqh9LiurGF0EpseFXdKMBw=
|
||||
github.com/alecthomas/repr v0.0.0-20220113201626-b1b626ac65ae/go.mod h1:2kn6fqh/zIyPLmm3ugklbEi5hg5wS435eygvNfaDQL8=
|
||||
github.com/alecthomas/repr v0.3.0 h1:NeYzUPfjjlqHY4KtzgKJiWd6sVq2eNUPTi34PiFGjY8=
|
||||
github.com/alecthomas/repr v0.3.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
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.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.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/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.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
|
||||
github.com/dlclark/regexp2 v1.10.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.1 h1:pWmKFVtt+Jl0vBZTIpz/eAKwsm6LkIxDVVbFHKkchhA=
|
||||
github.com/go-jose/go-jose/v3 v3.0.1/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
|
||||
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/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/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.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/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/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.4.0 h1:Yzoz33UZw9I/mFhx4MNrB6Fk+XHO1VukNcCa1+lwyKk=
|
||||
github.com/redis/go-redis/v9 v9.4.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
|
||||
github.com/resendlabs/resend-go v1.7.0 h1:DycOqSXtw2q7aB+Nt9DDJUDtaYcrNPGn1t5RFposas0=
|
||||
github.com/resendlabs/resend-go v1.7.0/go.mod h1:yip1STH7Bqfm4fD0So5HgyNbt5taG5Cplc4xXxETyLI=
|
||||
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.16.0 h1:XB+gA4QX532p1N98ZWez6wuI+5xcUbxR+jT5s7mmmug=
|
||||
github.com/stripe/stripe-go/v76 v76.16.0/go.mod h1:rw1MxjlAKKcZ+3FOXgTHgwiOa2ya6CPq6ykpJ0Q6Po4=
|
||||
github.com/svix/svix-webhooks v1.17.0 h1:xCpz1Nbl6FvKMF2SpFoUsdKAjgfWLiU7P1SPQOCq30g=
|
||||
github.com/svix/svix-webhooks v1.17.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.0 h1:EfOIvIMZIzHdB/R/zVrikYLPPwJlfMcNczJFMs1m6sA=
|
||||
github.com/yuin/goldmark v1.7.0/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 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
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.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ=
|
||||
golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA=
|
||||
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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.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/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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
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/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=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I=
|
||||
google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
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.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=
|
31
lib/email.go
Normal file
31
lib/email.go
Normal file
|
@ -0,0 +1,31 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/resendlabs/resend-go"
|
||||
)
|
||||
|
||||
var client *resend.Client
|
||||
|
||||
// init function
|
||||
func init() {
|
||||
client = resend.NewClient(os.Getenv("RESEND_API_KEY"))
|
||||
}
|
||||
|
||||
func SendEmail(to_email string, from_email string, from_name string, html string, subject string) {
|
||||
params := &resend.SendEmailRequest{
|
||||
From: from_name + "<" + from_email + ">",
|
||||
To: []string{to_email},
|
||||
Html: html,
|
||||
Subject: subject,
|
||||
}
|
||||
|
||||
sent, err := client.Emails.Send(params)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
return
|
||||
}
|
||||
fmt.Println(sent.Id)
|
||||
}
|
20
lib/links.go
Normal file
20
lib/links.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
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
|
||||
}
|
27
lib/logging.go
Normal file
27
lib/logging.go
Normal 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
65
lib/markdown.go
Normal 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
|
||||
}
|
90
lib/pubsub/adapters/localpubsub.go
Normal file
90
lib/pubsub/adapters/localpubsub.go
Normal file
|
@ -0,0 +1,90 @@
|
|||
package adapters
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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
|
||||
case <-time.After(30 * time.Second):
|
||||
// No message has been received for 30 seconds. Send a keep-alive message.
|
||||
return &pubsub.Message{Payload: "keep-alive"}, nil
|
||||
}
|
||||
}
|
||||
}
|
70
lib/pubsub/adapters/redispubsub.go
Normal file
70
lib/pubsub/adapters/redispubsub.go
Normal file
|
@ -0,0 +1,70 @@
|
|||
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 {
|
||||
if RedisClient != nil {
|
||||
return RedisClient
|
||||
}
|
||||
|
||||
godotenv.Load(".env")
|
||||
redis_host := os.Getenv("REDIS_HOST")
|
||||
redis_password := os.Getenv("REDIS_PASSWORD")
|
||||
|
||||
lib.LogInfo.Printf("\n[PUBSUB/REDIS]Connecting to Redis at %s\n", redis_host)
|
||||
RedisClient = redis.NewClient(&redis.Options{
|
||||
Addr: redis_host,
|
||||
Password: redis_password,
|
||||
DB: 0,
|
||||
})
|
||||
|
||||
return RedisClient
|
||||
}
|
||||
|
||||
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
16
lib/pubsub/interface.go
Normal 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
|
||||
}
|
14
lib/s3.go
Normal file
14
lib/s3.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func GeneratePublicURL(key string) string {
|
||||
bucket := os.Getenv("BUCKET_NAME")
|
||||
endpoint := os.Getenv("AWS_ENDPOINT_URL_S3")
|
||||
|
||||
url := fmt.Sprintf("%s/%s/%s", endpoint, bucket, key)
|
||||
return url
|
||||
}
|
64
lib/spotify.go
Normal file
64
lib/spotify.go
Normal file
|
@ -0,0 +1,64 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"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",
|
||||
}
|
||||
|
||||
func GetCurrentlyPlayingTrack(clientID string, clientSecret string, refreshToken string) (*spotify.CurrentlyPlaying, error) {
|
||||
// OAuth2 config
|
||||
config := &oauth2.Config{
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
Scopes: []string{spotify.ScopeUserReadCurrentlyPlaying},
|
||||
Endpoint: spotifyOAuth2Endpoint,
|
||||
}
|
||||
|
||||
// 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 {
|
||||
SendSSE(ctx, pubSub, "spotify", `<div class="indicator-item badge badge-success"><a _="on mouseover put '🎧 Listening to `+playing.Item.Name+" by "+playing.Item.Artists[0].Name+` 🎧' 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
|
||||
}
|
145
lib/sse.go
Normal file
145
lib/sse.go
Normal file
|
@ -0,0 +1,145 @@
|
|||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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 CreateTickerAndKeepAlive(c echo.Context, duration time.Duration) *time.Ticker {
|
||||
ticker := time.NewTicker(duration)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
if _, err := c.Response().Write([]byte(": keep-alive\n\n")); err != nil {
|
||||
log.Printf("Failed to write keep-alive: %v", err)
|
||||
}
|
||||
c.Response().Flush()
|
||||
}
|
||||
}()
|
||||
return ticker
|
||||
}
|
||||
|
||||
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
41
lib/stripe.go
Normal 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)
|
||||
}
|
43
lib/templates.go
Normal file
43
lib/templates.go
Normal 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
|
||||
}
|
151
main.go
Normal file
151
main.go
Normal file
|
@ -0,0 +1,151 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"atri.dad/api"
|
||||
"atri.dad/lib"
|
||||
"atri.dad/lib/pubsub"
|
||||
"atri.dad/lib/pubsub/adapters"
|
||||
"atri.dad/pages"
|
||||
"atri.dad/webhooks"
|
||||
|
||||
"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
|
||||
adapters.RedisClient = adapters.NewRedisClient()
|
||||
|
||||
// Test Redis connection
|
||||
_, err := adapters.RedisClient.Ping(context.Background()).Result()
|
||||
|
||||
// Initialize pubsub
|
||||
var pubSub pubsub.PubSub
|
||||
if err != nil {
|
||||
lib.LogWarning.Printf("\n[PUBSUB/INIT] Failed to connect to Redis: %v\n", err)
|
||||
lib.LogWarning.Printf("\n[PUBSUB/INIT] Falling back to LocalPubSub\n")
|
||||
pubSub = &adapters.LocalPubSub{}
|
||||
} else {
|
||||
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)))
|
||||
|
||||
// Generate the deployment time when the application starts
|
||||
deploymentTime := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
// Static server
|
||||
fs := http.FS(PublicFS)
|
||||
e.GET("/public/*", func(c echo.Context) error {
|
||||
// Generate an ETag based on the deployment time
|
||||
eTag := fmt.Sprintf(`W/"%s"`, deploymentTime)
|
||||
|
||||
// Set the ETag header
|
||||
c.Response().Header().Set("ETag", eTag)
|
||||
|
||||
// Set the Cache-Control header
|
||||
c.Response().Header().Set("Cache-Control", "public, max-age=3600")
|
||||
|
||||
// Check the file extension and set the Content-Type header accordingly
|
||||
ext := filepath.Ext(c.Param("*"))
|
||||
switch ext {
|
||||
case ".css":
|
||||
c.Response().Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
case ".js":
|
||||
c.Response().Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||
}
|
||||
|
||||
return echo.WrapHandler(http.FileServer(fs))(c)
|
||||
})
|
||||
|
||||
// Page routes
|
||||
e.GET("/", pages.Home)
|
||||
e.GET("/projects", pages.Projects)
|
||||
e.GET("/talks", pages.Talks)
|
||||
e.GET("/blog", pages.Blog)
|
||||
e.GET("/post/:post", pages.Post)
|
||||
e.GET("/sse", pages.SSEDemo)
|
||||
e.GET("/rss", pages.RSSFeedHandler)
|
||||
|
||||
// API Routes:
|
||||
apiGroup := e.Group("/api")
|
||||
apiGroup.GET("/ping", api.Ping)
|
||||
apiGroup.GET("/authed/ping", api.Authed)
|
||||
apiGroup.POST("/pay", api.Pay)
|
||||
apiGroup.GET("/post/copy", api.PostCopy)
|
||||
|
||||
apiGroup.GET("/sse", func(c echo.Context) error {
|
||||
return api.SSE(c, pubSub)
|
||||
})
|
||||
|
||||
apiGroup.POST("/sendsse", func(c echo.Context) error {
|
||||
return api.SSEDemoSend(c, pubSub)
|
||||
})
|
||||
|
||||
apiGroup.GET("/nowplaying", api.NowPlayingHandler)
|
||||
|
||||
// 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)
|
||||
}
|
29
package.json
29
package.json
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"name": "atri-dad",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "node dist/server/entry.mjs",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^0.19.1",
|
||||
"@astrojs/node": "^5.1.3",
|
||||
"@astrojs/react": "^2.1.3",
|
||||
"@astrojs/rss": "^2.4.1",
|
||||
"@astrojs/sitemap": "^1.3.1",
|
||||
"@astrojs/tailwind": "^3.1.2",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/react": "^18.0.21",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"astro": "^2.4.5",
|
||||
"daisyui": "^2.51.6",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"react-icons": "^4.8.0",
|
||||
"tailwindcss": "^3.3.2"
|
||||
}
|
||||
}
|
71
pages/blog.go
Normal file
71
pages/blog.go
Normal 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
22
pages/example.go
Normal 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)
|
||||
}
|
143
pages/home.go
Normal file
143
pages/home.go
Normal file
File diff suppressed because one or more lines are too long
78
pages/post.go
Normal file
78
pages/post.go
Normal 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)
|
||||
}
|
73
pages/projects.go
Normal file
73
pages/projects.go
Normal file
|
@ -0,0 +1,73 @@
|
|||
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: "Sprint Padawan",
|
||||
Description: "A scrum poker tool that helps agile teams plan their sprints in real-time.",
|
||||
Tags: []string{"react", "remix.js", "product"},
|
||||
Href: "https://sprintpadawan.dev",
|
||||
},
|
||||
{
|
||||
Name: "Atash",
|
||||
Description: "The 🔥hottest🔥 full-stack Remix template!",
|
||||
Tags: []string{"react", "remix.js", "template"},
|
||||
Href: "http://atash.atri.dad",
|
||||
},
|
||||
{
|
||||
Name: "Darius TS",
|
||||
Description: "🚀 A Web Application Template Powered by HTMX + Elysia + Tailwind 🚀",
|
||||
Tags: []string{"typescript", "htmx", "template"},
|
||||
Href: "https://ts.darius.atri.dad",
|
||||
},
|
||||
{
|
||||
Name: "Darius Go",
|
||||
Description: "🚀 A Web Application Template Powered by HTMX + Go Fiber + Tailwind 🚀",
|
||||
Tags: []string{"golang", "htmx", "template"},
|
||||
Href: "https://go.darius.atri.dad",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
Name: "GOTH Stack",
|
||||
Description: "A template using Go + Templates + HTMX",
|
||||
Tags: []string{"golang", "htmx", "template"},
|
||||
Href: "https://github.com/atridadl/goth.stack",
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
54
pages/rss.go
Normal file
54
pages/rss.go
Normal file
|
@ -0,0 +1,54 @@
|
|||
package pages
|
||||
|
||||
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))
|
||||
}
|
14
pages/ssedemo.go
Normal file
14
pages/ssedemo.go
Normal 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)
|
||||
}
|
39
pages/talks.go
Normal file
39
pages/talks.go
Normal 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
22
pages/templates/blog.html
Normal 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}}
|
26
pages/templates/example.html
Normal file
26
pages/templates/example.html
Normal 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}}
|
102
pages/templates/home.html
Normal file
102
pages/templates/home.html
Normal file
|
@ -0,0 +1,102 @@
|
|||
{{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'm <span
|
||||
class="bg-gradient-to-r from-pink-500 to-blue-500 bg-clip-text text-transparent"
|
||||
>Atridad</span
|
||||
>
|
||||
</h1>
|
||||
|
||||
<h2 class="text-2xl font-extrabold tracking-tight text-white sm:text-[2rem]">
|
||||
I'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}}
|
||||
{{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/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}}
|
||||
</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}}
|
||||
<a class="fill-white hover:fill-pink-500"
|
||||
href={{.Href}} target="_blank" rel="me" aria-label={{.Name}}>
|
||||
{{.Icon}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<div class="flex flex-row flex-wrap gap-2">
|
||||
<a
|
||||
class="btn btn-primary btn-outline btn-md lg:btn-lg"
|
||||
href={{.ContractLink}}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Contract Me
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="btn btn-primary btn-outline btn-md lg:btn-lg"
|
||||
href={{.ResumeURL}}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Resumé
|
||||
</a>
|
||||
|
||||
<a
|
||||
class="btn btn-primary btn-outline btn-md lg:btn-lg"
|
||||
href={{.SupportLink}}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Support 🩵
|
||||
</a>
|
||||
</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}}
|
22
pages/templates/layouts/base.html
Normal file
22
pages/templates/layouts/base.html
Normal 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}}
|
58
pages/templates/layouts/post.html
Normal file
58
pages/templates/layouts/post.html
Normal 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}}
|
45
pages/templates/partials/cardlinks.html
Normal file
45
pages/templates/partials/cardlinks.html
Normal file
|
@ -0,0 +1,45 @@
|
|||
{{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">
|
||||
<a
|
||||
role="button"
|
||||
href={{.Href}}
|
||||
aria-label={{.Name}}
|
||||
class="btn btn-circle btn-base-100 text-primary hover:btn-accent hover:text-neutral"
|
||||
>
|
||||
{{if eq true .Internal}}
|
||||
<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>
|
||||
{{else}}
|
||||
<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>
|
||||
{{end}}
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
4
pages/templates/partials/global.html
Normal file
4
pages/templates/partials/global.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
|
||||
{{define "header"}}
|
||||
<div class="fill-green-500 tooltip tooltip-top badge badge-success"></div>
|
||||
{{end}}
|
20
pages/templates/partials/header.html
Normal file
20
pages/templates/partials/header.html
Normal 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}}
|
27
pages/templates/partials/navitems.html
Normal file
27
pages/templates/partials/navitems.html
Normal file
|
@ -0,0 +1,27 @@
|
|||
{{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="/blog">
|
||||
Blog
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="no-underline" href="https://stats.uptimerobot.com/PoGQ9Cv9M2" target="_blank" rel="noreferrer">
|
||||
Status
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
20
pages/templates/post.html
Normal file
20
pages/templates/post.html
Normal 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}}
|
22
pages/templates/projects.html
Normal file
22
pages/templates/projects.html
Normal 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}}
|
33
pages/templates/ssedemo.html
Normal file
33
pages/templates/ssedemo.html
Normal file
|
@ -0,0 +1,33 @@
|
|||
{{define "title"}}Atridad Lahiji // SSE{{end}}
|
||||
|
||||
{{define "headercontent"}}
|
||||
Atridad Lahiji // SSE <div class="badge badge-accent">BETA</div>
|
||||
{{end}}
|
||||
|
||||
{{define "head"}}
|
||||
<link rel="stylesheet" href="/public/css/styles.ssedemo.css" />
|
||||
{{end}}
|
||||
|
||||
{{define "main"}}
|
||||
<h1 class="text-4xl">Server Sent Events</h1>
|
||||
<h2 class="text-xl">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.</h2>
|
||||
<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/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}}
|
22
pages/templates/talks.html
Normal file
22
pages/templates/talks.html
Normal 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}}
|
6
pages/templates/templatesfs.go
Normal file
6
pages/templates/templatesfs.go
Normal file
|
@ -0,0 +1,6 @@
|
|||
package templatefs
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *
|
||||
var FS embed.FS
|
4262
pnpm-lock.yaml
generated
4262
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
1
public/css/styles.blog.css
vendored
Normal file
1
public/css/styles.blog.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/css/styles.home.css
vendored
Normal file
1
public/css/styles.home.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/css/styles.post.css
vendored
Normal file
1
public/css/styles.post.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/css/styles.projects.css
vendored
Normal file
1
public/css/styles.projects.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/css/styles.ssedemo.css
vendored
Normal file
1
public/css/styles.ssedemo.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/css/styles.talks.css
vendored
Normal file
1
public/css/styles.talks.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
public/js/htmx.base.js
vendored
Normal file
1
public/js/htmx.base.js
vendored
Normal file
File diff suppressed because one or more lines are too long
355
public/js/htmx.sse.js
vendored
Normal file
355
public/js/htmx.sse.js
vendored
Normal file
|
@ -0,0 +1,355 @@
|
|||
/*
|
||||
Server Sent Events Extension
|
||||
============================
|
||||
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||
|
||||
*/
|
||||
|
||||
(function() {
|
||||
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api;
|
||||
|
||||
htmx.defineExtension("sse", {
|
||||
|
||||
/**
|
||||
* Init saves the provided reference to the internal HTMX API.
|
||||
*
|
||||
* @param {import("../htmx").HtmxInternalApi} api
|
||||
* @returns void
|
||||
*/
|
||||
init: function(apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef;
|
||||
|
||||
// set a function in the public API for creating new EventSource objects
|
||||
if (htmx.createEventSource == undefined) {
|
||||
htmx.createEventSource = createEventSource;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
* @returns void
|
||||
*/
|
||||
onEvent: function(name, evt) {
|
||||
|
||||
switch (name) {
|
||||
|
||||
case "htmx:beforeCleanupElement":
|
||||
var internalData = api.getInternalData(evt.target)
|
||||
// Try to remove remove an EventSource when elements are removed
|
||||
if (internalData.sseEventSource) {
|
||||
internalData.sseEventSource.close();
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
// Try to create EventSources when elements are processed
|
||||
case "htmx:afterProcessNode":
|
||||
ensureEventSourceOnElement(evt.target);
|
||||
registerSSE(evt.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
///////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
///////////////////////////////////////////////
|
||||
|
||||
|
||||
/**
|
||||
* createEventSource is the default method for creating new EventSource objects.
|
||||
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns EventSource
|
||||
*/
|
||||
function createEventSource(url) {
|
||||
return new EventSource(url, { withCredentials: true });
|
||||
}
|
||||
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.trim().split(/\s+/);
|
||||
}
|
||||
|
||||
function getLegacySSEURL(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
|
||||
if (legacySSEValue) {
|
||||
var values = splitOnWhitespace(legacySSEValue);
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/);
|
||||
if (value[0] === "connect") {
|
||||
return value[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLegacySSESwaps(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, "hx-sse");
|
||||
var returnArr = [];
|
||||
if (legacySSEValue != null) {
|
||||
var values = splitOnWhitespace(legacySSEValue);
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/);
|
||||
if (value[0] === "swap") {
|
||||
returnArr.push(value[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return returnArr;
|
||||
}
|
||||
|
||||
/**
|
||||
* registerSSE looks for attributes that can contain sse events, right
|
||||
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||
* the closest event source
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function registerSSE(elt) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource);
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null; // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement);
|
||||
var source = internalData.sseEventSource;
|
||||
|
||||
// Add message handlers for every `sse-swap` attribute
|
||||
queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function(child) {
|
||||
|
||||
var sseSwapAttr = api.getAttributeValue(child, "sse-swap");
|
||||
if (sseSwapAttr) {
|
||||
var sseEventNames = sseSwapAttr.split(",");
|
||||
} else {
|
||||
var sseEventNames = getLegacySSESwaps(child);
|
||||
}
|
||||
|
||||
for (var i = 0; i < sseEventNames.length; i++) {
|
||||
var sseEventName = sseEventNames[i].trim();
|
||||
var listener = function(event) {
|
||||
|
||||
// If the source is missing then close SSE
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the body no longer contains the element, remove the listener
|
||||
if (!api.bodyContains(child)) {
|
||||
source.removeEventListener(sseEventName, listener);
|
||||
}
|
||||
|
||||
// swap the response into the DOM and trigger a notification
|
||||
swap(child, event.data);
|
||||
api.triggerEvent(elt, "htmx:sseMessage", event);
|
||||
};
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(child).sseEventListener = listener;
|
||||
source.addEventListener(sseEventName, listener);
|
||||
}
|
||||
});
|
||||
|
||||
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||
queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) {
|
||||
|
||||
var sseEventName = api.getAttributeValue(child, "hx-trigger");
|
||||
if (sseEventName == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only process hx-triggers for events with the "sse:" prefix
|
||||
if (sseEventName.slice(0, 4) != "sse:") {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove the sse: prefix from here on out
|
||||
sseEventName = sseEventName.substr(4);
|
||||
|
||||
var listener = function() {
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!api.bodyContains(child)) {
|
||||
source.removeEventListener(sseEventName, listener);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||
* is created and stored in the element's internalData.
|
||||
* @param {HTMLElement} elt
|
||||
* @param {number} retryCount
|
||||
* @returns {EventSource | null}
|
||||
*/
|
||||
function ensureEventSourceOnElement(elt, retryCount) {
|
||||
|
||||
if (elt == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// handle extension source creation attribute
|
||||
queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) {
|
||||
var sseURL = api.getAttributeValue(child, "sse-connect");
|
||||
if (sseURL == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureEventSource(child, sseURL, retryCount);
|
||||
});
|
||||
|
||||
// handle legacy sse, remove for HTMX2
|
||||
queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) {
|
||||
var sseURL = getLegacySSEURL(child);
|
||||
if (sseURL == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureEventSource(child, sseURL, retryCount);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
function ensureEventSource(elt, url, retryCount) {
|
||||
var source = htmx.createEventSource(url);
|
||||
|
||||
source.onerror = function(err) {
|
||||
|
||||
// Log an error event
|
||||
api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source });
|
||||
|
||||
// If parent no longer exists in the document, then clean up this EventSource
|
||||
if (maybeCloseSSESource(elt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, try to reconnect the EventSource
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
retryCount = retryCount || 0;
|
||||
var timeout = Math.random() * (2 ^ retryCount) * 500;
|
||||
window.setTimeout(function() {
|
||||
ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1));
|
||||
}, timeout);
|
||||
}
|
||||
};
|
||||
|
||||
source.onopen = function(evt) {
|
||||
api.triggerEvent(elt, "htmx:sseOpen", { source: source });
|
||||
}
|
||||
|
||||
api.getInternalData(elt).sseEventSource = source;
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseSSESource confirms that the parent element still exists.
|
||||
* If not, then any associated SSE source is closed and the function returns true.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @returns boolean
|
||||
*/
|
||||
function maybeCloseSSESource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
var source = api.getInternalData(elt).sseEventSource;
|
||||
if (source != undefined) {
|
||||
source.close();
|
||||
// source = null
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} attributeName
|
||||
*/
|
||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||
|
||||
var result = [];
|
||||
|
||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||
if (api.hasAttribute(elt, attributeName)) {
|
||||
result.push(elt);
|
||||
}
|
||||
|
||||
// Search all child nodes that match the requested attribute
|
||||
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) {
|
||||
result.push(node);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} content
|
||||
*/
|
||||
function swap(elt, content) {
|
||||
|
||||
api.withExtensions(elt, function(extension) {
|
||||
content = extension.transformResponse(content, null, elt);
|
||||
});
|
||||
|
||||
var swapSpec = api.getSwapSpecification(elt);
|
||||
var target = api.getTarget(elt);
|
||||
var settleInfo = api.makeSettleInfo(elt);
|
||||
|
||||
api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo);
|
||||
|
||||
settleInfo.elts.forEach(function(elt) {
|
||||
if (elt.classList) {
|
||||
elt.classList.add(htmx.config.settlingClass);
|
||||
}
|
||||
api.triggerEvent(elt, 'htmx:beforeSettle');
|
||||
});
|
||||
|
||||
// Handle settle tasks (with delay if requested)
|
||||
if (swapSpec.settleDelay > 0) {
|
||||
setTimeout(doSettle(settleInfo), swapSpec.settleDelay);
|
||||
} else {
|
||||
doSettle(settleInfo)();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* doSettle mirrors much of the functionality in htmx that
|
||||
* settles elements after their content has been swapped.
|
||||
* TODO: this should be published by htmx, and not duplicated here
|
||||
* @param {import("../htmx").HtmxSettleInfo} settleInfo
|
||||
* @returns () => void
|
||||
*/
|
||||
function doSettle(settleInfo) {
|
||||
|
||||
return function() {
|
||||
settleInfo.tasks.forEach(function(task) {
|
||||
task.call();
|
||||
});
|
||||
|
||||
settleInfo.elts.forEach(function(elt) {
|
||||
if (elt.classList) {
|
||||
elt.classList.remove(htmx.config.settlingClass);
|
||||
}
|
||||
api.triggerEvent(elt, 'htmx:afterSettle');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function hasEventSource(node) {
|
||||
return api.getInternalData(node).sseEventSource != null;
|
||||
}
|
||||
|
||||
})();
|
477
public/js/htmx.ws.js
vendored
Normal file
477
public/js/htmx.ws.js
vendored
Normal file
|
@ -0,0 +1,477 @@
|
|||
/*
|
||||
WebSockets Extension
|
||||
============================
|
||||
This extension adds support for WebSockets to htmx. See /www/extensions/ws.md for usage instructions.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api;
|
||||
|
||||
htmx.defineExtension("ws", {
|
||||
|
||||
/**
|
||||
* init is called once, when this extension is first registered.
|
||||
* @param {import("../htmx").HtmxInternalApi} apiRef
|
||||
*/
|
||||
init: function (apiRef) {
|
||||
|
||||
// Store reference to internal API
|
||||
api = apiRef;
|
||||
|
||||
// Default function for creating new EventSource objects
|
||||
if (!htmx.createWebSocket) {
|
||||
htmx.createWebSocket = createWebSocket;
|
||||
}
|
||||
|
||||
// Default setting for reconnect delay
|
||||
if (!htmx.config.wsReconnectDelay) {
|
||||
htmx.config.wsReconnectDelay = "full-jitter";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
*/
|
||||
onEvent: function (name, evt) {
|
||||
|
||||
switch (name) {
|
||||
|
||||
// Try to close the socket when elements are removed
|
||||
case "htmx:beforeCleanupElement":
|
||||
|
||||
var internalData = api.getInternalData(evt.target)
|
||||
|
||||
if (internalData.webSocket) {
|
||||
internalData.webSocket.close();
|
||||
}
|
||||
return;
|
||||
|
||||
// Try to create websockets when elements are processed
|
||||
case "htmx:beforeProcessNode":
|
||||
var parent = evt.target;
|
||||
|
||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-connect"), function (child) {
|
||||
ensureWebSocket(child)
|
||||
});
|
||||
forEach(queryAttributeOnThisOrChildren(parent, "ws-send"), function (child) {
|
||||
ensureWebSocketSend(child)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function splitOnWhitespace(trigger) {
|
||||
return trigger.trim().split(/\s+/);
|
||||
}
|
||||
|
||||
function getLegacyWebsocketURL(elt) {
|
||||
var legacySSEValue = api.getAttributeValue(elt, "hx-ws");
|
||||
if (legacySSEValue) {
|
||||
var values = splitOnWhitespace(legacySSEValue);
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var value = values[i].split(/:(.+)/);
|
||||
if (value[0] === "connect") {
|
||||
return value[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocket creates a new WebSocket on the designated element, using
|
||||
* the element's "ws-connect" attribute.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @returns
|
||||
*/
|
||||
function ensureWebSocket(socketElt) {
|
||||
|
||||
// If the element containing the WebSocket connection no longer exists, then
|
||||
// do not connect/reconnect the WebSocket.
|
||||
if (!api.bodyContains(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the source straight from the element's value
|
||||
var wssSource = api.getAttributeValue(socketElt, "ws-connect")
|
||||
|
||||
if (wssSource == null || wssSource === "") {
|
||||
var legacySource = getLegacyWebsocketURL(socketElt);
|
||||
if (legacySource == null) {
|
||||
return;
|
||||
} else {
|
||||
wssSource = legacySource;
|
||||
}
|
||||
}
|
||||
|
||||
// Guarantee that the wssSource value is a fully qualified URL
|
||||
if (wssSource.indexOf("/") === 0) {
|
||||
var base_part = location.hostname + (location.port ? ':' + location.port : '');
|
||||
if (location.protocol === 'https:') {
|
||||
wssSource = "wss://" + base_part + wssSource;
|
||||
} else if (location.protocol === 'http:') {
|
||||
wssSource = "ws://" + base_part + wssSource;
|
||||
}
|
||||
}
|
||||
|
||||
var socketWrapper = createWebsocketWrapper(socketElt, function () {
|
||||
return htmx.createWebSocket(wssSource)
|
||||
});
|
||||
|
||||
socketWrapper.addEventListener('message', function (event) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var response = event.data;
|
||||
if (!api.triggerEvent(socketElt, "htmx:wsBeforeMessage", {
|
||||
message: response,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.withExtensions(socketElt, function (extension) {
|
||||
response = extension.transformResponse(response, null, socketElt);
|
||||
});
|
||||
|
||||
var settleInfo = api.makeSettleInfo(socketElt);
|
||||
var fragment = api.makeFragment(response);
|
||||
|
||||
if (fragment.children.length) {
|
||||
var children = Array.from(fragment.children);
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
api.oobSwap(api.getAttributeValue(children[i], "hx-swap-oob") || "true", children[i], settleInfo);
|
||||
}
|
||||
}
|
||||
|
||||
api.settleImmediately(settleInfo.tasks);
|
||||
api.triggerEvent(socketElt, "htmx:wsAfterMessage", { message: response, socketWrapper: socketWrapper.publicInterface })
|
||||
});
|
||||
|
||||
// Put the WebSocket into the HTML Element's custom data.
|
||||
api.getInternalData(socketElt).webSocket = socketWrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} WebSocketWrapper
|
||||
* @property {WebSocket} socket
|
||||
* @property {Array<{message: string, sendElt: Element}>} messageQueue
|
||||
* @property {number} retryCount
|
||||
* @property {(message: string, sendElt: Element) => void} sendImmediately sendImmediately sends message regardless of websocket connection state
|
||||
* @property {(message: string, sendElt: Element) => void} send
|
||||
* @property {(event: string, handler: Function) => void} addEventListener
|
||||
* @property {() => void} handleQueuedMessages
|
||||
* @property {() => void} init
|
||||
* @property {() => void} close
|
||||
*/
|
||||
/**
|
||||
*
|
||||
* @param socketElt
|
||||
* @param socketFunc
|
||||
* @returns {WebSocketWrapper}
|
||||
*/
|
||||
function createWebsocketWrapper(socketElt, socketFunc) {
|
||||
var wrapper = {
|
||||
socket: null,
|
||||
messageQueue: [],
|
||||
retryCount: 0,
|
||||
|
||||
/** @type {Object<string, Function[]>} */
|
||||
events: {},
|
||||
|
||||
addEventListener: function (event, handler) {
|
||||
if (this.socket) {
|
||||
this.socket.addEventListener(event, handler);
|
||||
}
|
||||
|
||||
if (!this.events[event]) {
|
||||
this.events[event] = [];
|
||||
}
|
||||
|
||||
this.events[event].push(handler);
|
||||
},
|
||||
|
||||
sendImmediately: function (message, sendElt) {
|
||||
if (!this.socket) {
|
||||
api.triggerErrorEvent()
|
||||
}
|
||||
if (!sendElt || api.triggerEvent(sendElt, 'htmx:wsBeforeSend', {
|
||||
message: message,
|
||||
socketWrapper: this.publicInterface
|
||||
})) {
|
||||
this.socket.send(message);
|
||||
sendElt && api.triggerEvent(sendElt, 'htmx:wsAfterSend', {
|
||||
message: message,
|
||||
socketWrapper: this.publicInterface
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
send: function (message, sendElt) {
|
||||
if (this.socket.readyState !== this.socket.OPEN) {
|
||||
this.messageQueue.push({ message: message, sendElt: sendElt });
|
||||
} else {
|
||||
this.sendImmediately(message, sendElt);
|
||||
}
|
||||
},
|
||||
|
||||
handleQueuedMessages: function () {
|
||||
while (this.messageQueue.length > 0) {
|
||||
var queuedItem = this.messageQueue[0]
|
||||
if (this.socket.readyState === this.socket.OPEN) {
|
||||
this.sendImmediately(queuedItem.message, queuedItem.sendElt);
|
||||
this.messageQueue.shift();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
init: function () {
|
||||
if (this.socket && this.socket.readyState === this.socket.OPEN) {
|
||||
// Close discarded socket
|
||||
this.socket.close()
|
||||
}
|
||||
|
||||
// Create a new WebSocket and event handlers
|
||||
/** @type {WebSocket} */
|
||||
var socket = socketFunc();
|
||||
|
||||
// The event.type detail is added for interface conformance with the
|
||||
// other two lifecycle events (open and close) so a single handler method
|
||||
// can handle them polymorphically, if required.
|
||||
api.triggerEvent(socketElt, "htmx:wsConnecting", { event: { type: 'connecting' } });
|
||||
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = function (e) {
|
||||
wrapper.retryCount = 0;
|
||||
api.triggerEvent(socketElt, "htmx:wsOpen", { event: e, socketWrapper: wrapper.publicInterface });
|
||||
wrapper.handleQueuedMessages();
|
||||
}
|
||||
|
||||
socket.onclose = function (e) {
|
||||
// If socket should not be connected, stop further attempts to establish connection
|
||||
// If Abnormal Closure/Service Restart/Try Again Later, then set a timer to reconnect after a pause.
|
||||
if (!maybeCloseWebSocketSource(socketElt) && [1006, 1012, 1013].indexOf(e.code) >= 0) {
|
||||
var delay = getWebSocketReconnectDelay(wrapper.retryCount);
|
||||
setTimeout(function () {
|
||||
wrapper.retryCount += 1;
|
||||
wrapper.init();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// Notify client code that connection has been closed. Client code can inspect `event` field
|
||||
// to determine whether closure has been valid or abnormal
|
||||
api.triggerEvent(socketElt, "htmx:wsClose", { event: e, socketWrapper: wrapper.publicInterface })
|
||||
};
|
||||
|
||||
socket.onerror = function (e) {
|
||||
api.triggerErrorEvent(socketElt, "htmx:wsError", { error: e, socketWrapper: wrapper });
|
||||
maybeCloseWebSocketSource(socketElt);
|
||||
};
|
||||
|
||||
var events = this.events;
|
||||
Object.keys(events).forEach(function (k) {
|
||||
events[k].forEach(function (e) {
|
||||
socket.addEventListener(k, e);
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
close: function () {
|
||||
this.socket.close()
|
||||
}
|
||||
}
|
||||
|
||||
wrapper.init();
|
||||
|
||||
wrapper.publicInterface = {
|
||||
send: wrapper.send.bind(wrapper),
|
||||
sendImmediately: wrapper.sendImmediately.bind(wrapper),
|
||||
queue: wrapper.messageQueue
|
||||
};
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureWebSocketSend attaches trigger handles to elements with
|
||||
* "ws-send" attribute
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function ensureWebSocketSend(elt) {
|
||||
var legacyAttribute = api.getAttributeValue(elt, "hx-ws");
|
||||
if (legacyAttribute && legacyAttribute !== 'send') {
|
||||
return;
|
||||
}
|
||||
|
||||
var webSocketParent = api.getClosestMatch(elt, hasWebSocket)
|
||||
processWebSocketSend(webSocketParent, elt);
|
||||
}
|
||||
|
||||
/**
|
||||
* hasWebSocket function checks if a node has webSocket instance attached
|
||||
* @param {HTMLElement} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasWebSocket(node) {
|
||||
return api.getInternalData(node).webSocket != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* processWebSocketSend adds event listeners to the <form> element so that
|
||||
* messages can be sent to the WebSocket server when the form is submitted.
|
||||
* @param {HTMLElement} socketElt
|
||||
* @param {HTMLElement} sendElt
|
||||
*/
|
||||
function processWebSocketSend(socketElt, sendElt) {
|
||||
var nodeData = api.getInternalData(sendElt);
|
||||
var triggerSpecs = api.getTriggerSpecs(sendElt);
|
||||
triggerSpecs.forEach(function (ts) {
|
||||
api.addTriggerHandler(sendElt, ts, nodeData, function (elt, evt) {
|
||||
if (maybeCloseWebSocketSource(socketElt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {WebSocketWrapper} */
|
||||
var socketWrapper = api.getInternalData(socketElt).webSocket;
|
||||
var headers = api.getHeaders(sendElt, api.getTarget(sendElt));
|
||||
var results = api.getInputValues(sendElt, 'post');
|
||||
var errors = results.errors;
|
||||
var rawParameters = results.values;
|
||||
var expressionVars = api.getExpressionVars(sendElt);
|
||||
var allParameters = api.mergeObjects(rawParameters, expressionVars);
|
||||
var filteredParameters = api.filterValues(allParameters, sendElt);
|
||||
|
||||
var sendConfig = {
|
||||
parameters: filteredParameters,
|
||||
unfilteredParameters: allParameters,
|
||||
headers: headers,
|
||||
errors: errors,
|
||||
|
||||
triggeringEvent: evt,
|
||||
messageBody: undefined,
|
||||
socketWrapper: socketWrapper.publicInterface
|
||||
};
|
||||
|
||||
if (!api.triggerEvent(elt, 'htmx:wsConfigSend', sendConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errors && errors.length > 0) {
|
||||
api.triggerEvent(elt, 'htmx:validation:halted', errors);
|
||||
return;
|
||||
}
|
||||
|
||||
var body = sendConfig.messageBody;
|
||||
if (body === undefined) {
|
||||
var toSend = Object.assign({}, sendConfig.parameters);
|
||||
if (sendConfig.headers)
|
||||
toSend['HEADERS'] = headers;
|
||||
body = JSON.stringify(toSend);
|
||||
}
|
||||
|
||||
socketWrapper.send(body, elt);
|
||||
|
||||
if (evt && api.shouldCancel(evt, elt)) {
|
||||
evt.preventDefault();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* getWebSocketReconnectDelay is the default easing function for WebSocket reconnects.
|
||||
* @param {number} retryCount // The number of retries that have already taken place
|
||||
* @returns {number}
|
||||
*/
|
||||
function getWebSocketReconnectDelay(retryCount) {
|
||||
|
||||
/** @type {"full-jitter" | ((retryCount:number) => number)} */
|
||||
var delay = htmx.config.wsReconnectDelay;
|
||||
if (typeof delay === 'function') {
|
||||
return delay(retryCount);
|
||||
}
|
||||
if (delay === 'full-jitter') {
|
||||
var exp = Math.min(retryCount, 6);
|
||||
var maxDelay = 1000 * Math.pow(2, exp);
|
||||
return maxDelay * Math.random();
|
||||
}
|
||||
|
||||
logError('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"');
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseWebSocketSource checks to the if the element that created the WebSocket
|
||||
* still exists in the DOM. If NOT, then the WebSocket is closed and this function
|
||||
* returns TRUE. If the element DOES EXIST, then no action is taken, and this function
|
||||
* returns FALSE.
|
||||
*
|
||||
* @param {*} elt
|
||||
* @returns
|
||||
*/
|
||||
function maybeCloseWebSocketSource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
api.getInternalData(elt).webSocket.close();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* createWebSocket is the default method for creating new WebSocket objects.
|
||||
* it is hoisted into htmx.createWebSocket to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns WebSocket
|
||||
*/
|
||||
function createWebSocket(url) {
|
||||
var sock = new WebSocket(url, []);
|
||||
sock.binaryType = htmx.config.wsBinaryType;
|
||||
return sock;
|
||||
}
|
||||
|
||||
/**
|
||||
* queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} attributeName
|
||||
*/
|
||||
function queryAttributeOnThisOrChildren(elt, attributeName) {
|
||||
|
||||
var result = []
|
||||
|
||||
// If the parent element also contains the requested attribute, then add it to the results too.
|
||||
if (api.hasAttribute(elt, attributeName) || api.hasAttribute(elt, "hx-ws")) {
|
||||
result.push(elt);
|
||||
}
|
||||
|
||||
// Search all child nodes that match the requested attribute
|
||||
elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "], [data-hx-ws], [hx-ws]").forEach(function (node) {
|
||||
result.push(node)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param {T[]} arr
|
||||
* @param {(T) => void} func
|
||||
*/
|
||||
function forEach(arr, func) {
|
||||
if (arr) {
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
func(arr[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
})();
|
||||
|
1
public/js/hyperscript.js
vendored
Normal file
1
public/js/hyperscript.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"infiniteLoopProtection": true,
|
||||
"hardReloadOnChange": false,
|
||||
"view": "browser",
|
||||
"template": "node",
|
||||
"container": {
|
||||
"port": 3000,
|
||||
"startScript": "start",
|
||||
"node": "16"
|
||||
}
|
||||
}
|
|
@ -1,43 +0,0 @@
|
|||
---
|
||||
// Import the global.css file here so that it is included on
|
||||
// all pages through the use of the <BaseHead /> component.
|
||||
import "../styles/global.css";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
const { title, description, image = "/placeholder-social.jpg" } = Astro.props;
|
||||
---
|
||||
|
||||
<!-- Global Metadata -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={new URL(image, Astro.url)} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={title} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
<meta property="twitter:image" content={new URL(image, Astro.url)} />
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
export interface Props {
|
||||
date: Date;
|
||||
}
|
||||
|
||||
const { date } = Astro.props;
|
||||
---
|
||||
|
||||
<time datetime={date.toISOString()}>
|
||||
{
|
||||
date.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
</time>
|
|
@ -1,35 +0,0 @@
|
|||
---
|
||||
import HeaderLink from "./HeaderLink.astro";
|
||||
import { SITE_TITLE } from "../consts";
|
||||
---
|
||||
|
||||
<header class="navbar bg-base-100">
|
||||
<div class="navbar-start">
|
||||
<a class="btn btn-ghost normal-case text-xl" href="/">{SITE_TITLE}</a>
|
||||
</div>
|
||||
<div class="navbar-end">
|
||||
<div class="dropdown dropdown-end">
|
||||
<label tabindex="0" class="btn btn-ghost">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
><path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h8m-8 6h16"></path></svg
|
||||
>
|
||||
</label>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-compact dropdown-content mt-3 p-2 shadow bg-base-100 rounded-box w-52"
|
||||
>
|
||||
<li><HeaderLink href="/">Home</HeaderLink></li>
|
||||
<li><HeaderLink href="/blog">Blog</HeaderLink></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
|
@ -1,24 +0,0 @@
|
|||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
|
||||
type Props = HTMLAttributes<'a'>;
|
||||
|
||||
const { href, class: className, ...props } = Astro.props;
|
||||
|
||||
const { pathname } = Astro.url;
|
||||
const isActive = href === pathname || href === pathname.replace(/\/$/, '');
|
||||
---
|
||||
|
||||
<a href={href} class:list={[className, { active: isActive }]} {...props}>
|
||||
<slot />
|
||||
</a>
|
||||
<style>
|
||||
a {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.active {
|
||||
font-weight: bolder;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
|
@ -1,5 +0,0 @@
|
|||
// Place any global data in this file.
|
||||
// You can import this data from anywhere in your site by using the `import` keyword.
|
||||
|
||||
export const SITE_TITLE = "[atri.dad]";
|
||||
export const SITE_DESCRIPTION = "It's me, hi, I'm the problem, it's me!";
|
|
@ -1,7 +0,0 @@
|
|||
---
|
||||
title: "Welcome to my site!"
|
||||
description: "Welcome to my site!"
|
||||
pubDate: "March 26 2023"
|
||||
---
|
||||
|
||||
Hello and welcome to my small corner of the internet! I will post some content here about the tech I work with, and other things that excite me. **Thanks for stopping by**!
|
|
@ -1,20 +0,0 @@
|
|||
import { defineCollection, z } from "astro:content";
|
||||
|
||||
const blog = defineCollection({
|
||||
// Type-check frontmatter using a schema
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
// Transform string to Date object
|
||||
pubDate: z
|
||||
.string()
|
||||
.or(z.date())
|
||||
.transform((val) => new Date(val)),
|
||||
updatedDate: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((str) => (str ? new Date(str) : undefined)),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog };
|
2
src/env.d.ts
vendored
2
src/env.d.ts
vendored
|
@ -1,2 +0,0 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
|
@ -1,35 +0,0 @@
|
|||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import BaseHead from "../components/BaseHead.astro";
|
||||
import Header from "../components/Header.astro";
|
||||
import FormattedDate from "../components/FormattedDate.astro";
|
||||
|
||||
type Props = CollectionEntry<"blog">["data"];
|
||||
|
||||
const { title, description, pubDate, updatedDate } = Astro.props;
|
||||
---
|
||||
|
||||
<html lang="en" data-theme="night">
|
||||
<head>
|
||||
<BaseHead title={title} description={description} />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Header />
|
||||
<main class="prose prose-invert mx-auto p-4">
|
||||
<article>
|
||||
<h1 class="title">{title}</h1>
|
||||
<FormattedDate date={pubDate} />
|
||||
{
|
||||
updatedDate && (
|
||||
<div class="last-updated-on">
|
||||
Last updated on <FormattedDate date={updatedDate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<hr />
|
||||
<slot />
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -1,18 +0,0 @@
|
|||
---
|
||||
import { CollectionEntry, getCollection } from "astro:content";
|
||||
import BlogPost from "../../layouts/BlogPost.astro";
|
||||
|
||||
const posts = await getCollection("blog");
|
||||
type Props = CollectionEntry<"blog">;
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const post = posts.find((page) => page.slug === slug);
|
||||
if (!post) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
const { Content } = await post.render();
|
||||
---
|
||||
|
||||
<BlogPost {...post.data}>
|
||||
<Content />
|
||||
</BlogPost>
|
|
@ -1,37 +0,0 @@
|
|||
---
|
||||
import BaseHead from "../../components/BaseHead.astro";
|
||||
import Header from "../../components/Header.astro";
|
||||
import { SITE_TITLE, SITE_DESCRIPTION } from "../../consts";
|
||||
import { getCollection } from "astro:content";
|
||||
import FormattedDate from "../../components/FormattedDate.astro";
|
||||
|
||||
const posts = (await getCollection("blog")).sort(
|
||||
(a, b) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf()
|
||||
);
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="night">
|
||||
<head>
|
||||
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
|
||||
</head>
|
||||
<body class="block h-[100%]">
|
||||
<Header />
|
||||
<main
|
||||
class="prose prose-invert container flex flex-col items-center justify-center gap-3 sm:gap-6 px-4 py-16 text-center mx-auto min-h-[calc(100%-64px)]"
|
||||
>
|
||||
<section>
|
||||
<ul>
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li>
|
||||
<FormattedDate date={post.data.pubDate} />
|
||||
<a href={`/blog/${post.slug}/`}>{post.data.title}</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
|
@ -1,158 +0,0 @@
|
|||
---
|
||||
import BaseHead from "../components/BaseHead.astro";
|
||||
import Header from "../components/Header.astro";
|
||||
import { SITE_TITLE, SITE_DESCRIPTION } from "../consts";
|
||||
|
||||
import {
|
||||
SiGithub,
|
||||
SiLinkedin,
|
||||
SiNextdotjs,
|
||||
SiPrisma,
|
||||
SiSpotify,
|
||||
SiTwitch,
|
||||
SiTwitter,
|
||||
SiYoutube,
|
||||
SiAstro,
|
||||
SiSvelte,
|
||||
SiPostgresql,
|
||||
SiRedis,
|
||||
SiRailway,
|
||||
} from "react-icons/si";
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="night">
|
||||
<head>
|
||||
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
|
||||
</head>
|
||||
<body class="block h-[100%]">
|
||||
<Header title={SITE_TITLE} />
|
||||
<main
|
||||
class="container flex flex-col items-center justify-center gap-3 sm:gap-6 px-4 py-16 text-center mx-auto min-h-[calc(100%-64px)]"
|
||||
>
|
||||
<h1 class="text-4xl font-extrabold text-white sm:text-8xl">
|
||||
Hi, I'm <span class="text-pink-500">Atridad</span>
|
||||
</h1>
|
||||
|
||||
<h2
|
||||
class="text-2xl font-extrabold tracking-tight text-white sm:text-[2rem]"
|
||||
>
|
||||
I'm a <span class="text-green-500">persian</span> -
|
||||
<span class="text-red-500">canadian</span>{" "}
|
||||
<span
|
||||
class="bg-gradient-to-r from-pink-500 to-blue-500 bg-clip-text text-transparent"
|
||||
>
|
||||
queer
|
||||
</span>{" "}
|
||||
software developer who loves making things for the web.
|
||||
</h2>
|
||||
|
||||
<h2
|
||||
class="text-xl font-extrabold tracking-tight text-white sm:text-[1.5rem]"
|
||||
>
|
||||
Pronouns: (he/<span class="text-pink-500">they</span>)
|
||||
</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"
|
||||
>
|
||||
<a
|
||||
href={"https://www.linkedin.com/in/atridad/"}
|
||||
target="_blank"
|
||||
aria-label="Linkedin"
|
||||
>
|
||||
<SiLinkedin className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
<a
|
||||
href={"https://twitter.com/atridadl/"}
|
||||
target="_blank"
|
||||
aria-label="Twitter"
|
||||
>
|
||||
<SiTwitter className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
<a
|
||||
href={"https://github.com/atridadl"}
|
||||
target="_blank"
|
||||
aria-label="Github"
|
||||
>
|
||||
<SiGithub className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
<a
|
||||
href={"https://open.spotify.com/user/31v3p4oq6im7fvpqhhbju222pbr4?si=d75830b95ed94d4f"}
|
||||
target="_blank"
|
||||
aria-label="Spotify"
|
||||
>
|
||||
<SiSpotify className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
<a
|
||||
href={"https://www.twitch.tv/himbothyswaggins"}
|
||||
target="_blank"
|
||||
aria-label="Twitch.tv"
|
||||
>
|
||||
<SiTwitch className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
<a
|
||||
href={"https://www.youtube.com/@himbothyswaggins"}
|
||||
target="_blank"
|
||||
aria-label="YouTube"
|
||||
>
|
||||
<SiYoutube className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
</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"
|
||||
>
|
||||
<a href={"https://nextjs.org/"} target="_blank" aria-label="Next.js">
|
||||
<SiNextdotjs className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
|
||||
<a href={"https://astro.build/"} target="_blank" aria-label="Astro">
|
||||
<SiAstro className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={"https://kit.svelte.dev/"}
|
||||
target="_blank"
|
||||
aria-label="SvelteKit"
|
||||
>
|
||||
<SiSvelte className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={"https://www.prisma.io/"}
|
||||
target="_blank"
|
||||
aria-label="Prisma"
|
||||
>
|
||||
<SiPrisma className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
|
||||
<a
|
||||
href={"https://www.postgresql.org/"}
|
||||
target="_blank"
|
||||
aria-label="PostgreSQL"
|
||||
>
|
||||
<SiPostgresql
|
||||
className="text-2xl hover:text-pink-500 sm:text-4xl"
|
||||
/>
|
||||
</a>
|
||||
|
||||
<a href={"https://redis.io/"} target="_blank" aria-label="Redis">
|
||||
<SiRedis className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
|
||||
<a href={"https://railway.app/"} target="_blank" aria-label="Railway">
|
||||
<SiRailway className="text-2xl hover:text-pink-500 sm:text-4xl" />
|
||||
</a>
|
||||
</div>
|
||||
</span>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
16
src/pages/rss.xml.js
vendored
16
src/pages/rss.xml.js
vendored
|
@ -1,16 +0,0 @@
|
|||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
|
||||
|
||||
export async function get(context) {
|
||||
const posts = await getCollection('blog');
|
||||
return rss({
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
site: context.site,
|
||||
items: posts.map((post) => ({
|
||||
...post.data,
|
||||
link: `/blog/${post.slug}/`,
|
||||
})),
|
||||
});
|
||||
}
|
12
src/styles/global.css
vendored
12
src/styles/global.css
vendored
|
@ -1,12 +0,0 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
container,
|
||||
body {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
position: fixed;
|
||||
}
|
14
stylegen/base.css
vendored
Normal file
14
stylegen/base.css
vendored
Normal 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;
|
||||
}
|
||||
}
|
85
stylegen/gen.sh
Executable file
85
stylegen/gen.sh
Executable file
|
@ -0,0 +1,85 @@
|
|||
#!/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
|
||||
|
||||
# For macOS, we use a single binary called 'macos'
|
||||
if [ "$OS" = "macos" ]; then
|
||||
BINARY="./tw/macos"
|
||||
else
|
||||
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
|
||||
fi
|
||||
|
||||
echo $BINARY
|
||||
|
||||
# Infer pages from .html files in the pages directory
|
||||
PAGES=$(ls ../pages/templates/*.html | xargs -n 1 basename | cut -d. -f1)
|
||||
|
||||
# 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
stylegen/tw/linux-arm64
Normal file
BIN
stylegen/tw/linux-arm64
Normal file
Binary file not shown.
BIN
stylegen/tw/linux-x64
Normal file
BIN
stylegen/tw/linux-x64
Normal file
Binary file not shown.
BIN
stylegen/tw/macos
Executable file
BIN
stylegen/tw/macos
Executable file
Binary file not shown.
BIN
stylegen/tw/windows-arm64.exe
Normal file
BIN
stylegen/tw/windows-arm64.exe
Normal file
Binary file not shown.
BIN
stylegen/tw/windows-x64.exe
Normal file
BIN
stylegen/tw/windows-x64.exe
Normal file
Binary file not shown.
|
@ -1,8 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography"), require("daisyui")],
|
||||
};
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
78
webhooks/clerk.go
Normal file
78
webhooks/clerk.go
Normal 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!")
|
||||
}
|
Loading…
Add table
Reference in a new issue