Compare commits

121 Commits

Author SHA1 Message Date
a8d1b879be Changes to docker configuration to decrease build time
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1m35s
2026-03-10 12:07:22 +00:00
f82389225c Changes to docker configuration to decrease build time 2026-03-10 12:07:13 +00:00
165852e738 Remove extra attach and rename bottom button
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m18s
2026-03-10 12:02:14 +00:00
c58c19cc1e Make links clickable
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m49s
2026-03-10 11:58:06 +00:00
26ea0108e0 Add scroll to bottom on chat
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-10 11:56:52 +00:00
604576b46a Only show attach button if user is admin
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m4s
2026-03-09 18:11:03 +00:00
33d72fd20a Fix side scrolling for iphones
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m1s
2026-03-09 18:03:19 +00:00
d3cbc687d5 Fix sidebar on first breakpoint
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m55s
2026-03-09 18:00:51 +00:00
d7b76e4742 open port 3000 for gitea runner
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2m36s
2026-03-09 17:55:10 +00:00
64c2ba5562 Fix page height on first breakpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:50:49 +00:00
6796367dbe Fix page height on first breakpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:40:00 +00:00
c2580c984d Allow chat to get videos
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:30:54 +00:00
68db930049 Don't use SaveUploadedFile (causing permission issues)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:21:26 +00:00
63da086da2 Removed setting own permissions, let dockerfile entryhost do it
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:10:24 +00:00
6326a438dc Halftone + mask reduces performance alot, change background
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:56:20 +00:00
7c980f1b1f Fix file permissions, still
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:53:45 +00:00
141ceab7e6 Reduce performance lost on large screens
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:41:55 +00:00
d03f9668ad Add error handling 2026-03-09 16:41:38 +00:00
41d6cf0dac omg fix undefined variable
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:29:56 +00:00
1e3c6adf5e Fix file permissions on image upload
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:23:44 +00:00
99ddd7d494 Fix file permissions
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:20:47 +00:00
8e50537333 Get AI to fix vunerabilities in site
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 14:12:29 +00:00
85a2325683 change file permissions to /uploads
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m0s
2026-03-09 13:59:59 +00:00
0a8a752433 Add file upload to website and integrate into chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m12s
2026-03-09 13:47:45 +00:00
4c396ef30f Add file upload to website and integrate into chat
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 13:47:38 +00:00
77e2c272cb Update mobile layout adjustments
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m53s
2026-03-09 13:06:17 +00:00
1578a05762 Remove extra whitespace on CV.vue and stopped height becoming 0 on image transitions
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-03-09 12:07:05 +00:00
a6bc1d5126 Add transcript to site
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-03-09 11:58:44 +00:00
2737b4f0d0 Avoid panic on spotify if not authed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m35s
2026-03-07 17:46:55 +00:00
9fa953c969 Add local dev mode with HTTP-only nginx and DB seeding)
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m11s
2026-03-07 17:36:54 +00:00
5a45f1f427 Revert "Update notes component to reflect obsidian notes"
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 28s
This reverts commit 4458844029.
2026-03-07 17:07:21 +00:00
4458844029 Update notes component to reflect obsidian notes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m54s
2026-03-07 17:04:23 +00:00
3200ef5bee make text size even larger
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m42s
2026-03-07 16:57:46 +00:00
0da6d3f0ed check duplicates before making claude request
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m31s
2026-03-07 16:51:11 +00:00
88ce32abeb make popup text size larger
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m45s
2026-03-07 16:46:03 +00:00
adcf1bda48 Check that paces are reasonable
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-07 16:43:08 +00:00
7450b5a624 Add gym chart to show rowing results
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m45s
2026-03-07 16:31:15 +00:00
ab2b0a1e3d Add padding to chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-06 16:09:14 +00:00
ff82b8bdf9 Change author ID colour
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m48s
2026-03-06 16:02:37 +00:00
1429a6a5cb Change author ID colour
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-06 16:01:44 +00:00
7a71484ecc Show author id in chat log
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m57s
2026-03-06 15:56:12 +00:00
e1563b55f4 remove todo make persistent chatlog from readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8s
2026-03-06 13:29:59 +00:00
4fbeabc3ae Make chat longer
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m43s
2026-03-05 21:57:54 +00:00
a83b98eb2b Make chat persistent across reboot
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m25s
2026-03-05 21:43:04 +00:00
5346b24999 Make chat component autoscroll
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-03-05 21:31:00 +00:00
3779a1cbcc Add important todo to site
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4s
2026-03-05 20:38:24 +00:00
3f39f6327c Lookmaxing
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m54s
2026-03-05 20:32:14 +00:00
9dc9a3a063 Pose max message limit on chat function so no crash ^_^
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m30s
2026-03-05 20:07:08 +00:00
a6b543cf65 Make chat component look nicer
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m36s
2026-03-05 19:58:07 +00:00
4a65836210 Make chat component look nicer and upgrade websocket connection
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-05 19:51:33 +00:00
95635c86b3 Fix up live chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m26s
2026-03-05 19:14:05 +00:00
3056b23b50 nicer name
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-03-05 19:04:50 +00:00
72013f5cdd update readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-03-05 19:03:40 +00:00
7aa62659e5 remove silly background
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m35s
2026-03-05 17:35:25 +00:00
aa3f0a189d refactor slideshow code to its component and added Miku & miku background
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m42s
2026-03-05 13:31:28 +00:00
646f93136d update rowing information to non fricken nanoseconds who though time.Durations should be nanoseconds
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m45s
2026-03-04 16:48:21 +00:00
54852eba82 Update CV
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-04 16:21:30 +00:00
e43c07b30a more verbose error response
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m17s
2026-03-04 16:10:52 +00:00
190bc6076b remove json boilerplate, log error and return response
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m45s
2026-03-04 15:58:14 +00:00
88884121ab allow multiple files to be uploaded to rowing endpoint
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m51s
2026-03-04 15:26:17 +00:00
e25fc5f1d1 implement create rowing component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m39s
2026-03-04 14:45:50 +00:00
5bcc65668e move admin component files and add rowing component 2026-03-04 14:40:56 +00:00
2c1ecce99a Merge remote-tracking branch 'gitea/rowing'
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m48s
2026-03-04 14:32:34 +00:00
f2ba3494b1 remove no slip
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m35s
2026-03-03 10:32:38 +00:00
d56bd5783d eliminate the wretched hiding console.log, filthy log......
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m20s
2026-02-24 22:48:13 +00:00
f60636942f eliminate the wretched hiding console.log, filthy log......
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m31s
2026-02-24 22:40:55 +00:00
b087172bb1 slowed down the whole animation industry
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-02-24 22:31:54 +00:00
0c93c6bc27 make colors match
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m41s
2026-02-23 16:16:09 +00:00
48ae2f59ea adding important context
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m37s
2026-02-23 16:06:05 +00:00
c9faa90abd adding important context
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m43s
2026-02-23 16:03:52 +00:00
ef78974744 stamps width fix, also screw mobile even more
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m39s
2026-02-23 15:56:52 +00:00
49499052b0 add link to feed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m49s
2026-02-23 15:51:56 +00:00
dbb4914745 fix mobile, nobody likes mobile......... hate mobile
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-02-23 15:49:04 +00:00
34fa96ddab adding git feed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
2026-02-23 15:45:46 +00:00
8a9f3c373d Update links
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m27s
2026-02-23 14:23:43 +00:00
dc05ade798 update readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m29s
2026-02-23 14:15:32 +00:00
1e47919a40 update readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-23 13:23:50 +00:00
8e9734fca7 update cv
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m27s
2026-02-23 13:14:41 +00:00
da9a083f2d eddy fix
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m34s
2026-02-21 20:06:59 +00:00
3c40eb9f08 I should seriously look at what I'm commiting before I commit it.... >_< dw I won't do this in professional context
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m26s
2026-02-20 16:28:16 +00:00
e016e3af46 fix infinite loop :( shouldn't have existed, silly programmer
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 50s
2026-02-20 16:26:22 +00:00
0c91f512b4 adding more detail to cv
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m23s
2026-02-20 16:24:15 +00:00
f63b61431b maybe bedroom is not professional
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
2026-02-20 16:22:39 +00:00
f3ea83c477 changed cv
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-02-20 16:13:01 +00:00
4b5ed4787a center text and fullwidth button
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-02-19 21:41:49 +00:00
747a403bcb idk what this changes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-18 21:45:17 +00:00
fe16ccab97 add ignore to webpack future content
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
2026-02-18 21:43:51 +00:00
7bcb485fc6 move radio and add to homepage
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-02-18 21:42:37 +00:00
a3d73b12f4 Adding gitea link
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-02-18 21:29:30 +00:00
47a8e6c35e Adding gitea link
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m22s
2026-02-18 21:27:43 +00:00
f885ff9175 Adding gitea link
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m25s
2026-02-18 21:22:41 +00:00
d574fa7692 change intro
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m20s
2026-02-18 21:13:32 +00:00
ac171f7846 yes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
Merge branch 'main' of ssh://adam-french.co.uk:2222/adamf/web_server
2026-02-18 21:09:20 +00:00
b5b86a2a37 revert to old website intro 2026-02-18 21:08:33 +00:00
cfdb5b4d50 Update .gitea/workflows/deploy.yaml
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-18 16:38:06 +00:00
37580cdc42 Stop da haxxers
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-18 16:26:52 +00:00
711236b776 fix actions
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m35s
2026-02-18 16:21:52 +00:00
75454c2ed8 fix actions
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 5s
2026-02-18 16:19:58 +00:00
78c824c4c8 remove mountpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2m56s
2026-02-17 18:37:46 +00:00
ba3b933068 remove mountpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 5s
2026-02-17 18:36:23 +00:00
14c430bbad adding gitea actions
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2s
2026-02-17 18:34:55 +00:00
26c7422e34 adding gitea-runner service 2026-02-17 18:10:19 +00:00
470b1c79d8 fix ssh domain for gitea 2026-02-16 16:57:20 +00:00
d849b606ec fix certbot env vars 2026-02-16 15:07:44 +00:00
46a9da4c90 gitea runner 2026-02-16 14:52:33 +00:00
398a610cb2 fix root of gitea 2026-02-16 13:58:09 +00:00
b506bae515 fix gitea 2026-02-16 13:57:28 +00:00
11ad0b5a83 reverse proxy to gitea 2026-02-16 13:21:11 +00:00
d7393e1419 Revert "Revert "idk what I changed""
This reverts commit 0d32333c0c.
2026-02-16 13:03:13 +00:00
0d32333c0c Revert "idk what I changed"
This reverts commit 7dc3f49273.
2026-02-16 13:02:31 +00:00
050a38a76f Merge branch 'main' of /home/adamf/repos/web_server 2026-02-16 12:56:23 +00:00
bc43e9ed02 add gitea 2026-02-16 12:52:08 +00:00
75b8b02825 ignore gitea volume 2026-02-16 12:51:48 +00:00
5c69a1d0a7 adding gitea 2026-02-16 11:46:49 +00:00
aa915e1071 Merge branch 'main' of 192.168.178.24:~/repos/web_server
ye
2026-02-14 22:03:47 +00:00
7dc3f49273 idk what I changed 2026-02-14 22:03:32 +00:00
21d3997a16 remove ts 2026-02-10 17:04:52 +00:00
c56ba217dd remove tsconfig 2026-02-10 17:01:26 +00:00
91804f1fe7 adding vueuse 2026-02-10 17:00:59 +00:00
7e74ce5a2a adding typescript 2026-02-10 16:57:00 +00:00
e92ac49140 new components 2026-02-10 16:46:49 +00:00
65 changed files with 3652 additions and 1165 deletions

View File

@@ -0,0 +1,19 @@
name: Deploy with Docker Compose
on:
push:
branches: [main]
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Pull changes
working-directory: /home/adamf/deploy/web_server
run: git pull gitea main
- name: Run docker compose up
working-directory: /home/adamf/deploy/web_server
env:
DOCKER_API_VERSION: "1.41"
run: docker compose up -d --build --remove-orphans

6
.gitignore vendored
View File

@@ -3,6 +3,12 @@ certbot/www
backend/token/
.env
gitea/data/*
gitea-runner/data/*
# Will add in future (webpack)
nginx/vue/crates/
# Logs
logs
*.log

View File

@@ -1,7 +1,6 @@
package handlers
import (
"fmt"
"net/http"
"adam-french.co.uk/backend/models"
@@ -68,10 +67,9 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
claims, err := store.Auth.VerifyJWT(refreshToken)
if err != nil {
ctx.JSON(http.StatusUnauthorized, err.Error())
return
}
fmt.Printf("claims: %v\n", claims)
userIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
@@ -93,6 +91,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
tokens.AccessToken,
@@ -122,12 +121,12 @@ func (store *Store) Login(ctx *gin.Context) {
user := models.User{}
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
ctx.JSON(http.StatusNotFound, err.Error())
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
ctx.JSON(http.StatusUnauthorized, err.Error())
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
@@ -137,6 +136,7 @@ func (store *Store) Login(ctx *gin.Context) {
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
tokens.AccessToken,
@@ -164,6 +164,7 @@ func (store *Store) Logout(ctx *gin.Context) {
}
func removeCookies(ctx *gin.Context) {
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
"",

View File

@@ -0,0 +1,97 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
var allowedExtensions = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
".mp4": true, ".webm": true, ".mp3": true, ".ogg": true,
".pdf": true, ".txt": true,
}
var extensionToMIMEPrefix = map[string]string{
".jpg": "image/", ".jpeg": "image/", ".png": "image/", ".gif": "image/", ".webp": "image/",
".mp4": "video/", ".webm": "video/",
".pdf": "application/pdf", ".txt": "text/",
}
func (store *Store) UploadMessageFile(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
const maxSize = 10 << 20 // 10MB
if file.Size > maxSize {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file too large"})
return
}
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedExtensions[ext] {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
return
}
// Validate actual content type matches extension
f, err := file.Open()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
buf := make([]byte, 512)
n, err := f.Read(buf)
f.Close()
if err != nil && n == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "failed to read file content"})
return
}
detectedType := http.DetectContentType(buf[:n])
expectedPrefix, ok := extensionToMIMEPrefix[ext]
if ok && !strings.HasPrefix(detectedType, expectedPrefix) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match extension"})
return
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
return
}
filename := hex.EncodeToString(b) + ext
uploadDir := "/backend/uploads/"
dest := filepath.Join(uploadDir, filename)
src, err := file.Open()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
defer src.Close()
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
defer out.Close()
if _, err := io.Copy(out, src); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
ctx.JSON(http.StatusOK, gin.H{"url": "/uploads/" + filename})
}

View File

@@ -6,7 +6,7 @@ import (
"encoding/json"
"io"
"net/http"
"time"
"strings"
"github.com/rwcarlsen/goexif/exif"
@@ -16,9 +16,9 @@ import (
)
type ExtractedRowingData struct {
TimeMinutes float64 `json:"timeMinutes"`
TimeSeconds float64 `json:"timeSeconds"`
Distance float64 `json:"distance"`
TimeMinutes uint64 `json:"timeMinutes"`
TimeSeconds uint64 `json:"timeSeconds"`
Distance uint64 `json:"distance"`
}
func (store *Store) GetRowing(ctx *gin.Context) {
@@ -82,6 +82,13 @@ func (store *Store) CreateRowing(ctx *gin.Context) {
}
encoded := base64.StdEncoding.EncodeToString(data)
// Reject duplicates: same EXIF datetime already recorded
var existing models.Rowing
if err := store.DB.Where("date = ?", dateTaken).First(&existing).Error; err == nil {
ctx.JSON(http.StatusConflict, gin.H{"error": "duplicate entry for this date"})
return
}
// Build the message with an image + text prompt
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeHaiku4_5,
@@ -110,21 +117,38 @@ No text, no markdown, no explanation. Just the JSON object.`),
},
},
})
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"})
ctx.JSON(http.StatusInternalServerError, gin.H{
"raw": message.Content[0].Text,
"error": err.Error(),
})
return
}
if len(message.Content) == 0 {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"})
ctx.JSON(http.StatusInternalServerError, gin.H{
"raw": message.Content[0].Text,
"error": "empty response from Claude",
})
return
}
extractedData := ExtractedRowingData{}
err = json.Unmarshal([]byte(message.Content[0].Text), &extractedData)
raw := message.Content[0].Text
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
err = json.Unmarshal([]byte(raw), &extractedData)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"})
ctx.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to parse JSON response",
"detail": err.Error(),
"raw": raw,
})
return
}
@@ -134,15 +158,39 @@ No text, no markdown, no explanation. Just the JSON object.`),
}
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
totalDuration := time.Duration(totalSeconds * float64(time.Second))
per500m := time.Duration(totalSeconds / extractedData.Distance * 500 * float64(time.Second))
// Validate for anomalous values
const (
minDistance = 100 // metres
maxDistance = 100000 // metres
minTotalSecs = 30 // 30 seconds
maxTotalSecs = 7200 // 2 hours
minPacePer500m = 80 // ~1:20 /500m (faster than any human)
maxPacePer500m = 150 // ~2:30 /500m (slow, not important)
)
if extractedData.Distance < minDistance || extractedData.Distance > maxDistance {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous distance value"})
return
}
if totalSeconds < minTotalSecs || totalSeconds > maxTotalSecs {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous time value"})
return
}
per500m := float64(totalSeconds) / float64(extractedData.Distance) * 500.0
if per500m < minPacePer500m || per500m > maxPacePer500m {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous pace value"})
return
}
calories := float64(extractedData.Distance) / 7500.0 * 500.0
rowing := models.Rowing{
Date: dateTaken,
Time: totalDuration,
Time: totalSeconds,
TimePer500m: per500m,
Distance: extractedData.Distance,
Calories: extractedData.Distance / 7500.0 * 500.0,
Calories: calories,
}
if err := store.DB.Create(&rowing).Error; err != nil {

View File

@@ -53,10 +53,16 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
}
func (store *Store) RecentlyPlayed(ctx *gin.Context) {
if store.SpotifyClient == nil {
ctx.JSON(500, gin.H{"error": "Spotify not authenticated"})
return
}
opts := spotify.RecentlyPlayedOptions{Limit: 3}
if store.RecentSongsFresh() {
ctx.JSON(200, *store.RecentSongs)
return
}
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)

View File

@@ -15,6 +15,21 @@ type UserCredentials struct {
}
func (store *Store) CreateUser(ctx *gin.Context) {
claimsVal, ok := ctx.Get("userClaims")
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
return
}
if !(*claims)["admin"].(bool) {
ctx.JSON(http.StatusForbidden, gin.H{"error": "admin access required"})
return
}
var input UserCredentials
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error())
@@ -31,32 +46,9 @@ func (store *Store) CreateUser(ctx *gin.Context) {
tx := store.DB.Create(&user)
if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
}
// Generate JWT token
tokens, err := store.Auth.GenerateJWT(&user)
if err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error())
return
}
ctx.SetCookie(
"access_token",
tokens.AccessToken,
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
store.Auth.Config.Endpoint,
store.Auth.Config.Domain,
true, true,
)
ctx.SetCookie(
"refresh_token",
tokens.RefreshToken,
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
store.Auth.Config.Endpoint,
store.Auth.Config.Domain,
true, true,
)
ctx.JSON(http.StatusOK, user)
}
@@ -141,6 +133,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) {
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
"",

View File

@@ -14,9 +14,8 @@ import (
)
func main() {
logsDir := "/backend/logs"
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
panic(err)
}
@@ -39,6 +38,11 @@ func main() {
if err != nil {
log.Fatal(err)
}
if os.Getenv("SEED_DB") == "true" {
services.SeedDatabase(db)
}
domainName := os.Getenv("DOMAIN")
services.InitWebSocket(db, domainName)
// SPOTIFY
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
@@ -54,7 +58,6 @@ func main() {
claudeClient := services.InitClaude(&claudeConfig)
authSecret := os.Getenv("BACKEND_SECRET")
domainName := os.Getenv("DOMAIN")
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
accessTokenLifetime := 24 * time.Hour
refreshTokenLifetime := 365 * 24 * time.Hour
@@ -93,7 +96,7 @@ func main() {
protected.PUT("/user/:id", store.UpdateUser)
protected.DELETE("/user/:id", store.DeleteUser)
r.GET("/user", store.GetUsers)
r.POST("/user", store.CreateUser)
protected.POST("/user", store.CreateUser)
// AUTH
r.POST("/auth/login", store.Login)
@@ -109,6 +112,7 @@ func main() {
// MESSAGES
r.GET("/ws", store.ConnectWebSocket)
protected.POST("/messages/upload", store.UploadMessageFile)
// NOTES
r.GET("/notes/*path", store.GetNoteFile)

View File

@@ -30,8 +30,8 @@ type Post struct {
type Message struct {
ID uint `gorm:"primarykey" json:"id"`
Content string `json:"text"`
AuthorID uint `json:"-"`
Author *User `gorm:"foreignKey:AuthorID" json:"author"`
AuthorID uint `json:"authorId"`
FileURL string `json:"fileUrl,omitempty"`
CreatedAt time.Time `json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
}
@@ -61,8 +61,8 @@ type Rowing struct {
CreatedAt time.Time `json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
Date time.Time `json:"date"`
Time time.Duration `json:"time"`
TimePer500m time.Duration `json:"timePer500m"`
Distance float64 `json:"distance"`
Time uint64 `json:"time"`
Distance uint64 `json:"distance"`
TimePer500m float64 `json:"timePer500m"`
Calories float64 `json:"calories"`
}

View File

@@ -37,6 +37,7 @@ func migrateDatabase(db *gorm.DB) error {
&models.Activity{},
&models.Favorite{},
&models.Rowing{},
&models.Message{},
)
if err != nil {
return err

65
backend/services/seed.go Normal file
View File

@@ -0,0 +1,65 @@
package services
import (
"log"
"time"
"adam-french.co.uk/backend/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func SeedDatabase(db *gorm.DB) {
var user models.User
if db.First(&user).Error == nil {
log.Println("Database already has data, skipping seed")
return
}
log.Println("Seeding database with test data...")
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
if err != nil {
log.Fatal("Failed to hash seed password:", err)
}
testUser := models.User{
Username: "testuser",
Password: hashedPassword,
Admin: true,
}
db.Create(&testUser)
posts := []models.Post{
{Title: "Welcome to my blog", Content: "This is the first test post with some example content.", AuthorID: testUser.ID},
{Title: "Learning Go", Content: "Go is a great language for building web servers and APIs.", AuthorID: testUser.ID},
{Title: "Vue 3 Tips", Content: "The composition API makes Vue components much more flexible.", AuthorID: testUser.ID},
}
db.Create(&posts)
link1 := "https://example.com/project"
link2 := "https://example.com/book"
activities := []models.Activity{
{Type: "project", Name: "coding"},
{Type: "hobby", Name: "reading", Link: &link1},
{Type: "fitness", Name: "exercise"},
}
db.Create(&activities)
favorites := []models.Favorite{
{Type: "language", Name: "Go"},
{Type: "book", Name: "Designing Data-Intensive Applications", Link: &link2},
{Type: "framework", Name: "Vue"},
}
db.Create(&favorites)
now := time.Now()
rowingEntries := []models.Rowing{
{Date: now.AddDate(0, 0, -14), Time: 1800, Distance: 5000, TimePer500m: 120.0, Calories: 300},
{Date: now.AddDate(0, 0, -7), Time: 1750, Distance: 5200, TimePer500m: 118.5, Calories: 315},
{Date: now, Time: 1700, Distance: 5400, TimePer500m: 116.2, Calories: 330},
}
db.Create(&rowingEntries)
log.Println("Database seeded successfully")
}

View File

@@ -1,33 +1,64 @@
package services
import (
"net/http"
"strings"
"sync"
"time"
"adam-french.co.uk/backend/models"
"gorm.io/gorm"
"github.com/gorilla/websocket"
)
const maxMessages = 50
var allowedDomain string
var Upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
origin = strings.TrimPrefix(origin, "https://")
origin = strings.TrimPrefix(origin, "http://")
return origin == allowedDomain || origin == "www."+allowedDomain
},
}
var (
clients = make(map[*websocket.Conn]bool)
messages = make([]models.Message, 0)
mu sync.Mutex
clients = make(map[*websocket.Conn]bool)
mu sync.Mutex
wsDB *gorm.DB
nextAuthorID uint
)
const (
rateLimitWindow = time.Second
rateLimitMaxMsgs = 10
)
func InitWebSocket(database *gorm.DB, domain string) {
wsDB = database
allowedDomain = domain
}
func HandleWebSocket(conn *websocket.Conn) {
defer conn.Close()
mu.Lock()
clients[conn] = true
nextAuthorID++
authorID := nextAuthorID
// Send existing message history to new client
for _, msg := range messages {
var history []models.Message
wsDB.Order("created_at ASC").Limit(maxMessages).Find(&history)
for _, msg := range history {
if err := conn.WriteJSON(msg); err != nil {
mu.Unlock()
return
@@ -35,17 +66,32 @@ func HandleWebSocket(conn *websocket.Conn) {
}
mu.Unlock()
msgCount := 0
windowStart := time.Now()
for {
var incoming models.Message
if err := conn.ReadJSON(&incoming); err != nil {
break
}
incoming.CreatedAt = time.Now()
now := time.Now()
if now.Sub(windowStart) > rateLimitWindow {
msgCount = 0
windowStart = now
}
msgCount++
if msgCount > rateLimitMaxMsgs {
continue
}
incoming.AuthorID = authorID
// Store and broadcast
mu.Lock()
messages = append(messages, incoming)
wsDB.Create(&incoming)
wsDB.Where("id NOT IN (?)",
wsDB.Model(&models.Message{}).Select("id").Order("created_at DESC").Limit(maxMessages),
).Delete(&models.Message{})
for client := range clients {
if err := client.WriteJSON(incoming); err != nil {
@@ -56,7 +102,6 @@ func HandleWebSocket(conn *websocket.Conn) {
mu.Unlock()
}
// Cleanup on disconnect
mu.Lock()
delete(clients, conn)
mu.Unlock()

10
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
nginx:
environment:
- DEV_MODE=true
- SEED_DB=true
ports:
- 80:80
certbot:
profiles:
- disabled

View File

@@ -1,78 +1,126 @@
networks:
app-network:
driver: bridge
app-network:
driver: bridge
volumes:
dbdata:
dbdata:
uploads:
services:
nginx:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: nginx
env_file: ./.env
restart: unless-stopped
depends_on:
- backend
- icecast2
networks:
- app-network
ports:
- 80:80
- 443:443
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
nginx:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: nginx
env_file: ./.env
restart: always
depends_on:
- backend
- icecast2
- gitea
networks:
- app-network
ports:
- 80:80
- 443:443
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
- uploads:/uploads
certbot:
image: certbot/certbot
container_name: certbot
volumes:
- ./certbot/entrypoint.sh:/entrypoint.sh
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: ["/entrypoint.sh"]
networks:
- app-network
certbot:
image: certbot/certbot
container_name: certbot
volumes:
- ./certbot/entrypoint.sh:/entrypoint.sh
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: ["/entrypoint.sh"]
env_file:
- .env
networks:
- app-network
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: "${BACKEND_HOST}"
restart: unless-stopped
depends_on:
- db
networks:
- app-network
env_file:
- ./.env
volumes:
- ./backend/token/:/backend/token
- ${OBSIDIAN_DIR}:/backend/notes
- ./logs:/backend/logs
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: "${BACKEND_HOST}"
restart: always
depends_on:
- db
networks:
- app-network
env_file:
- ./.env
volumes:
- ./backend/token/:/backend/token
- ${OBSIDIAN_DIR}:/backend/notes
- ./logs:/backend/logs
- uploads:/backend/uploads
db:
image: postgres:16
container_name: "${POSTGRES_HOST}"
restart: unless-stopped
env_file:
- ./.env
networks:
- app-network
volumes:
- dbdata:/var/lib/postgresql/data
db:
image: postgres:16
container_name: "${POSTGRES_HOST}"
restart: always
env_file:
- ./.env
networks:
- app-network
volumes:
- dbdata:/var/lib/postgresql/data
icecast2:
build:
context: ./icecast2
dockerfile: Dockerfile
container_name: "${ICECAST_HOST}"
restart: always
networks:
- app-network
env_file:
- ./.env
ports:
- "${ICECAST_PORT}:${ICECAST_PORT}"
icecast2:
build:
context: ./icecast2
dockerfile: Dockerfile
container_name: "${ICECAST_HOST}"
restart: always
networks:
- app-network
env_file:
- ./.env
gitea-runner:
image: gitea/act_runner:latest
container_name: "${GITEA_RUNNER_HOST}"
profiles:
- disabled
environment:
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
CONFIG_FILE: /config.yaml
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
GITEA_INSTANCE_URL: "http://${GITEA_HOST}:3000"
GITEA_RUNNER_LABELS: "self-hosted:host"
volumes:
- ./gitea-runner/config.yaml:/config.yaml
- ./gitea-runner/data:/data
- /var/run/docker.sock:/var/run/docker.sock # WARNING: Docker socket mount gives container host-level access. Runner is in 'disabled' profile to mitigate risk.
restart: unless-stopped
networks:
- app-network
gitea:
image: docker.gitea.com/gitea:1.25.4-rootless
container_name: "${GITEA_HOST}"
networks:
- app-network
environment:
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=${POSTGRES_HOST}
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
- GITEA__database__USER=${POSTGRES_USER}
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
- USER_UID=1000
- USER_GID=1000
restart: always
volumes:
- ./gitea/data:/var/lib/gitea
- ./gitea/config:/etc/gitea
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "2222:2222"
- "3000:3000"
depends_on:
- db

110
gitea-runner/config.yaml Normal file
View File

@@ -0,0 +1,110 @@
# Example configuration file, it's safe to copy this as the default config file without any modification.
# You don't have to copy this file to your instance,
# just run `./act_runner generate-config > config.yaml` to generate a config file.
log:
# The level of logging, can be trace, debug, info, warn, error, fatal
level: info
runner:
# Where to store the registration result.
file: .runner
# Execute how many tasks concurrently at the same time.
capacity: 1
# Extra environment variables to run jobs.
envs:
A_TEST_ENV_NAME_1: a_test_env_value_1
A_TEST_ENV_NAME_2: a_test_env_value_2
# Extra environment variables to run jobs from a file.
# It will be ignored if it's empty or the file doesn't exist.
env_file: .env
# The timeout for a job to be finished.
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
# So the job could be stopped by the Gitea instance if it's timeout is shorter than this.
timeout: 3h
# The timeout for the runner to wait for running jobs to finish when shutting down.
# Any running jobs that haven't finished after this timeout will be cancelled.
shutdown_timeout: 0s
# Whether skip verifying the TLS certificate of the Gitea instance.
insecure: false
# The timeout for fetching the job from the Gitea instance.
fetch_timeout: 5s
# The interval for fetching the job from the Gitea instance.
fetch_interval: 2s
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
# and github_mirror is not empty. In this case,
# it replaces https://github.com with the value here, which is useful for some special network environments.
github_mirror: ''
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
# Find more images provided by Gitea at https://gitea.com/docker.gitea.com/runner-images .
# If it's empty when registering, it will ask for inputting labels.
# If it's empty when execute `daemon`, will use labels in `.runner` file.
labels:
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
- "ubuntu-20.04:docker://docker.gitea.com/runner-images:ubuntu-20.04"
cache:
# Enable cache server to use actions/cache.
enabled: true
# The directory to store the cache data.
# If it's empty, the cache data will be stored in $HOME/.cache/actcache.
dir: ""
# The host of the cache server.
# It's not for the address to listen, but the address to connect from job containers.
# So 0.0.0.0 is a bad choice, leave it empty to detect automatically.
host: ""
# The port of the cache server.
# 0 means to use a random available port.
port: 0
# The external cache server URL. Valid only when enable is true.
# If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
# The URL should generally end with "/".
external_server: ""
container:
# Specifies the network to which the container will connect.
# Could be host, bridge or the name of a custom network.
# If it's empty, act_runner will create a network automatically.
network: ""
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
privileged: false
# And other options to be used when the container is started (eg, --add-host=my.gitea.url:host-gateway).
options:
# The parent directory of a job's working directory.
# NOTE: There is no need to add the first '/' of the path as act_runner will add it automatically.
# If the path starts with '/', the '/' will be trimmed.
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
# If it's empty, /workspace will be used.
workdir_parent:
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
# valid_volumes:
# - data
# - /src/*.json
# If you want to allow any volume, please use the following configuration:
# valid_volumes:
# - '**'
valid_volumes: []
# overrides the docker client host with the specified one.
# If it's empty, act_runner will find an available docker host automatically.
# If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
docker_host: ""
# Pull docker image(s) even if already present
force_pull: true
# Rebuild docker image(s) even if already present
force_rebuild: false
# Always require a reachable docker daemon, even if not required by act_runner
require_docker: false
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
docker_timeout: 0s
host:
# The parent directory of a job's working directory.
# If it's empty, $HOME/.cache/act/ will be used.
workdir_parent:

98
gitea/config/app.ini Normal file
View File

@@ -0,0 +1,98 @@
APP_NAME = Gitea: Git with a cup of tea
RUN_USER = git
RUN_MODE = prod
WORK_PATH = /var/lib/gitea
[repository]
ROOT = /var/lib/gitea/git/repositories
[repository.local]
LOCAL_COPY_PATH = /tmp/gitea/local-repo
[repository.upload]
TEMP_PATH = /tmp/gitea/uploads
[server]
APP_DATA_PATH = /var/lib/gitea
SSH_DOMAIN = adam-french.co.uk
HTTP_PORT = 3000
ROOT_URL = https://adam-french.co.uk/gitea/
DISABLE_SSH = false
; In rootless gitea container only internal ssh server is supported
START_SSH_SERVER = true
SSH_PORT = 2222
SSH_LISTEN_PORT = 2222
BUILTIN_SSH_SERVER_USER = git
LFS_START_SERVER = true
DOMAIN = stppi.local
LFS_JWT_SECRET = XHIJprS_aMv0tizioZpUD38GGqTtNMFXMz1R6LuPvjU
OFFLINE_MODE = true
[database]
PATH = /var/lib/gitea/data/gitea.db
DB_TYPE = postgres
HOST = db
NAME = gitea
USER = postgres
PASSWD = password
SCHEMA =
SSL_MODE = disable
LOG_SQL = false
[session]
PROVIDER_CONFIG = /var/lib/gitea/data/sessions
PROVIDER = file
[picture]
AVATAR_UPLOAD_PATH = /var/lib/gitea/data/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /var/lib/gitea/data/repo-avatars
[attachment]
PATH = /var/lib/gitea/data/attachments
[log]
ROOT_PATH = /var/lib/gitea/data/log
MODE = console
LEVEL = info
[security]
INSTALL_LOCK = true
SECRET_KEY =
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzEyNDMyMTd9.yHsgFcEwDNWmZebftpe8tpWRFa5aR5tkpQuVYybeVaY
PASSWORD_HASH_ALGO = pbkdf2
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
[lfs]
PATH = /var/lib/gitea/git/lfs
[mailer]
ENABLED = false
[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true
[cron.update_checker]
ENABLED = false
[repository.pull-request]
DEFAULT_MERGE_STYLE = merge
[repository.signing]
DEFAULT_TRUST_MODEL = committer
[oauth2]
JWT_SECRET = pYiwW8xxGi23gysl2pa-02Cf567Z5ERvR6DDFGIn2iQ

5
nginx/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
vue/node_modules
vue/.vite
vue/dist
**/.git
**/.DS_Store

View File

@@ -1,32 +1,20 @@
FROM nginx:latest
RUN rm -rf /etc/nginx/html/*
# Install dependencies needed to add NodeSource repo
RUN apt-get update && apt-get install -y \
curl \
build-essential \
git \
gettext-base \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js LTS + npm
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g npm@latest
# Stage 1: Build Vue app
FROM node:22-slim AS build
WORKDIR /app
COPY vue/package.json vue/package-lock.json ./
RUN npm ci
COPY vue/ ./
RUN npm install
RUN npm run build
RUN mkdir -p /etc/nginx/html \
&& cp -r ./dist/* /etc/nginx/html/
# Stage 2: Serve with nginx
FROM nginx:latest
RUN rm -rf /etc/nginx/html/* && \
apt-get update && apt-get install -y gettext-base && \
rm -rf /var/lib/apt/lists/*
COPY --from=build /app/dist /etc/nginx/html/
COPY nginx.conf.template /etc/nginx/nginx.conf.template
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.conf.template
COPY nginx_dev.conf.template /etc/nginx/nginx_dev.conf.template
COPY robots.txt /etc/nginx/html/robots.txt
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,16 +1,24 @@
#!/bin/sh
set -e
# Check if certificate exists
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
echo "Certificates found. Using production nginx config."
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT}' \
< /etc/nginx/nginx.conf.template \
> /etc/nginx/nginx.conf
# Check if dev mode, certificate exists, or setup mode
if [ "$DEV_MODE" = "true" ]; then
echo "Dev mode. Using HTTP-only nginx config."
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
</etc/nginx/nginx_dev.conf.template \
>/etc/nginx/nginx.conf
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
echo "Certificates found. Using production nginx config."
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
</etc/nginx/nginx.conf.template \
>/etc/nginx/nginx.conf
else
echo "Certificates NOT found. Using setup nginx config."
envsubst '${DOMAIN}' < /etc/nginx/nginx_setup.conf.template > /etc/nginx/nginx.conf
echo "Certificates NOT found. Using setup nginx config."
envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf
fi
# Ensure upload directory is traversable by nginx worker
chmod 755 /uploads 2>/dev/null || true
# Start nginx
nginx -g 'daemon off;'

View File

@@ -9,6 +9,12 @@ http {
server_tokens off;
charset utf-8;
client_max_body_size 10M;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
log_format compact
'$remote_addr "$request" $status rt=$request_time';
@@ -55,6 +61,13 @@ http {
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
location /uploads/ {
alias /uploads/;
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition "inline" always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
}
location / {
try_files $uri $uri/ /index.html;
}
@@ -77,7 +90,40 @@ http {
return 301 $BACKEND_ENDPOINT/;
}
location $BACKEND_ENDPOINT/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/auth/login {
limit_req zone=login burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/messages/upload {
limit_req zone=upload burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/ {
limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
@@ -98,6 +144,18 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /gitea {
return 301 /gitea/;
}
location /gitea/ {
proxy_pass http://$GITEA_HOST:$GITEA_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

View File

@@ -0,0 +1,127 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
charset utf-8;
client_max_body_size 10M;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
log_format compact
'$remote_addr "$request" $status rt=$request_time';
access_log /var/log/nginx/access.log compact;
types {
text/javascript mjs;
}
server {
listen 80;
server_name $DOMAIN www.$DOMAIN;
root /etc/nginx/html;
index index.html;
location /uploads/ {
alias /uploads/;
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition "inline" always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
}
location / {
try_files $uri $uri/ /index.html;
}
location = /robots.txt {
allow all;
log_not_found off;
access_log off;
}
location = /img/stamps/mine.gif {
add_header Access-Control-Allow-Origin *;
}
location $BACKEND_ENDPOINT {
return 301 $BACKEND_ENDPOINT/;
}
location $BACKEND_ENDPOINT/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/auth/login {
limit_req zone=login burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/messages/upload {
limit_req zone=upload burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/ {
limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /radio {
return 301 /radio/;
}
location /radio/ {
proxy_pass http://$ICECAST_HOST:$ICECAST_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /gitea {
return 301 /gitea/;
}
location /gitea/ {
proxy_pass http://$GITEA_HOST:$GITEA_PORT/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

View File

@@ -10,12 +10,14 @@
"dependencies": {
"@mdit/plugin-katex": "^0.24.1",
"@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.2",
"katex": "^0.16.27",
"markdown-it": "^14.1.0",
"markdown-it-wikilinks": "^1.4.0",
"pinia": "^3.0.4",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
@@ -1022,9 +1024,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -1035,9 +1037,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -1048,9 +1050,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -1061,9 +1063,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -1074,9 +1076,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -1087,9 +1089,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -1100,9 +1102,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -1113,9 +1115,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -1126,9 +1128,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -1139,9 +1141,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -1152,9 +1154,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -1165,9 +1167,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -1178,9 +1180,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@@ -1191,9 +1193,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -1204,9 +1206,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -1217,9 +1219,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -1230,9 +1232,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -1243,9 +1245,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -1256,9 +1258,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -1269,9 +1271,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@@ -1282,9 +1284,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -1295,9 +1297,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -1308,9 +1310,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -1321,9 +1323,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -1334,9 +1336,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -1631,6 +1633,12 @@
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT"
},
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
@@ -1916,6 +1924,44 @@
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
"license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.1",
"@vueuse/shared": "14.2.1"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/ansis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
@@ -1939,13 +1985,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@@ -2936,9 +2982,9 @@
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
@@ -3199,9 +3245,9 @@
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -3214,31 +3260,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.55.1",
"@rollup/rollup-android-arm64": "4.55.1",
"@rollup/rollup-darwin-arm64": "4.55.1",
"@rollup/rollup-darwin-x64": "4.55.1",
"@rollup/rollup-freebsd-arm64": "4.55.1",
"@rollup/rollup-freebsd-x64": "4.55.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
"@rollup/rollup-linux-arm64-musl": "4.55.1",
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
"@rollup/rollup-linux-loong64-musl": "4.55.1",
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
"@rollup/rollup-linux-x64-gnu": "4.55.1",
"@rollup/rollup-linux-x64-musl": "4.55.1",
"@rollup/rollup-openbsd-x64": "4.55.1",
"@rollup/rollup-openharmony-arm64": "4.55.1",
"@rollup/rollup-win32-arm64-msvc": "4.55.1",
"@rollup/rollup-win32-ia32-msvc": "4.55.1",
"@rollup/rollup-win32-x64-gnu": "4.55.1",
"@rollup/rollup-win32-x64-msvc": "4.55.1",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
@@ -3382,6 +3428,19 @@
"utf8-byte-length": "^1.0.1"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",

View File

@@ -14,12 +14,14 @@
"dependencies": {
"@mdit/plugin-katex": "^0.24.1",
"@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.2",
"katex": "^0.16.27",
"markdown-it": "^14.1.0",
"markdown-it-wikilinks": "^1.4.0",
"pinia": "^3.0.4",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

View File

@@ -2,6 +2,7 @@
/* PRINTING */
@media print {
.no-print,
.no-print * {
display: none !important;
@@ -11,6 +12,7 @@
height: 0px;
}
}
/* END OF PRINTING */
/* FONTS */
@@ -27,6 +29,7 @@
font-weight: normal;
font-style: normal;
}
/* END OF FONTS */
/* VARIABLES */
@@ -75,6 +78,7 @@
--font-heading: var(--font_heading);
--default-font-family: var(--font_default);
}
/* END OF VARIABLES */
/* ELEMENTS */
body {
@@ -118,9 +122,11 @@ h3,
h4 {
@apply text-lg;
}
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
}
@@ -130,7 +136,7 @@ p {
}
a {
@apply text-primary bg-link text-center font-heading text-xl tracking-wide;
@apply text-primary bg-link text-center font-heading tracking-wide;
}
input,
@@ -219,18 +225,24 @@ td {
/* PHONE */
@media (max-width: 850px) {
.a4page-portrait {
width: 100%; /* fill mobile width */
width: 100%;
/* fill mobile width */
height: fit-content;
margin: 0 auto; /* center horizontally */
margin: 0 auto;
/* center horizontally */
box-sizing: border-box;
}
.a4page-landscape {
width: 100%; /* fill mobile width */
width: 100%;
/* fill mobile width */
height: fit-content;
margin: 0 auto; /* center horizontally */
margin: 0 auto;
/* center horizontally */
box-sizing: border-box;
}
}
@media (max-width: 600px) {
.a5page-portrait {
width: 100%;
@@ -238,6 +250,7 @@ td {
margin: 0 auto;
box-sizing: border-box;
}
.a5page-landscape {
width: 100%;
height: fit-content;
@@ -249,12 +262,15 @@ td {
.tl {
@apply absolute top-0 left-0;
}
.tr {
@apply absolute top-0 right-0;
}
.bl {
@apply absolute bottom-0 left-0;
}
.br {
@apply absolute bottom-0 right-0;
}
@@ -270,17 +286,9 @@ td {
--blur: 0%;
background-color: var(--bg_secondary);
background-image: radial-gradient(
circle at center,
var(--bg_primary) var(--dot_size),
transparent var(--blur)
);
background-image: radial-gradient(circle at center,
var(--bg_primary) var(--dot_size),
transparent var(--blur));
background-size: var(--bg_size) var(--bg_size);
background-position: 0 0;
mask-image: linear-gradient(
30deg,
rgba(1, 1, 1, 1) 0%,
rgba(1, 1, 1, 0.9) 100%
);
}

View File

@@ -49,14 +49,10 @@ const faces_string = faces.join(" ");
<RouterLink class="bdr-2 bg-bg_primary" to="/" v-if="!inHome">
<a>HOME</a>
</RouterLink>
<RouterLink
class="bdr-2 bg-bg_primary"
v-if="parentPath"
:to="parentPath"
>
<RouterLink class="bdr-2 bg-bg_primary" v-if="parentPath" :to="parentPath">
<a>UP</a>
</RouterLink>
<Headline class="border flex-1">
<Headline class="border flex-1 max-w-full">
<code class="whitespace-pre">{{ faces_string }}</code>
</Headline>
</nav>

View File

@@ -1,79 +1,85 @@
<script setup>
import { ref, onMounted, useTemplateRef, onUnmounted } from "vue";
import { onMounted, useTemplateRef, onUnmounted } from "vue";
const container = useTemplateRef("container");
const item1 = useTemplateRef("item1");
const item2 = useTemplateRef("item2");
let offset = 0;
let cachedWidth = 0;
let rafId;
const speed = 0.5; // pixels per frame
function animate() {
function measureWidth() {
const ctnr = container.value;
const it1 = item1.value;
const it2 = item2.value;
if (ctnr && it1) {
cachedWidth = Math.max(ctnr.offsetWidth, it1.scrollWidth);
}
}
const width = Math.max(ctnr.offsetWidth, it1.scrollWidth);
function animate() {
const ctnr = container.value;
if (!ctnr || cachedWidth === 0) {
rafId = requestAnimationFrame(animate);
return;
}
offset -= speed;
if (offset <= -width) {
offset += width;
if (offset <= -cachedWidth) {
offset += cachedWidth;
}
it1.style.transform = `translateX(${offset}px)`;
it2.style.transform = `translateX(${width + offset}px)`;
ctnr.style.transform = `translateX(${offset}px)`;
rafId = requestAnimationFrame(animate);
}
let resizeObserver;
onMounted(() => {
measureWidth();
rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureWidth);
resizeObserver.observe(container.value);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
});
</script>
<template>
<div class="marquee">
<div class="root">
<div class="container" ref="container">
<div class="item" ref="item1"><slot /></div>
<div class="item item2" ref="item2"><slot /></div>
<div ref="item1">
<slot />
</div>
<div>
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.marquee {
.root {
overflow: hidden;
width: 100%;
}
.container {
width: 100%;
height: fit-content;
position: relative;
will-change: transform;
}
.item {
height: fit-content;
top: 0px;
padding-right: 3em;
width: fit-content;
white-space: nowrap;
}
.item1 {
left: 0px;
}
.item2 {
position: absolute;
height: fit-content;
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
/* Each column fits its content */
overflow-x: visible;
will-change: transform;
gap: 10em;
}
</style>

View File

@@ -9,12 +9,19 @@ import { useTemplateRef, onMounted, onBeforeUnmount } from "vue";
const container = useTemplateRef("container");
const SPEED = 1; // px per frame
const SPEED = 0.0005; // % per frame
const PAUSE = 2000; // ms at top/bottom
let pos = 0;
let direction = 1; // 1 = down, -1 = up
let timeoutId;
let timeoutId2;
let cachedScrollHeight = 0;
function measureScrollHeight() {
const el = container.value;
if (el) cachedScrollHeight = el.scrollHeight;
}
function handleHover() {
cancelAnimationFrame(timeoutId);
@@ -27,25 +34,45 @@ function handleHover() {
function tick() {
const el = container.value;
el.scrollTop += SPEED * direction;
if (!el || cachedScrollHeight === 0) {
timeoutId = requestAnimationFrame(tick);
return;
}
const reachedBottom = el.scrollTop + el.clientHeight >= el.scrollHeight;
const reachedTop = el.scrollTop <= 0;
const reachedBottom = pos <= 0;
const reachedTop = pos >= 1;
if (reachedBottom || reachedTop) {
direction *= -1;
if (reachedBottom) {
pos = 0.001;
direction = 1;
handleHover();
return;
} else if (reachedTop) {
pos = 0.999;
direction = -1;
handleHover();
return;
}
pos += direction * SPEED;
el.scrollTop = pos * cachedScrollHeight;
timeoutId = requestAnimationFrame(tick);
}
let resizeObserver;
onMounted(() => {
measureScrollHeight();
timeoutId = requestAnimationFrame(tick);
resizeObserver = new ResizeObserver(measureScrollHeight);
resizeObserver.observe(container.value);
});
onBeforeUnmount(() => {
cancelAnimationFrame(timeoutId);
resizeObserver?.disconnect();
});
</script>

View File

@@ -1,9 +1,75 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
import Button from "@/components/input/Button.vue";
import { useMessagesStore } from "@/stores/messages";
import { useAuthStore } from "@/stores/auth";
import Header from "@/components/text/Header.vue";
const messagesStore = useMessagesStore();
const authStore = useAuthStore();
const messages = computed(() => messagesStore.messages);
const messageInput = ref("");
const messagesContainer = ref(null);
const fileInput = ref(null);
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop =
messagesContainer.value.scrollHeight;
}
});
}
watch(messages, scrollToBottom, { deep: true });
function sendMessage() {
const text = messageInput.value.trim();
if (!text) return;
messagesStore.sendMessage(text);
messageInput.value = "";
}
async function onFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
await messagesStore.uploadAndSendFile(file);
fileInput.value.value = "";
}
function isImageUrl(url) {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
}
function isVideoUrl(url) {
return /\.(mp4|webm|ogg|mov)$/i.test(url);
}
function isSafeFileUrl(url) {
return typeof url === "string" && url.startsWith("/uploads/");
}
const urlRegex = /(https?:\/\/[^\s<]+)/g;
function parseMessageParts(text) {
const parts = [];
let lastIndex = 0;
let match;
while ((match = urlRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push({
type: "text",
value: text.slice(lastIndex, match.index),
});
}
parts.push({ type: "link", value: match[1] });
lastIndex = urlRegex.lastIndex;
}
if (lastIndex < text.length) {
parts.push({ type: "text", value: text.slice(lastIndex) });
}
return parts;
}
onMounted(() => {
messagesStore.connect();
@@ -14,15 +80,67 @@ onUnmounted(() => {
</script>
<template>
<div>
<div class="flex flex-col">
<div class="flex flex-col">
<Header>Chat</Header>
<div
ref="messagesContainer"
class="flex flex-col flex-1 overflow-y-auto p-2"
>
<p v-for="message in messages" :key="message.id">
{{ message.content }}
<span class="text-tertiary">{{ message.authorId }}:</span>
<template
v-for="(part, i) in parseMessageParts(message.text || '')"
:key="i"
>
<a
v-if="part.type === 'link'"
:href="part.value"
target="_blank"
rel="noopener noreferrer"
class="text-primary underline"
>{{ part.value }}</a
>
<span v-else>{{ part.value }}</span>
</template>
<template
v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)"
>
<img
v-if="isImageUrl(message.fileUrl)"
:src="message.fileUrl"
class="max-w-xs max-h-48 rounded"
/>
<video
v-else-if="isVideoUrl(message.fileUrl)"
:src="message.fileUrl"
controls
class="max-w-xs max-h-48 rounded"
/>
<a
v-else
:href="message.fileUrl"
target="_blank"
class="underline"
>{{ message.fileUrl.split("/").pop() }}</a
>
</template>
</p>
</div>
<div class="flex flex-row">
<input v-model="messageInput" @keyup.enter="sendMessage" />
<input v-model="messageInput" @keyup.enter="sendMessage" />
<input
ref="fileInput"
type="file"
class="hidden"
@change="onFileSelected"
/>
<div class="flex justify-between">
<Button @click="sendMessage">Send</Button>
<Button v-if="authStore.user.admin" @click="fileInput.click()"
>Attach</Button
>
<div class="flex gap-2">
<Button @click="scrollToBottom">Bottom</Button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup>
import axios from "axios";
import { ref, onMounted } from "vue";
import Header from "@/components/text/Header.vue";
const url =
"/gitea/api/v1/users/adamf/activities/feeds?limit=1";
const feed = ref(null);
const isLoading = ref(true);
const hasError = ref(false);
async function checkFeed() {
try {
const res = await axios.get(url);
feed.value = res.data[0] || null;
hasError.value = false;
} catch (err) {
hasError.value = true;
} finally {
isLoading.value = false;
}
}
onMounted(() => {
checkFeed();
});
</script>
<template>
<div class="justify-center text-center">
<Header class="text-left">Commits</Header>
<div v-if="isLoading">
<p>Loading latest activity...</p>
</div>
<div v-else-if="hasError">
<p>Could not fetch feed. Please try again later.</p>
</div>
<div v-else-if="feed" class="flex-col justify-center flex">
<h3>Last git activity</h3>
<img
:src="feed.act_user.avatar_url"
alt="User avatar"
class="avatar"
/>
<a :href="feed.repo.html_url">
<h3>repo: {{ feed.repo.full_name }}</h3>
</a>
<p>Action: {{ feed.op_type }}</p>
<p>Message: {{ JSON.parse(feed.content).Commits[0].Message }}</p>
<small> {{ new Date(feed.created).toLocaleString() }}</small>
</div>
<div v-else>
<p>No activity found.</p>
</div>
</div>
</template>

View File

@@ -13,7 +13,7 @@ const keys = ["name", "link"];
<template>
<a v-for="(row, rowIndex) in linkArr" :key="rowIndex" :href="row.link">
<p class="bdr-2 bg-bg_primary">
<p class="bdr-2 bg-bg_tertiary">
{{ row.name }}
</p>
</a>

View File

@@ -1,19 +1,22 @@
<template>
<div v-if="streamLive">
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" />
<audio controls :src="streamUrl" ref="audio"></audio>
</div>
<div v-else>
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" />
<div class="m-1">
<p>Stream is offline. Tune in Fridays @ 6:00pm, Monday @ 8:00am</p>
<Button @click="checkStream()">Check Stream</Button>
<div class="m-1 text-center">
<p>Radio is offline. Message for info!</p>
<Button class="w-full" @click="checkStream()">Check Stream</Button>
</div>
</div>
</template>
<script setup>
import Button from "@/components/input/Button.vue";
import Header from "@/components/text/Header.vue";
import { ref, onMounted } from "vue";
import axios from "axios";

View File

@@ -0,0 +1,83 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import Header from "@/components/text/Header.vue";
const props = defineProps({
images: {
type: Array,
required: true,
},
interval: {
type: Number,
default: 10000,
},
});
const currentIndex = ref(0);
const currentComment = computed(() => props.images[currentIndex.value].comment);
const currentUrl = computed(() => props.images[currentIndex.value].url);
let nextId;
function nextImage() {
clearTimeout(nextId);
currentIndex.value = (currentIndex.value + 1) % props.images.length;
nextId = setTimeout(nextImage, props.interval);
}
onMounted(() => {
nextId = setTimeout(nextImage, props.interval);
});
onUnmounted(() => {
clearTimeout(nextId);
});
</script>
<template>
<div class="slideshow-wrapper">
<Transition name="fade">
<div class="image-viewer" @click="nextImage" :key="currentIndex">
<Header v-if="currentComment">
{{ currentComment }}
</Header>
<img :src="currentUrl" alt="Image Viewer" loading="lazy" />
</div>
</Transition>
</div>
</template>
<style scoped>
.slideshow-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.image-viewer {
width: 100%;
height: 100%;
overflow: hidden;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-leave-active {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -24,8 +24,8 @@ setInterval(updateDateTime, 60000);
</script>
<template>
<div class="items-center flex flex-col">
<div class="flex flex-col">
<Header>{{ weekday }} {{ day }}, {{ month }}</Header>
<h1>{{ time }}</h1>
<h1>{{ weekday }} {{ day }}, {{ month }}</h1>
</div>
</template>

View File

@@ -1,5 +1,7 @@
<script setup>
import Button from "@/components/input/Button.vue";
import Header from "@/components/text/Header.vue";
import { ref } from "vue";
const timer = ref(null);
@@ -64,7 +66,7 @@ function playFinishedSound() {
<template>
<div class="flex flex-col gap-1 p-1 items-center">
<h2 class="items-center">Timer</h2>
<Header>Timer</Header>
<div v-if="finished && paused" class="flex flex-col">
<div class="flex flex-row p-2 place-content-around">
<input

View File

@@ -2,64 +2,64 @@ import { createRouter, createWebHistory } from "vue-router";
import Home from "@/views/home/Home.vue";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: Home,
},
{
path: "/cv",
name: "cv",
component: () => import("../views/CV.vue"),
},
{
path: "/admin",
name: "admin",
component: () => import("../views/Admin.vue"),
},
{
path: "/bookmarks",
name: "bookmarks",
component: () => import("../views/Bookmarks.vue"),
},
{
path: "/notes/:path(.*)*",
name: "notes",
component: () => import("../views/Notes.vue"),
},
{
path: "/shrines",
name: "shrine links",
component: () => import("../views/Shrines.vue"),
},
{
path: "/shrines/gto",
name: "gto shrine",
component: () => import("../views/shrines/GTO.vue"),
},
{
path: "/shrines/skipskipbenben",
name: "skipskipbenben shrine",
component: () => import("../views/shrines/Skipskipbenben.vue"),
},
{
path: "/shrines/evangelion",
name: "evangelion shrine",
component: () => import("../views/shrines/Evangelion.vue"),
},
{
path: "/shrines/demoman",
name: "demoman shrine",
component: () => import("../views/shrines/Demoman.vue"),
},
{
path: "/:pathMatch(.*)*",
name: "404",
component: () => import("../views/404.vue"),
},
],
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: "/",
name: "home",
component: Home,
},
{
path: "/cv",
name: "cv",
component: () => import("../views/CV/CV.vue"),
},
{
path: "/admin",
name: "admin",
component: () => import("../views/admin/Admin.vue"),
},
{
path: "/bookmarks",
name: "bookmarks",
component: () => import("../views/Bookmarks.vue"),
},
{
path: "/notes/:path(.*)*",
name: "notes",
component: () => import("../views/Notes.vue"),
},
{
path: "/shrines",
name: "shrine links",
component: () => import("../views/Shrines.vue"),
},
{
path: "/shrines/gto",
name: "gto shrine",
component: () => import("../views/shrines/GTO.vue"),
},
{
path: "/shrines/skipskipbenben",
name: "skipskipbenben shrine",
component: () => import("../views/shrines/Skipskipbenben.vue"),
},
{
path: "/shrines/evangelion",
name: "evangelion shrine",
component: () => import("../views/shrines/Evangelion.vue"),
},
{
path: "/shrines/demoman",
name: "demoman shrine",
component: () => import("../views/shrines/Demoman.vue"),
},
{
path: "/:pathMatch(.*)*",
name: "404",
component: () => import("../views/404.vue"),
},
],
});
export default router;

View File

@@ -1,16 +1,15 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import axios from "axios";
const URL = "/api/ws";
const message_template = {
id: 0,
content: "Yo",
};
function getWebSocketURL() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${window.location.host}/api/ws`;
}
export const useMessagesStore = defineStore("messages", () => {
const socket = ref(null);
const messages = ref([message_template]);
const messages = ref([]);
const isConnected = ref(false);
const lastError = ref(null);
@@ -19,7 +18,7 @@ export const useMessagesStore = defineStore("messages", () => {
function connect() {
if (socket.value && isConnected.value) return;
socket.value = new WebSocket(URL);
socket.value = new WebSocket(getWebSocketURL());
socket.value.onopen = () => {
isConnected.value = true;
@@ -31,8 +30,7 @@ export const useMessagesStore = defineStore("messages", () => {
const data = JSON.parse(event.data);
messages.value.push(data);
} catch {
// fallback if server sends plain text
messages.value.push(event.data);
messages.value.push({ text: event.data });
}
};
@@ -53,15 +51,28 @@ export const useMessagesStore = defineStore("messages", () => {
isConnected.value = false;
}
function sendMessage(payload) {
function sendMessage(text) {
if (!socket.value || !isConnected.value) return;
socket.value.send(JSON.stringify(payload));
socket.value.send(JSON.stringify({ text }));
}
function clearMessages() {
messages.value = [];
}
async function uploadAndSendFile(file) {
try {
const formData = new FormData();
formData.append("file", file);
const res = await axios.post("/api/messages/upload", formData);
const { url } = res.data;
if (!socket.value || !isConnected.value) return;
socket.value.send(JSON.stringify({ text: "", fileUrl: url }));
} catch (err) {
lastError.value = err;
}
}
return {
messages,
isConnected,
@@ -73,5 +84,6 @@ export const useMessagesStore = defineStore("messages", () => {
disconnect,
sendMessage,
clearMessages,
uploadAndSendFile,
};
});

View File

@@ -1,603 +0,0 @@
<template>
<main>
<div class="a4page">
<div class="contact">
<h1>Adam French</h1>
<!-- <a href="covers.html"><img width=25 height=50 src="img/rune.png"></a> -->
<div class="contact-details">
<p>+447563266931</p>
<p>adam.a.french@outlook.com</p>
<p>www.adam-french.co.uk</p>
</div>
</div>
<h2>Profile</h2>
<p>
Recently graduated from the University of Leeds with a BSc
Computer Science with Mathematics (International) degree.
Currently self-studying and building projects aligned with the
type of roles I am seeking. I have a strong background across a
variety of programming languages and will be able to quickly get
on board with any codebase.
</p>
<p>
I am most keen to work for a company with altruistic values and
a focus on durable solutions. Looking forward to learning from
experts and collaborating with motivated individuals.
</p>
<h2>Personal Projects</h2>
<table>
<thead>
<tr>
<th>Project</th>
<th>Skills</th>
<th>Date</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>Personal Websites</td>
<td>Nginx, Vue, Postgres, Docker, Go, Python</td>
<td>Ongoing</td>
<td class="row-leftalign">
My personal site, Currently
<b>self hosted</b>
using <b>listed skills</b>. In the past, I have used
Svelte, React/Redux, SQLite, Rust and Deno.
</td>
</tr>
<tr>
<td>Computer Graphics</td>
<td>Rust, Linear Algebra, Multithreading</td>
<td>2023</td>
<td class="row-leftalign">
A multithreaded, recursive ray tracer implemented in
Rust.
</td>
</tr>
<tr>
<td>Mobile Automata</td>
<td>Mathematica, JS</td>
<td>2024</td>
<td class="row-leftalign">
Investigated properties of cellular automata by
observing emergent behaviors through custom
simulations.
</td>
</tr>
<tr>
<td>Arduino Programming & Circuits</td>
<td>C++, Soldering, Embedded Systems</td>
<td>2022 - 2025</td>
<td class="row-leftalign">
Created decorations using salvaged components from
discarded electronics.
</td>
</tr>
<tr>
<td>Memory Palace Website</td>
<td>TS, Rust, React, Redux, SQLite</td>
<td>2025</td>
<td class="row-leftalign">
Full-stack web application aiming to make the
memory palace memorization technique easy.
</td>
</tr>
<tr>
<td>3D Printing</td>
<td>FreeCAD</td>
<td>Ongoing</td>
<td class="row-leftalign">
Designing quality of life objects using FreeCAD and
printing with a BambuLab A1.
</td>
</tr>
</tbody>
</table>
<h2>Education</h2>
<table>
<thead>
<tr>
<th>Location</th>
<th>Date</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>The University of Leeds</td>
<td>
<!-- <div style="display: flex; flex-direction: column; align-items: center;"> -->
<!-- <span>2021</span> -->
<!-- <span>to</span> -->
<!-- <span>2025</span> -->
<!-- </div> -->
2021-2025
</td>
<td class="row-leftalign">
<strong
>BSc Computer Science with Mathematics
(International)</strong
><br />
<strong
>Average:
81.1%&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;&ensp;(First
Class Honours) </strong
><br />
<strong>Relevant Courses: </strong>
Procedural Programming, Object Oriented Programming,
Algorithms and Data Structures I & II, Databases,
Computer Processors, Compiler Design and
Construction, Formal Languages and Finite Automata,
Probability and Statistics I, Machine Learning,
Graph Algorithms & Complexity Theory
</td>
</tr>
<tr>
<td>The University of Waterloo</td>
<td>
<!-- <div style="display: flex; flex-direction: column; align-items: center;"> -->
<!-- <span>2023</span> -->
<!-- <span>to</span> -->
<!-- <span>2024</span> -->
<!-- </div> -->
2023-2024
</td>
<td class="row-leftalign">
<strong>Average: 74.5%</strong>
<br />
<strong>Relevant Courses:</strong>
Applied Cryptography, Introduction to Computer
Graphics, Introduction to Rings and Fields with
Applications<br /><br />
</td>
</tr>
</tbody>
</table>
</div>
<div class="a4page">
<h2>Experience</h2>
<table>
<thead>
<tr>
<th>Role</th>
<th>Location</th>
<th>Date</th>
<th>Duties</th>
</tr>
</thead>
<tbody>
<tr>
<td>Student</td>
<td>Wolfram Summer School</td>
<td>2024</td>
<td class="row-leftalign">
Designed and completed a time-constrained research
project exploring Mobile Automata and conditions for
computational reversibility. Communicated findings
through visualizations and presentations.
</td>
</tr>
<tr>
<td>Bartender, Waiter, Cashier</td>
<td>Hospitality Venues</td>
<td>2018-2023</td>
<td class="row-leftalign">
Delivered heartfelt customer service in various
fast-paced, high-pressure hospitality environments.
</td>
</tr>
</tbody>
</table>
<h2>Commitments</h2>
<table>
<thead>
<tr>
<th>Activity</th>
<th>Date</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<tr>
<td>Learning Mandarin</td>
<td>Ongoing</td>
<td class="row-leftalign">
Aiming to complete HSK 3 proficiency exam by
December 2026
</td>
</tr>
<!-- <tr> -->
<!-- <td>Cybersecurity Training</td> -->
<!-- <td>Ongoing</td> -->
<!-- <td class="row-leftalign"> -->
<!-- Using <em>pwn.college, tryhackme.com</em> to learn pentesting techniques.</td> -->
<!-- </tr> -->
<tr>
<td>Sports Activities</td>
<td>Ongoing</td>
<td class="row-leftalign">
Run weekly, active gym attendee, regularly go
hiking.
</td>
</tr>
<tr>
<td>meetup.com</td>
<td>Ongoing</td>
<td class="row-leftalign">
Attending various tech meetups and social events.
</td>
</tr>
<tr>
<td>Boardgames</td>
<td>Ongoing</td>
<td class="row-leftalign">
Meet up regularly to play the game
<i>Root</i>.
</td>
</tr>
<tr>
<td>Leetcode</td>
<td>Ongoing</td>
<td class="row-leftalign">
Do the leetcode daily challenge and hone in on
different programming languages.
</td>
</tr>
<tr>
<td>Construction and Landscaping</td>
<td>Ongoing</td>
<td class="row-leftalign">
Involved in building a house in Bulgaria.
</td>
</tr>
<tr>
<td>University of Waterloo Film Club</td>
<td>2023-2024</td>
<td class="row-leftalign">
Worked on student films <em>Moon King</em> and
<em>HAM</em>, available online.
</td>
</tr>
<tr>
<td>Socratica</td>
<td>2023-2024</td>
<td class="row-leftalign">
Worked with individuals exploring innovative tech.
</td>
</tr>
<tr>
<td>University of Leeds Hockey Club</td>
<td>2022-2023</td>
<td class="row-leftalign">
Played for the University of Leeds Hockey Club.
</td>
</tr>
<tr>
<td>Royal Air Force Air Cadets</td>
<td>2017-2020</td>
<td class="row-leftalign">
Achieved the role of Sergeant and Best Cadet"
award.
</td>
</tr>
</tbody>
</table>
<!-- <div class="interests"> -->
<!-- <table> -->
<!-- <tr><th>Personal qualities</th></tr> -->
<!-- <tr><td>Intuitive</td></tr> -->
<!-- <tr><td>Communicative</td></tr> -->
<!-- <tr><td>Adaptable</td></tr> -->
<!-- <tr><td>Versatile</td></tr> -->
<!-- <tr><td>Diligent</td></tr> -->
<!-- </table> -->
<!-- <table> -->
<!-- <tr><th>Interests</th></tr> -->
<!-- <tr><td>Neuroscience</td></tr> -->
<!-- <tr><td>Bouldering</td></tr> -->
<!-- <tr><td>Science Fiction</td></tr> -->
<!-- <tr><td>Mathematics</td></tr> -->
<!-- <tr><td>Hiking</td></tr> -->
<!-- </table> -->
<!-- <table> -->
<!-- <tr><th>Languages</th></tr> -->
<!-- <tr><td>Rust</td></tr> -->
<!-- <tr><td>HTML/JS</td></tr> -->
<!-- <tr><td>C/C++</td></tr> -->
<!-- <tr><td>React/Vue</td></tr> -->
<!-- <tr><td>Python</td></tr> -->
<!-- </table> -->
<!-- </div> -->
</div>
</main>
</template>
<style scoped>
/* Fonts */
/*@font-face {
font-family: "AldoTheApache";
src: url("/fonts/AldotheApache.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "RobotFont";
src: url("/fonts/Robot_Font.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "m12";
src: url("/fonts/m12.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}*/
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
/* Variables */
* {
/* Blue - Beige */
/* --primary: #153448;
--secondary: #3C5B6F;
--tertiary: #948979;
--quaternary: #f5bb78;
--background: #DFD0B8; */
/* Blue - Turqouise */
/* --primary: #161D6F;
--secondary: #0B2F9F;
--tertiary: #98DED9;
--quaternary: #C7FFD8;
--background: #C2EFD1; */
/* Red - Blue */
/* --primary: #ff204e; */
/* --secondary: #a0153e; */
/* --tertiary: #5d0341; */
/* --quaternary: #3a0e41; */
/* --background: #00224d; */
/* Blue - Brown */
/* --primary: #35374B; */
/* --secondary: #344955; */
/* --tertiary: #50727b; */
/* --quaternary: #78a083; */
/* --background: #c7b077; */
/* Black - White */
--primary: black;
--secondary: black;
--tertiary: black;
--quaternary: #cccccc;
--background: white;
/* Blue - White */
/* --primary: #201e43; */
/* --secondary: #134b70; */
/* --tertiary: #508c9b; */
/* --quaternary: #cceeee; */
/* --background: #eeeeee; */
--font-heading: big_noodle_titling;
--font-text: CreatoDisplay;
--font-size-text: 90%;
--font-size-heading: 2.5em;
--font-size-subheading: 1.5em;
--font-size-tableheading: 1.2em;
}
/* A5 Page */
.a5page {
/* overflow: scroll; */
display: flex;
flex-direction: column;
font-family: var(--font-text);
height: 148mm;
width: 210mm;
padding: 4mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.2);
border: solid 2px var(--primary);
}
/* A4 Page */
.a4page {
line-height: 1.6;
font-family: var(--font-text);
width: 210mm;
/* Standard A4 width */
height: 297mm;
/* Standard A4 height */
padding: 8mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary);
overflow: auto;
/* Enables scrolling when content exceeds height */
margin: auto auto;
/* Centers the page horizontally */
}
/* Component Styling */
main {
padding: 0px;
display: flex;
flex-direction: column;
height: fit-content;
}
span {
height: 2em;
}
h1,
h2,
h3,
h4 {
border: none;
color: var(--primary);
font-family: var(--font-heading);
text-transform: capitalize;
text-align: center;
}
h1 {
font-size: var(--font-size-heading);
}
h2 {
margin: 0px;
margin-bottom: 3px;
border-bottom: 1px solid var(--primary);
font-size: var(--font-size-subheading);
}
p {
color: var(--secondary);
font-size: var(--font-size-text);
margin-top: 0.3em;
margin-bottom: 0.5em;
}
table {
color: var(--secondary);
border-collapse: collapse;
border: 1px solid black;
}
td {
/* border: 2px solid var(--tertiary); */
color: var(--secondary);
border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px;
font-size: var(--font-size-text);
text-align: left;
}
th {
color: var(--secondary);
border: 2px solid var(--tertiary);
padding: 1px 0px 1px 7px;
font-family: var(--font-heading);
font-size: var(--font-size-tableheading);
background-color: var(--quaternary);
text-align: left;
}
a {
text-decoration: none;
color: inherit;
}
a:hover,
a:visited {
color: inherit;
}
/* Classes */
/* Cover Navigation (for ease of use) */
.cover-nav {
position: fixed;
top: 0.5vh;
/* Position the element at the top of the screen */
left: 80vw;
/* Position the element at the left of the screen */
border: 2px solid var(--tertiary);
width: 19.5vw;
/* Make the element span the width of the screen (optional) */
background-color: var(--background);
/* Set a background color to avoid overlap issues */
z-index: 1000;
/* Ensures the element is above other content */
}
.cover-nav td,
tr {
font-family: var(--font-text);
border: 0;
}
.cover-nav th {
text-align: center;
align-items: center;
border-bottom: 2px solid var(--tertiary);
}
@media print {
.no-print {
display: none !important;
}
}
/* Cover letter styling */
textarea {
width: 100%;
height: 100%;
padding: 10px;
box-sizing: border-box;
border: 0px solid var(--primary);
resize: none;
font-family: var(--font-text);
}
/* Contact At Top of Page */
.contact {
all: unset;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--primary);
}
.contact-details {
all: unset;
display: flex;
flex-direction: column;
text-align: right;
}
.contact-details p {
margin: 1px 0;
}
/* Interests and Skills at bottom of page */
.interests {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-top: solid 2px var(--primary);
}
.interests td,
tr,
th {
border: 0;
}
.row-leftalign {
/* background-image: url("https://www.fridakahlo.org/assets/img/paintings/without-hope.jpg"); */
text-align: left;
}
</style>

View File

@@ -0,0 +1,359 @@
<script setup>
import Project from "./Project.vue";
</script>
<template>
<main>
<div class="no-print w-full h-20">
</div>
<div class="a4page">
<div class="flex flex-row justify-between">
<h1 class="name">Adam French</h1>
<div class="contact-details text-right">
<p>+447563266931</p>
<p>adam.a.french@outlook.com</p>
<h4>
<a href="https://www.adam-french.co.uk">
www.adam-french.co.uk
</a>
</h4>
</div>
</div>
<h2>Profile</h2>
<p>
First Class Honours graduate in Computer Science with Mathematics
from the University of Leeds (81.1%), with a year abroad at the
University of Waterloo. Proficient in full-stack development,
systems programming, and CI/CD automation. Eager to contribute to
a collaborative engineering team, apply strong academic
foundations to real-world problems, and grow through hands-on
experience.
</p>
<h2>Skills</h2>
<div class="skills-grid">
<div><strong>Languages</strong><br /><small>Go, Rust, Python, JavaScript / TypeScript, SQL</small></div>
<div><strong>Frontend</strong><br /><small>Vue, React / Redux, Svelte, Tailwind CSS, WebAssembly</small></div>
<div><strong>Backend / Infra</strong><br /><small>Nginx, Docker, PostgreSQL, SQLite, JWT Auth, Git Actions</small></div>
</div>
<h2>Projects</h2>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
>
web_server.git
</a>
</h4>
</template>
<template v-slot:top>
<small>
Nginx, Vue, Postgres, Docker, Go, Python, Rust Wasm,
Git Actions, JWT Auth
</small>
<small>2025</small>
</template>
<p>
Self-hosted personal website with a fully automated CI/CD
pipeline. Iterated across diverse tech stacks including
Svelte, React/Redux, SQLite, Rust Actix, and Deno.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
>
tour.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust</small>
<small>2026</small>
</template>
<p>
CLI tool for building and navigating interactive code
tutorials, with version-traversal semantics inspired by Git.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
>
rust-raytracer.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust, Linear Algebra, Multithreading</small>
<small>2023</small>
</template>
<p>
Parallelised recursive ray tracer for realistic 3D rendering.
Emphasised algorithmic efficiency and low-level memory
management in Rust.
</p>
</Project>
<Project>
<template #left>
<h4>
<a
class="text-center w-full"
href="https://community.wolfram.com/groups/-/m/t/3210947"
>
Wolfram Summer School
</a>
</h4>
</template>
<template #top>
<small>Wolfram Mathematica</small>
<small>2024</small>
</template>
<p>
Research project on Mobile Automata with data visualisation
and academic presentation. Delivered within a tight deadline
in collaboration with academic mentors.
</p>
</Project>
<h2>Education</h2>
<div class="w-full h-fit flex-row flex gap-5">
<div class="flex-1 border-r border-dotted pr-3">
<h3>
<a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
University of Leeds
</a>
</h3>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>81.1% First Class Honours</small>
<small>20212025</small>
</div>
<small>BSc Computer Science with Mathematics (International)</small>
<ul>
<li>Algorithms & Data Structures I & II</li>
<li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li>
<li>Graph Algorithms & Complexity Theory</li>
<li>Machine Learning · Databases · Computer Processors</li>
<li>Probability and Statistics I</li>
</ul>
</div>
<div class="flex-1 pl-3">
<h3>University of Waterloo</h3>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>Year abroad</small>
<small>20232024</small>
</div>
<ul>
<li>Applied Cryptography</li>
<li>Introduction to Computer Graphics</li>
<li>Introduction to Rings and Fields with Applications</li>
</ul>
</div>
</div>
</div>
<div class="no-print w-full h-20">
</div>
<div class="a4page">
<div class="flex-1 pl-3">
<h2>Experience</h2>
<Project>
<template #left>
<p>Hospitality</p>
</template>
<template #top>
<small>Cashier, Bartender, Waiter</small>
<small>20182023</small>
</template>
<p>
Worked at <em>Belgrave Music Hall</em>,
<em>The Crown and Anchor</em>, and
<em>BFI Riverfront Kitchen</em>. Developed
communication, composure under pressure, and
reliability in customer-facing roles.
</p>
</Project>
<h2>Interests</h2>
<ul>
<li>Leetcode daily competitive problem solving</li>
<li>Learning Mandarin</li>
<li>Rhythm Games</li>
<li>Climbing · Gym</li>
<li>Board games · Meetup.com</li>
</ul>
</div>
</div>
<div class="no-print w-full h-20">
</div>
</main>
</template>
<style scoped>
/* Fonts */
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
/* Variables */
* {
--primary: black;
--secondary: #0000ff;
--tertiary: #ff0000;
--quaternary: #cccccc;
--background: white;
--font-heading: big_noodle_titling;
--font-text: CreatoDisplay;
--font-size-name: 2.5em;
--font-size-text: 100%;
--font-size-small: 0.9em;
--font-size-heading: 2.1em;
--font-size-subheading: 1.7em;
--font-size-subsubheading: 1.4em;
}
/* A4 Page */
.a4page {
line-height: 1.6;
font-family: var(--font-text);
width: 210mm;
height: 297mm;
padding: 5mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary);
overflow: hidden;
margin: auto auto;
}
/* Component Styling */
main {
padding: 0px;
display: flex;
flex-direction: column;
height: fit-content;
}
span {
height: 2em;
}
h1,
h2,
h3,
h4 {
border: none;
color: var(--primary);
font-family: var(--font-heading);
text-transform: capitalize;
}
h1 {
font-size: var(--font-size-heading);
}
h2 {
border-bottom: 1px solid var(--primary);
font-size: var(--font-size-subheading);
}
h3 {
font-size: var(--font-size-subsubheading);
}
a:hover {
color: var(--tertiary);
}
a {
background-color: transparent;
color: var(--secondary);
}
p {
margin-bottom: 0.2em;
color: var(--primary);
font-size: var(--font-size-text);
}
table {
color: var(--secondary);
border-collapse: collapse;
border: 1px solid black;
}
td {
color: var(--secondary);
border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px;
font-size: var(--font-size-text);
text-align: left;
}
th {
color: var(--secondary);
border: 2px solid var(--tertiary);
padding: 1px 0px 1px 7px;
font-family: var(--font-heading);
font-size: var(--font-size-subsubheading);
background-color: var(--quaternary);
text-align: left;
}
@media print {
.no-print {
display: none !important;
}
}
small {
font-size: var(--font-size-small);
color: var(--primary);
}
ul {
font-size: var(--font-size-small);
margin: 0;
padding-left: 1.2em;
}
li {
font-size: var(--font-size-small);
color: var(--primary);
}
.skills-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.3em 1em;
margin-bottom: 0.2em;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup></script>
<template>
<div class="flex-row flex">
<div class="w-2/7 p-5 m-auto">
<slot name="left" />
</div>
<div class="w-full p-2">
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<slot name="top" />
</div>
<slot />
</div>
</div>
</template>

View File

@@ -2,11 +2,12 @@
import { ref } from "vue";
import { useAuthStore } from "@/stores/auth";
import Login from "@/components/admin/Login.vue";
import CreateUser from "@/components/admin/CreateUser.vue";
import CreatePost from "@/components/admin/CreatePost.vue";
import CreateFavorite from "@/components/admin/CreateFavorite.vue";
import CreateActivity from "@/components/admin/CreateActivity.vue";
import Login from "./Login.vue";
import CreateUser from "./CreateUser.vue";
import CreatePost from "./CreatePost.vue";
import CreateFavorite from "./CreateFavorite.vue";
import CreateActivity from "./CreateActivity.vue";
import CreateRowing from "./CreateRowing.vue";
const auth = useAuthStore();
</script>
@@ -21,6 +22,7 @@ const auth = useAuthStore();
<CreatePost class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateFavorite class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateActivity class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
<CreateRowing class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
</div>
</main>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import axios from "axios";
const images = ref([]);
const results = ref([]);
function onFileChange(e) {
images.value = Array.from(e.target.files);
results.value = [];
}
async function submit() {
if (!images.value.length) return;
results.value = images.value.map((f) => ({ name: f.name, status: "Uploading..." }));
await Promise.all(
images.value.map(async (file, i) => {
const formData = new FormData();
formData.append("image", file);
try {
const res = await axios.post("/api/rowing", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
const mins = Math.floor(res.data.Time / 1e9 / 60);
const secs = String(Math.floor((res.data.Time / 1e9) % 60)).padStart(2, "0");
results.value[i].status = `${res.data.Distance}m in ${mins}:${secs}`;
results.value[i].ok = true;
} catch (err) {
results.value[i].status = err.response?.data?.error || "Upload failed";
results.value[i].ok = false;
}
})
);
images.value = [];
}
</script>
<template>
<div class="flex flex-col gap-2">
<h1>Create Rowing</h1>
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple @change="onFileChange" />
<Button @click="submit">Upload</Button>
<div v-for="r in results" :key="r.name">
<span>{{ r.name }}: </span>
<span :class="r.ok ? '' : 'text-red-500'">{{ r.status }}</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { useTemplateRef, ref, onMounted, onUnmounted } from 'vue';
const display = useTemplateRef('display')
const displayText = ref("");
const charHeight: number = 14;
const charWidth: number = charHeight * 0.6;
let n: number;
let m: number;
function setup() {
display.value.style.fontSize = `${charHeight}px`;
display.value.style.lineHeight = `${charHeight}px`;
fillDisplay()
}
function fillDisplay() {
// M rows N columns
m = Math.floor(display.value.offsetHeight / charHeight);
n = Math.floor(display.value.offsetWidth / charWidth);
const row = ' '.repeat(n);
displayText.value = (row + '\n').repeat(m - 1) + row
}
function close() {
displayText.value = ""
}
onMounted(() => {
setup()
})
onUnmounted(() => {
close()
})
</script>
<template>
<pre class="overflow-scroll w-full h-full bg-black text-white m-0 p-0" id="container" ref="display">{{ displayText
}}</pre>
</template>

View File

@@ -1,70 +1,15 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { Transition } from "vue";
import Header from "@/components/text/Header.vue";
import Slideshow from "@/components/util/Slideshow.vue";
const images = [
{ url: "/img/memes/pidgeon.gif", comment: "鸟" },
//{ url: "/img/memes/no_slip.png" },
//{ url: "/img/memes/epic.jpeg" },
{ url: "/img/bedroom/img2.png", comment: "办公桌" },
{ url: "/img/bedroom/img1.png", comment: "床" },
// { url: "/img/memes/no_slip.png" },
// { url: "/img/memes/epic.jpeg" },
// { url: "/img/bedroom/img2.png", comment: "办公桌" },
// { url: "/img/bedroom/img1.png", comment: "床" },
];
const currentIndex = ref(0);
const currentComment = computed(() => images[currentIndex.value].comment);
const currentUrl = computed(() => images[currentIndex.value].url);
let nextId;
function nextImage() {
clearTimeout(nextId);
let newIndex;
do {
newIndex = Math.floor(Math.random() * images.length);
} while (newIndex === currentIndex.value);
currentIndex.value = newIndex;
nextId = setTimeout(nextImage, 10000);
}
onMounted(() => {
nextId = setTimeout(nextImage, 10000);
});
onUnmounted(() => {
clearTimeout(nextId);
});
</script>
<template>
<Transition name="fade" mode="out-in">
<div class="image-viewer" @click="nextImage" :key="currentIndex">
<Header v-if="currentComment">
{{ currentComment }}
</Header>
<img :src="currentUrl" alt="Image Viewer" />
</div>
</Transition>
<Slideshow :images="images" />
</template>
<style scoped>
.image-viewer {
width: 100%;
overflow: hidden;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,270 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import axios from "axios";
import Header from "@/components/text/Header.vue";
const rows = ref([]);
const loading = ref(true);
const error = ref(null);
const metric = ref("distance");
const hovered = ref(null);
const METRICS = [
{ key: "distance", label: "Distance (m)", color: "#55ffbb" },
{ key: "timePer500m", label: "Pace /500m", color: "#ff579a" },
{ key: "calories", label: "Calories", color: "#62ff57" },
];
onMounted(async () => {
try {
const res = await axios.get("/api/rowing");
rows.value = res.data.slice().reverse(); // API returns DESC, reverse to chronological
} catch (e) {
error.value = e.message;
} finally {
loading.value = false;
}
});
const activeMetric = computed(() => METRICS.find((m) => m.key === metric.value));
// SVG layout constants
const W = 290;
const H = 120;
const PL = 46; // padding left
const PT = 8; // padding top
const PR = 8; // padding right
const PB = 28; // padding bottom
const PLOT_W = W - PL - PR;
const PLOT_H = H - PT - PB;
const chartData = computed(() =>
rows.value.map((r) => ({
date: new Date(r.date),
value: r[metric.value],
raw: r,
}))
);
const minVal = computed(() => Math.min(...chartData.value.map((d) => d.value)));
const maxVal = computed(() => Math.max(...chartData.value.map((d) => d.value)));
const points = computed(() => {
const data = chartData.value;
const n = data.length;
if (!n) return [];
const min = minVal.value;
const range = maxVal.value - min || 1;
return data.map((d, i) => ({
x: PL + (n <= 1 ? PLOT_W / 2 : (i / (n - 1)) * PLOT_W),
y: PT + PLOT_H - ((d.value - min) / range) * PLOT_H,
date: d.date,
value: d.value,
raw: d.raw,
}));
});
const polyline = computed(() => points.value.map((p) => `${p.x},${p.y}`).join(" "));
const xLabels = computed(() => {
const data = chartData.value;
const pts = points.value;
if (!data.length) return [];
const indices = new Set([0, Math.floor((data.length - 1) / 2), data.length - 1]);
return [...indices].map((i) => ({
x: pts[i].x,
label: data[i].date.toLocaleDateString("en-GB", { month: "short", day: "numeric" }),
}));
});
const yLabels = computed(() => {
const min = minVal.value;
const max = maxVal.value;
return [0, 0.5, 1].map((t) => {
const raw = Math.round(min + t * (max - min));
return {
y: PT + PLOT_H - t * PLOT_H,
label: metric.value === "timePer500m" ? formatTime(raw) : raw,
};
});
});
function formatTime(secs) {
const m = Math.floor(secs / 60);
const s = Math.round(secs % 60);
return `${m}:${String(s).padStart(2, "0")}`;
}
function formatValue(key, val) {
if (key === "timePer500m") return formatTime(val) + " /500m";
if (key === "distance") return val + " m";
if (key === "calories") return Math.round(val) + " kcal";
return val;
}
</script>
<template>
<div class="flex flex-col h-full overflow-hidden">
<Header>Rowing</Header>
<div v-if="loading" class="flex-1 flex items-center justify-center">
<p>Loading...</p>
</div>
<div v-else-if="error" class="flex-1 flex items-center justify-center">
<p class="text-tertiary text-xs">{{ error }}</p>
</div>
<div v-else class="flex flex-col flex-1 px-1 pb-1 gap-1 overflow-hidden">
<!-- Metric tabs -->
<div class="flex gap-1 pt-1">
<button
v-for="m in METRICS"
:key="m.key"
class="metric-btn text-xs px-2 py-0.5 font-heading border"
:style="{
borderColor: m.color,
color: metric === m.key ? '#1b110e' : m.color,
backgroundColor: metric === m.key ? m.color : 'transparent',
}"
@click="metric = m.key"
>
{{ m.label }}
</button>
</div>
<!-- SVG Chart -->
<div class="flex-1 relative">
<svg
:viewBox="`0 0 ${W} ${H}`"
width="100%"
height="100%"
preserveAspectRatio="none"
class="overflow-visible"
>
<!-- Grid lines -->
<line
v-for="yl in yLabels"
:key="yl.y"
:x1="PL"
:y1="yl.y"
:x2="W - PR"
:y2="yl.y"
stroke="var(--quaternary)"
stroke-width="0.5"
/>
<!-- Area fill -->
<polygon
v-if="points.length"
:points="`${PL},${PT + PLOT_H} ${polyline} ${W - PR},${PT + PLOT_H}`"
:fill="activeMetric.color"
fill-opacity="0.08"
/>
<!-- Line -->
<polyline
v-if="points.length"
:points="polyline"
:stroke="activeMetric.color"
stroke-width="1.5"
fill="none"
stroke-linejoin="round"
stroke-linecap="round"
/>
<!-- Data points -->
<circle
v-for="(p, i) in points"
:key="i"
:cx="p.x"
:cy="p.y"
:r="hovered === i ? 4 : 2"
:fill="activeMetric.color"
style="cursor: pointer"
@mouseenter="hovered = i"
@mouseleave="hovered = null"
/>
<!-- Y axis labels -->
<text
v-for="yl in yLabels"
:key="`y${yl.y}`"
:x="PL - 3"
:y="yl.y + 3"
text-anchor="end"
font-size="10"
fill="var(--primary)"
font-family="var(--font_heading)"
>{{ yl.label }}</text>
<!-- X axis labels -->
<text
v-for="xl in xLabels"
:key="`x${xl.x}`"
:x="xl.x"
:y="H - 4"
text-anchor="middle"
font-size="10"
fill="var(--primary)"
font-family="var(--font_heading)"
>{{ xl.label }}</text>
<!-- Axes -->
<line :x1="PL" :y1="PT" :x2="PL" :y2="PT + PLOT_H" stroke="var(--primary)" stroke-width="0.5" />
<line :x1="PL" :y1="PT + PLOT_H" :x2="W - PR" :y2="PT + PLOT_H" stroke="var(--primary)" stroke-width="0.5" />
<!-- Tooltip -->
<g v-if="hovered !== null && points[hovered]">
<rect
:x="Math.min(points[hovered].x + 4, W - 85)"
:y="points[hovered].y - 20"
width="82"
height="32"
fill="var(--bg_primary)"
:stroke="activeMetric.color"
stroke-width="0.5"
rx="1"
/>
<text
:x="Math.min(points[hovered].x + 7, W - 82)"
:y="points[hovered].y - 6"
font-size="12"
fill="var(--secondary)"
font-family="var(--font_heading)"
>{{ points[hovered].date.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "2-digit" }) }}</text>
<text
:x="Math.min(points[hovered].x + 7, W - 82)"
:y="points[hovered].y + 8"
font-size="14"
:fill="activeMetric.color"
font-family="var(--font_heading)"
>{{ formatValue(metric, points[hovered].value) }}</text>
</g>
</svg>
</div>
<!-- Summary stats -->
<div class="flex justify-between text-xs border-t border-quaternary pt-1">
<div class="flex flex-col items-center">
<span class="text-primary font-heading">{{ rows.length }}</span>
<span class="text-quaternary" style="font-size: 0.6rem">sessions</span>
</div>
<div class="flex flex-col items-center">
<span class="text-primary font-heading">{{ rows.reduce((s, r) => s + r.distance, 0).toLocaleString() }}m</span>
<span class="text-quaternary" style="font-size: 0.6rem">total dist</span>
</div>
<div class="flex flex-col items-center">
<span class="text-primary font-heading">{{ formatTime(rows.reduce((s, r) => s + r.timePer500m, 0) / (rows.length || 1)) }}</span>
<span class="text-quaternary" style="font-size: 0.6rem">avg pace</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.metric-btn {
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
letter-spacing: 0.03em;
}
</style>

View File

@@ -1,28 +1,46 @@
<script setup>
import Timer from "@/components/util/Timer.vue";
import Elle from "@/components/elle/Elle.vue";
import Time from "@/components/util/Time.vue";
import Radio from "@/components/util/Radio.vue";
import Elle from "@/components/elle/Elle.vue";
import Chat from "@/components/util/Chat.vue";
import MusicPlayer from "@/components/util/MusicPlayer.vue";
import CommitHistory from "@/components/util/CommitHistory.vue";
import Intro from "./Intro.vue";
import Intro2 from "./Intro2.vue";
import BadApple from "./BadApple.vue";
import Miku from "./Miku.vue";
import Stamps from "./Stamps.vue";
import Listening from "./Listening.vue";
import Links from "./Links.vue";
import Feed from "./Feed.vue";
import Collage from "./Collage.vue";
import Favorites from "./Favorites.vue";
import Gym from "./Gym.vue";
// import Gym from "./Gym.vue";
import Gym2 from "./Gym2.vue";
import Consumption from "./Consumption.vue";
import UtenaFrame from "@/components/borders/UtenaFrame.vue";
</script>
<template>
<main class="halftone justify-center flex flex-row w-full h-full">
<div class="h-fit flex flex-row">
<div class="a4page-portrait homeGrid relative bdr-1">
<Intro class="intro" />
<div class="outerWrap h-fit flex flex-row">
<div
class="sidebar flex-1 flex flex-col m-10 w-60 gap-2 place-content-between"
>
<div class="border-children background-children">
<Chat class="h-200" />
</div>
<div>
<Miku class="border-tertiary border bg-bg_secondary" />
</div>
</div>
<div
class="a4page-portrait homeGrid relative background-children border-children bdr-1"
>
<!-- <Intro class="intro" /> -->
<Intro2 class="intro" />
<!-- <BadApple class="intro" /> -->
<Listening class="listening" />
<Stamps class="stamps" />
<Feed class="feed" />
@@ -30,22 +48,28 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
<Collage class="collage" />
<Consumption class="consumption" />
<Favorites class="favorites" />
<Gym class="gym" />
<!-- <Gym class="gym" /> -->
<Gym2 class="gym" />
</div>
<div
class="sidebar border-quaternary place-content-between flex-1 flex flex-col m-10 w-60 border-2"
class="sidebar place-content-between flex-1 flex flex-col m-10 w-60"
>
<div class="flex flex-col flex-1">
<Time class="bg-bg_primary border-primary border-b" />
<Timer class="border-primary border-b bg-bg_primary" />
<div
class="flex flex-col background-children border-children flex-1 gap-2"
>
<Time />
<Timer />
<Radio />
<CommitHistory />
<!-- <Elle class="flex-1" /> -->
<!-- <Chat class="bdr-2 bg-bg_primary" /> -->
<!-- <MusicPlayer /> -->
</div>
<div>
<img
src="/img/memes/fire-woman.gif"
class="border-tertiary border"
loading="lazy"
/>
</div>
</div>
@@ -54,32 +78,74 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
</template>
<style scoped>
.homeGrid > * {
.border-children > * {
border: 2px solid var(--quaternary);
border-color: var(--quaternary);
}
.background-children > * {
background-color: var(--bg_primary);
}
.homeGrid {
display: grid;
grid-gap: 5px;
gap: 5px;
grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(10, 1fr);
}
@media (max-width: 850px) {
.homeGrid {
width: 100%;
display: flex;
@media (max-width: 1200px) {
.outerWrap {
flex-direction: column;
align-items: stretch;
}
.homeGrid {
order: -1;
width: 100%;
height: 350mm;
margin-inline: 0;
box-sizing: border-box;
}
.sidebar {
width: 100%;
margin: 5px 10px;
flex-direction: column;
align-items: center;
gap: 8px;
}
}
@media (max-width: 1200px) {
.tr,
.br,
@media (max-width: 850px) {
.homeGrid {
display: flex;
flex-direction: column;
height: auto;
}
.sidebar {
display: none;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.sidebar > * {
max-width: 400px;
width: 100%;
}
}
@media (max-width: 500px) {
main {
overflow-x: hidden;
}
.outerWrap {
max-width: 100vw;
}
.sidebar {
margin: 5px 0;
}
}
@@ -117,6 +183,7 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
grid-column: span 4;
grid-row: span 2;
}
.gym {
grid-column: span 3;
grid-row: span 2;
@@ -126,4 +193,10 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
grid-column: span 3;
grid-row: span 2;
}
.bg-random {
background-color: var(--bg_primary);
background-image: url("/img/miku/miku2.gif");
background-size: 10px 10px;
}
</style>

View File

@@ -4,34 +4,27 @@ import Paragraph from "@/components/text/Paragraph.vue";
</script>
<template>
<div
class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start"
>
<Header>Intro</Header>
<Paragraph>
Hi, I'm Adam, thank you for visiting my website. I'm currently a 20
something graduate looking for work. I like to game, listen to lots
of music and occasionally watch anime.
</Paragraph>
<Header>Getting around</Header>
<Paragraph>
Pages available can be traversed through links below. I am hoping to
add some shrines, code-walkthoughs, live chat and page transitions
at a later date.
</Paragraph>
<Header>Contact</Header>
<Paragraph>
Please email me <a href="mailto:adam.a.french@outlook.com">here</a>,
or contact me though any of the social medias linked.
</Paragraph>
<Header>A Quote</Header>
<!-- <p>
What makes me a good demoman? If I were a bad demoman, I wouldn't be
sittin' here discussin' it with you, now would I?!
</p> -->
<Paragraph>
One crossed wire, one wayward pinch of potassium chlorate, one
errant twitch, and KA-BLOOIE!
</Paragraph>
<div class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start">
<Header>Yo</Header>
<!-- <Header>Intro</Header> -->
<!-- <Paragraph> -->
<!-- Hi, I'm Adam, thank you for visiting my website. -->
<!-- </Paragraph> -->
<!-- <Header>Getting around</Header> -->
<!-- <Paragraph> -->
<!-- Pages available can be traversed through links below. I am hoping to -->
<!-- add some shrines, code-walkthoughs, live chat and page transitions -->
<!-- at a later date. -->
<!-- </Paragraph> -->
<!-- <Header>Contact</Header> -->
<!-- <Paragraph> -->
<!-- Please email me <a href="mailto:adam.a.french@outlook.com">here</a>, -->
<!-- or contact me though any of the social medias linked. -->
<!-- </Paragraph> -->
<!-- <Header>A Quote</Header> -->
<!-- <Paragraph> -->
<!-- One crossed wire, one wayward pinch of potassium chlorate, one -->
<!-- errant twitch, and KA-BLOOIE! -->
<!-- </Paragraph> -->
</div>
</template>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import { rand } from "@vueuse/core";
import { ref, onMounted, onUnmounted, nextTick } from "vue";
interface Item {
x: number;
y: number;
dx: number;
dy: number;
content: string;
}
const container = ref<HTMLDivElement | null>(null);
const itemEls = ref<HTMLDivElement[]>([]);
const phrases = [
"Welcome to my website",
"I'm looking to do a big revamp",
"A4 sheets of paper are what I'm used to",
"I'd love to know your recommendations",
"for very short books",
"Message me by any means necessary",
"I like anime, all kinds of music and sci fic",
];
// Non-reactive animation state to avoid triggering Vue re-renders every frame
const animState = phrases.map((text, i) => ({
x: i * 20,
y: i * 20,
dx: rand(0, 60) / 100,
dy: 1.0,
content: text,
cachedW: 0,
cachedH: 0,
}));
// Reactive items only for initial render
const items = ref<Item[]>(
animState.map((s) => ({
x: s.x,
y: s.y,
dx: s.dx,
dy: s.dy,
content: s.content,
})),
);
let rafId = 0;
let cachedCW = 0;
let cachedCH = 0;
let lastFrameTime = 0;
const FRAME_INTERVAL = 1000 / 30;
function measureSizes() {
const c = container.value;
if (c) {
cachedCW = c.clientWidth;
cachedCH = c.clientHeight;
}
itemEls.value.forEach((el, i) => {
if (el && animState[i]) {
animState[i].cachedW = el.offsetWidth;
animState[i].cachedH = el.offsetHeight;
}
});
}
function animate(timestamp: number) {
if (!cachedCW || !cachedCH) {
rafId = requestAnimationFrame(animate);
return;
}
if (timestamp - lastFrameTime < FRAME_INTERVAL) {
rafId = requestAnimationFrame(animate);
return;
}
lastFrameTime = timestamp;
for (let i = 0; i < animState.length; i++) {
const s = animState[i];
const el = itemEls.value[i];
if (!el) continue;
s.x += s.dx;
s.y += s.dy;
if (s.x < 0 || s.x > cachedCW - s.cachedW) s.dx *= -1;
if (s.y < 0 || s.y > cachedCH - s.cachedH) s.dy *= -1;
el.style.transform = `translate(${s.x}px, ${s.y}px)`;
}
rafId = requestAnimationFrame(animate);
}
let resizeObserver: ResizeObserver;
onMounted(async () => {
await nextTick();
measureSizes();
rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureSizes);
resizeObserver.observe(container.value!);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
});
</script>
<template>
<div
ref="container"
class="w-full h-full min-h-125 relative overflow-hidden"
>
<div
v-for="(item, i) in items"
:key="i"
ref="itemEls"
class="absolute w-fit h-fit"
>
<h1>
{{ item.content }}
</h1>
</div>
</div>
</template>

View File

@@ -1,7 +1,6 @@
<script setup>
import RouterTable from "@/components/util/RouterTable.vue";
import LinkTable from "@/components/util/LinkTable.vue";
import Markdown from "@/components/util/Markdown.vue";
const site_links = [
{ name: "CV", link: "/cv" },
@@ -12,6 +11,7 @@ const site_links = [
];
const social_links = [
{ name: "Gitea", link: "/gitea/explore/repos" },
{ name: "Steam", link: "https://steamcommunity.com/id/SteveThePug" },
{ name: "Github", link: "https://github.com/SteveThePug" },
{ name: "Spotify", link: "https://open.spotify.com/user/stevethepug" },

View File

@@ -29,7 +29,8 @@ onUnmounted(() => {
</script>
<template>
<Transition name="fade" mode="out-in">
<div class="listening-wrapper">
<Transition name="fade">
<div
@click="nextSong"
:key="song.track.id"
@@ -45,9 +46,16 @@ onUnmounted(() => {
</p>
</div>
</Transition>
</div>
</template>
<style scoped>
.listening-wrapper {
position: relative;
width: 100%;
height: 100%;
}
img {
width: 70%;
}
@@ -56,15 +64,17 @@ p {
margin: 0 auto;
}
.fade-enter-active {
transition: opacity 0.5s ease;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from {
opacity: 0;
.fade-leave-active {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

View File

@@ -0,0 +1,13 @@
<script setup>
import Slideshow from "@/components/util/Slideshow.vue";
const images = [
{ url: "/img/miku/miku1.gif" },
{ url: "/img/miku/miku2.gif" },
// { url: "/img/miku/miku2.png" },
];
</script>
<template>
<Slideshow class="p-5" :images="images" :interval="10000" />
</template>

View File

@@ -42,7 +42,8 @@ img {
width: 89px;
height: 59px;
}
.tst {
width: calc(89px * 4);
min-width: calc(89px * 4);
}
</style>

View File

@@ -7,10 +7,21 @@ import vueDevTools from "vite-plugin-vue-devtools";
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), vueDevTools(), tailwindcss()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
plugins: [
vue(),
...(process.env.NODE_ENV !== "production" ? [vueDevTools()] : []),
tailwindcss(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
proxy: {
"/api": "http://localhost:8080",
"/gitea": "http://localhost:3000",
"/radio": "http://localhost:8000",
},
},
},
});

974
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
{
"dependencies": {
"@vueuse/core": "^14.2.0"
"@vueuse/core": "^14.2.0",
"vite": "^7.3.1"
}
}

116
readme.md
View File

@@ -1,22 +1,117 @@
# TODO
- ML for 4chan
- Cache requests
- Login to add / remove posts (auth)
- Design webpage
- Calendar to show radio times
- Nice smooth function background
- Shrines
- Redis (not really)
# My Web
# .env
## Important TODO
- Get a new background
![screenshot](nginx/vue/public/img/screenshot.png)
Welcome to the source code for my website! Please contact me if you would like to collaborate and thank you for visiting.
This website is currently self hosted on my Raspberry Pi. Any interference and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
## Architecture
All services run in Docker containers orchestrated by Docker Compose:
```
nginx (80, 443) ── Frontend SPA + Reverse Proxy
backend (8080) ── Go API
db (5432) ── PostgreSQL 16
icecast2 (8000) ── Audio Streaming
gitea (3000) ── Self-Hosted Git
gitea-runner ── CI/CD Runner
certbot ── SSL Certificate Management
```
## Tech Stack
**Frontend** - Vue 3, Vite, Tailwind CSS, Pinia, Vue Router, markdown-it, Rust/WASM
**Backend** - Go (Gin), GORM, PostgreSQL, JWT auth, WebSockets
**Integrations** - Spotify API, Anthropic Claude API, Icecast2
**Infrastructure** - Docker Compose, Nginx, Let's Encrypt (Certbot), Gitea + Act Runner
## Features
- Spotify integration (currently playing, recently played)
- Obsidian note viewer with wikilink and LaTeX support
- Live radio streaming via Icecast2
- Real-time chat over WebSockets
- Blog with admin panel (CRUD)
- Activity and rowing session tracking
- Fan shrines (GTO, Evangelion, Demoman, Skip Skip Benben)
- Self-hosted Git (Gitea) with CI/CD
- Claude AI integration
## Pages
| Route | Description |
| -------------- | ------------------------------------- |
| `/` | Home dashboard with grid layout |
| `/admin` | Admin panel (authenticated) |
| `/cv` | Curriculum Vitae |
| `/bookmarks` | Bookmarks |
| `/notes/:path` | Obsidian note viewer |
| `/shrines` | Fan shrine index + individual shrines |
## API
Public endpoints for posts, users, favorites, activities, rowing, Spotify, notes, and WebSocket messaging. Protected endpoints for creating/updating/deleting content require JWT authentication via `/auth/login`.
## Local Testing (Dev Mode)
Run the full stack over plain HTTP without SSL certificates:
```
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
This uses an HTTP-only nginx config with all routing (SPA, backend proxy, radio, gitea) and disables certbot. Visit `http://localhost` to test.
## Future Ideas
- More Rust to WASM
- ML for chatboards
- Cache requests
- Design more webpages
- Calendar to show radio times
- Nice smooth function background and transitions
- Design shrines
- Redis (not really but practical experience)
## .env
These environment variables are found in the `.env` file. The use of environment variables can be found by reading the code so the security of the variable names are not significant.
```
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
POSTGRES_PORT=
POSTGRES_HOST=
GITEA_HOST=
GITEA_PORT=
POSTGRES_GITEA_DB=
GITEA_RUNNER_HOST=
GITEA_RUNNER_NAME=
GITEA_RUNNER_REGISTRATION_TOKEN=
BACKEND_PORT=
BACKEND_HOST=
BACKEND_SECRET=
BACKEND_ENDPOINT=
CLAUDE_API_KEY=
SEED_DB=
OBSIDIAN_DIR=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REDIRECT_URI=
@@ -32,3 +127,4 @@ ICECAST_MOUNT=
DOMAIN=
EMAIL=
```