Compare commits

...

58 Commits

Author SHA1 Message Date
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
41 changed files with 1618 additions and 467 deletions

View File

@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"fmt"
"net/http" "net/http"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
@@ -68,10 +67,9 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
claims, err := store.Auth.VerifyJWT(refreshToken) claims, err := store.Auth.VerifyJWT(refreshToken)
if err != nil { if err != nil {
ctx.JSON(http.StatusUnauthorized, err.Error()) ctx.JSON(http.StatusUnauthorized, err.Error())
return
} }
fmt.Printf("claims: %v\n", claims)
userIDF, ok := (*claims)["id"].(float64) userIDF, ok := (*claims)["id"].(float64)
if !ok { if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
@@ -93,6 +91,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
return return
} }
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
tokens.AccessToken, tokens.AccessToken,
@@ -122,12 +121,12 @@ func (store *Store) Login(ctx *gin.Context) {
user := models.User{} user := models.User{}
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil { 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 return
} }
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil { 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 return
} }
@@ -137,6 +136,7 @@ func (store *Store) Login(ctx *gin.Context) {
return return
} }
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
tokens.AccessToken, tokens.AccessToken,
@@ -164,6 +164,7 @@ func (store *Store) Logout(ctx *gin.Context) {
} }
func removeCookies(ctx *gin.Context) { func removeCookies(ctx *gin.Context) {
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "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" "encoding/json"
"io" "io"
"net/http" "net/http"
"time" "strings"
"github.com/rwcarlsen/goexif/exif" "github.com/rwcarlsen/goexif/exif"
@@ -16,9 +16,9 @@ import (
) )
type ExtractedRowingData struct { type ExtractedRowingData struct {
TimeMinutes float64 `json:"timeMinutes"` TimeMinutes uint64 `json:"timeMinutes"`
TimeSeconds float64 `json:"timeSeconds"` TimeSeconds uint64 `json:"timeSeconds"`
Distance float64 `json:"distance"` Distance uint64 `json:"distance"`
} }
func (store *Store) GetRowing(ctx *gin.Context) { func (store *Store) GetRowing(ctx *gin.Context) {
@@ -82,6 +82,13 @@ func (store *Store) CreateRowing(ctx *gin.Context) {
} }
encoded := base64.StdEncoding.EncodeToString(data) 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 // Build the message with an image + text prompt
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{ message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeHaiku4_5, Model: anthropic.ModelClaudeHaiku4_5,
@@ -110,21 +117,38 @@ No text, no markdown, no explanation. Just the JSON object.`),
}, },
}, },
}) })
if err != nil { 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 return
} }
if len(message.Content) == 0 { 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 return
} }
extractedData := ExtractedRowingData{} 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 { 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 return
} }
@@ -134,15 +158,39 @@ No text, no markdown, no explanation. Just the JSON object.`),
} }
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds 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{ rowing := models.Rowing{
Date: dateTaken, Date: dateTaken,
Time: totalDuration, Time: totalSeconds,
TimePer500m: per500m, TimePer500m: per500m,
Distance: extractedData.Distance, Distance: extractedData.Distance,
Calories: extractedData.Distance / 7500.0 * 500.0, Calories: calories,
} }
if err := store.DB.Create(&rowing).Error; err != nil { 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) { 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} opts := spotify.RecentlyPlayedOptions{Limit: 3}
if store.RecentSongsFresh() { if store.RecentSongsFresh() {
ctx.JSON(200, *store.RecentSongs) ctx.JSON(200, *store.RecentSongs)
return
} }
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts) played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)

View File

@@ -15,6 +15,21 @@ type UserCredentials struct {
} }
func (store *Store) CreateUser(ctx *gin.Context) { 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 var input UserCredentials
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error()) ctx.JSON(http.StatusBadRequest, err.Error())
@@ -31,32 +46,9 @@ func (store *Store) CreateUser(ctx *gin.Context) {
tx := store.DB.Create(&user) tx := store.DB.Create(&user)
if tx.Error != nil { if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) 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 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) ctx.JSON(http.StatusOK, user)
} }
@@ -141,6 +133,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) {
return return
} }
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
"", "",

View File

@@ -15,7 +15,7 @@ import (
func main() { func main() {
logsDir := "/backend/logs" 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 { if err != nil {
panic(err) panic(err)
} }
@@ -38,6 +38,11 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
if os.Getenv("SEED_DB") == "true" {
services.SeedDatabase(db)
}
domainName := os.Getenv("DOMAIN")
services.InitWebSocket(db, domainName)
// SPOTIFY // SPOTIFY
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE") spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
@@ -53,7 +58,6 @@ func main() {
claudeClient := services.InitClaude(&claudeConfig) claudeClient := services.InitClaude(&claudeConfig)
authSecret := os.Getenv("BACKEND_SECRET") authSecret := os.Getenv("BACKEND_SECRET")
domainName := os.Getenv("DOMAIN")
backendEndpoint := os.Getenv("BACKEND_ENDPOINT") backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
accessTokenLifetime := 24 * time.Hour accessTokenLifetime := 24 * time.Hour
refreshTokenLifetime := 365 * 24 * time.Hour refreshTokenLifetime := 365 * 24 * time.Hour
@@ -92,7 +96,7 @@ func main() {
protected.PUT("/user/:id", store.UpdateUser) protected.PUT("/user/:id", store.UpdateUser)
protected.DELETE("/user/:id", store.DeleteUser) protected.DELETE("/user/:id", store.DeleteUser)
r.GET("/user", store.GetUsers) r.GET("/user", store.GetUsers)
r.POST("/user", store.CreateUser) protected.POST("/user", store.CreateUser)
// AUTH // AUTH
r.POST("/auth/login", store.Login) r.POST("/auth/login", store.Login)
@@ -108,6 +112,7 @@ func main() {
// MESSAGES // MESSAGES
r.GET("/ws", store.ConnectWebSocket) r.GET("/ws", store.ConnectWebSocket)
protected.POST("/messages/upload", store.UploadMessageFile)
// NOTES // NOTES
r.GET("/notes/*path", store.GetNoteFile) r.GET("/notes/*path", store.GetNoteFile)

View File

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

View File

@@ -37,6 +37,7 @@ func migrateDatabase(db *gorm.DB) error {
&models.Activity{}, &models.Activity{},
&models.Favorite{}, &models.Favorite{},
&models.Rowing{}, &models.Rowing{},
&models.Message{},
) )
if err != nil { if err != nil {
return err 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 package services
import ( import (
"net/http"
"strings"
"sync" "sync"
"time" "time"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
"gorm.io/gorm"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
const maxMessages = 50
var allowedDomain string
var Upgrader = websocket.Upgrader{ var Upgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 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 ( var (
clients = make(map[*websocket.Conn]bool) clients = make(map[*websocket.Conn]bool)
messages = make([]models.Message, 0) mu sync.Mutex
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) { func HandleWebSocket(conn *websocket.Conn) {
defer conn.Close() defer conn.Close()
mu.Lock() mu.Lock()
clients[conn] = true clients[conn] = true
nextAuthorID++
authorID := nextAuthorID
// Send existing message history to new client var history []models.Message
for _, msg := range messages { wsDB.Order("created_at ASC").Limit(maxMessages).Find(&history)
for _, msg := range history {
if err := conn.WriteJSON(msg); err != nil { if err := conn.WriteJSON(msg); err != nil {
mu.Unlock() mu.Unlock()
return return
@@ -35,17 +66,32 @@ func HandleWebSocket(conn *websocket.Conn) {
} }
mu.Unlock() mu.Unlock()
msgCount := 0
windowStart := time.Now()
for { for {
var incoming models.Message var incoming models.Message
if err := conn.ReadJSON(&incoming); err != nil { if err := conn.ReadJSON(&incoming); err != nil {
break 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() 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 { for client := range clients {
if err := client.WriteJSON(incoming); err != nil { if err := client.WriteJSON(incoming); err != nil {
@@ -56,7 +102,6 @@ func HandleWebSocket(conn *websocket.Conn) {
mu.Unlock() mu.Unlock()
} }
// Cleanup on disconnect
mu.Lock() mu.Lock()
delete(clients, conn) delete(clients, conn)
mu.Unlock() 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

@@ -4,6 +4,7 @@ networks:
volumes: volumes:
dbdata: dbdata:
uploads:
services: services:
nginx: nginx:
@@ -25,6 +26,7 @@ services:
volumes: volumes:
- ./certbot/conf:/etc/letsencrypt - ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot - ./certbot/www:/var/www/certbot
- uploads:/uploads
certbot: certbot:
image: certbot/certbot image: certbot/certbot
@@ -55,6 +57,7 @@ services:
- ./backend/token/:/backend/token - ./backend/token/:/backend/token
- ${OBSIDIAN_DIR}:/backend/notes - ${OBSIDIAN_DIR}:/backend/notes
- ./logs:/backend/logs - ./logs:/backend/logs
- uploads:/backend/uploads
db: db:
image: postgres:16 image: postgres:16
@@ -66,8 +69,6 @@ services:
- app-network - app-network
volumes: volumes:
- dbdata:/var/lib/postgresql/data - dbdata:/var/lib/postgresql/data
ports:
- 5432:5432
icecast2: icecast2:
build: build:
@@ -79,12 +80,12 @@ services:
- app-network - app-network
env_file: env_file:
- ./.env - ./.env
ports:
- "${ICECAST_PORT}:${ICECAST_PORT}"
gitea-runner: gitea-runner:
image: gitea/act_runner:latest image: gitea/act_runner:latest
container_name: "${GITEA_RUNNER_HOST}" container_name: "${GITEA_RUNNER_HOST}"
profiles:
- disabled
environment: environment:
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME} GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
CONFIG_FILE: /config.yaml CONFIG_FILE: /config.yaml
@@ -94,7 +95,7 @@ services:
volumes: volumes:
- ./gitea-runner/config.yaml:/config.yaml - ./gitea-runner/config.yaml:/config.yaml
- ./gitea-runner/data:/data - ./gitea-runner/data:/data
- /var/run/docker.sock:/var/run/docker.sock - /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 restart: unless-stopped
networks: networks:
- app-network - app-network
@@ -110,6 +111,8 @@ services:
- GITEA__database__NAME=${POSTGRES_GITEA_DB} - GITEA__database__NAME=${POSTGRES_GITEA_DB}
- GITEA__database__USER=${POSTGRES_USER} - GITEA__database__USER=${POSTGRES_USER}
- GITEA__database__PASSWD=${POSTGRES_PASSWORD} - GITEA__database__PASSWD=${POSTGRES_PASSWORD}
- USER_UID=1000
- USER_GID=1000
restart: always restart: always
volumes: volumes:
- ./gitea/data:/var/lib/gitea - ./gitea/data:/var/lib/gitea
@@ -117,7 +120,7 @@ services:
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
ports: ports:
- "3000:3000"
- "2222:2222" - "2222:2222"
- "3000:3000"
depends_on: depends_on:
- db - db

View File

@@ -26,6 +26,7 @@ RUN mkdir -p /etc/nginx/html \
COPY nginx.conf.template /etc/nginx/nginx.conf.template COPY nginx.conf.template /etc/nginx/nginx.conf.template
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.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 robots.txt /etc/nginx/html/robots.txt
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh

View File

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

View File

@@ -9,6 +9,12 @@ http {
server_tokens off; server_tokens off;
charset utf-8; 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 log_format compact
'$remote_addr "$request" $status rt=$request_time'; '$remote_addr "$request" $status rt=$request_time';
@@ -55,6 +61,13 @@ http {
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
@@ -77,7 +90,40 @@ http {
return 301 $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/ { location $BACKEND_ENDPOINT/ {
limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host; proxy_set_header Host $host;

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

@@ -1024,9 +1024,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1037,9 +1037,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1050,9 +1050,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1063,9 +1063,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1076,9 +1076,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1089,9 +1089,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1102,9 +1102,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1115,9 +1115,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -1128,9 +1128,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1141,9 +1141,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1154,9 +1154,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -1167,9 +1167,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -1180,9 +1180,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1193,9 +1193,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -1206,9 +1206,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1219,9 +1219,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -1232,9 +1232,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -1245,9 +1245,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1258,9 +1258,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1271,9 +1271,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1284,9 +1284,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1297,9 +1297,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1310,9 +1310,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -1323,9 +1323,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1336,9 +1336,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2982,9 +2982,9 @@
} }
}, },
"node_modules/markdown-it": { "node_modules/markdown-it": {
"version": "14.1.0", "version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1", "argparse": "^2.0.1",
@@ -3245,9 +3245,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.55.1", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
@@ -3260,31 +3260,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },

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.

View File

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

View File

@@ -5,21 +5,31 @@ const container = useTemplateRef("container");
const item1 = useTemplateRef("item1"); const item1 = useTemplateRef("item1");
let offset = 0; let offset = 0;
let cachedWidth = 0;
let rafId; let rafId;
const speed = 0.5; // pixels per frame const speed = 0.5; // pixels per frame
function animate() { function measureWidth() {
const ctnr = container.value; const ctnr = container.value;
const it1 = item1.value; const it1 = item1.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; offset -= speed;
if (offset <= -width) { if (offset <= -cachedWidth) {
offset += width; offset += cachedWidth;
} }
ctnr.style.transform = `translateX(${offset}px)`; ctnr.style.transform = `translateX(${offset}px)`;
@@ -27,12 +37,19 @@ function animate() {
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
} }
let resizeObserver;
onMounted(() => { onMounted(() => {
measureWidth();
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureWidth);
resizeObserver.observe(container.value);
}); });
onUnmounted(() => { onUnmounted(() => {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
}); });
</script> </script>

View File

@@ -16,6 +16,12 @@ let pos = 0;
let direction = 1; // 1 = down, -1 = up let direction = 1; // 1 = down, -1 = up
let timeoutId; let timeoutId;
let timeoutId2; let timeoutId2;
let cachedScrollHeight = 0;
function measureScrollHeight() {
const el = container.value;
if (el) cachedScrollHeight = el.scrollHeight;
}
function handleHover() { function handleHover() {
cancelAnimationFrame(timeoutId); cancelAnimationFrame(timeoutId);
@@ -28,6 +34,10 @@ function handleHover() {
function tick() { function tick() {
const el = container.value; const el = container.value;
if (!el || cachedScrollHeight === 0) {
timeoutId = requestAnimationFrame(tick);
return;
}
const reachedBottom = pos <= 0; const reachedBottom = pos <= 0;
const reachedTop = pos >= 1; const reachedTop = pos >= 1;
@@ -46,16 +56,23 @@ function tick() {
pos += direction * SPEED; pos += direction * SPEED;
el.scrollTop = pos * el.scrollHeight; el.scrollTop = pos * cachedScrollHeight;
timeoutId = requestAnimationFrame(tick); timeoutId = requestAnimationFrame(tick);
} }
let resizeObserver;
onMounted(() => { onMounted(() => {
measureScrollHeight();
timeoutId = requestAnimationFrame(tick); timeoutId = requestAnimationFrame(tick);
resizeObserver = new ResizeObserver(measureScrollHeight);
resizeObserver.observe(container.value);
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
cancelAnimationFrame(timeoutId); cancelAnimationFrame(timeoutId);
resizeObserver?.disconnect();
}); });
</script> </script>

View File

@@ -1,9 +1,75 @@
<script setup> <script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
import Button from "@/components/input/Button.vue"; import Button from "@/components/input/Button.vue";
import { useMessagesStore } from "@/stores/messages"; import { useMessagesStore } from "@/stores/messages";
import { useAuthStore } from "@/stores/auth";
import Header from "@/components/text/Header.vue";
const messagesStore = useMessagesStore(); const messagesStore = useMessagesStore();
const authStore = useAuthStore();
const messages = computed(() => messagesStore.messages); 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(() => { onMounted(() => {
messagesStore.connect(); messagesStore.connect();
@@ -14,15 +80,67 @@ onUnmounted(() => {
</script> </script>
<template> <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"> <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> </p>
</div> </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 @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>
</div> </div>
</template> </template>

View File

@@ -1,9 +1,10 @@
<script setup> <script setup>
import axios from "axios"; import axios from "axios";
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import Header from "@/components/text/Header.vue";
const url = const url =
"https://www.adam-french.co.uk/gitea/api/v1/users/adamf/activities/feeds?limit=1"; "/gitea/api/v1/users/adamf/activities/feeds?limit=1";
const feed = ref(null); const feed = ref(null);
const isLoading = ref(true); const isLoading = ref(true);
@@ -28,6 +29,8 @@ onMounted(() => {
<template> <template>
<div class="justify-center text-center"> <div class="justify-center text-center">
<Header class="text-left">Commits</Header>
<div v-if="isLoading"> <div v-if="isLoading">
<p>Loading latest activity...</p> <p>Loading latest activity...</p>
</div> </div>

View File

@@ -1,11 +1,13 @@
<template> <template>
<div v-if="streamLive"> <div v-if="streamLive">
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" /> <img src="/img/tmpen31z3pe.PNG" />
<audio controls :src="streamUrl" ref="audio"></audio> <audio controls :src="streamUrl" ref="audio"></audio>
</div> </div>
<div v-else> <div v-else>
<Header>Radio</Header>
<img src="/img/tmpen31z3pe.PNG" /> <img src="/img/tmpen31z3pe.PNG" />
<div class="m-1"> <div class="m-1 text-center">
<p>Radio is offline. Message for info!</p> <p>Radio is offline. Message for info!</p>
<Button class="w-full" @click="checkStream()">Check Stream</Button> <Button class="w-full" @click="checkStream()">Check Stream</Button>
</div> </div>
@@ -14,6 +16,7 @@
<script setup> <script setup>
import Button from "@/components/input/Button.vue"; import Button from "@/components/input/Button.vue";
import Header from "@/components/text/Header.vue";
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import axios from "axios"; 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> </script>
<template> <template>
<div class="items-center flex flex-col"> <div class="flex flex-col">
<Header>{{ weekday }} {{ day }}, {{ month }}</Header>
<h1>{{ time }}</h1> <h1>{{ time }}</h1>
<h1>{{ weekday }} {{ day }}, {{ month }}</h1>
</div> </div>
</template> </template>

View File

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

View File

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

View File

@@ -4,187 +4,205 @@ import Project from "./Project.vue";
<template> <template>
<main> <main>
<div class="no-print w-full h-20">
</div>
<div class="a4page"> <div class="a4page">
<div class="flex flex-row justify-between"> <div class="flex flex-row justify-between">
<h1 class="name">Adam French</h1> <h1 class="name">Adam French</h1>
<!-- <a href="covers.html"><img width=25 height=50 src="img/rune.png"></a> -->
<div class="contact-details text-right"> <div class="contact-details text-right">
<p>+447563266931</p> <p>+447563266931</p>
<p>adam.a.french@outlook.com</p> <p>adam.a.french@outlook.com</p>
<h4>
<a href="https://www.adam-french.co.uk"> <a href="https://www.adam-french.co.uk">
www.adam-french.co.uk www.adam-french.co.uk
</a> </a>
</h4>
</div> </div>
</div> </div>
<h2>Profile</h2> <h2>Profile</h2>
<p> <p>
Recent Computer Science with Mathematics (International) First Class Honours graduate in Computer Science with Mathematics
graduate from the University of Leeds, awarded First Class from the University of Leeds (81.1%), with a year abroad at the
Honours (81.1%). Strong foundation in full-stack software University of Waterloo. Proficient in full-stack development,
development, CI/CD workflows, and modern programming languages. systems programming, and CI/CD automation. Eager to contribute to
Experienced in creating scalable, maintainable systems and a collaborative engineering team, apply strong academic
motivated by solving complex technical problems. Enthusiastic foundations to real-world problems, and grow through hands-on
about working within organisations that promote innovation, experience.
collaboration, and positive social impact.
</p> </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> <h2>Projects</h2>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
<template v-slot:left> <template v-slot:left>
<h4>
<a <a
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git" href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
> >
web_server.git web_server.git
</a> </a>
</h4>
</template> </template>
<template v-slot:top> <template v-slot:top>
<small> <small>
Nginx, Vue, Postgres, Docker, Go, Python, Rust -> Wasm, Nginx, Vue, Postgres, Docker, Go, Python, Rust Wasm,
Git Actions, JWT Auth Git Actions, JWT Auth
</small> </small>
<small>2025</small> <small>2025</small>
</template> </template>
<p> <p>
Developed and self-hosted a personal website with a fully Self-hosted personal website with a fully automated CI/CD
automated maintenance CI/CD pipeline. Experimented with pipeline. Iterated across diverse tech stacks including
diverse tech stacks including Svelte, React/Redux, SQLite, Svelte, React/Redux, SQLite, Rust Actix, and Deno.
Rust Actix, and Deno.
</p> </p>
</Project> </Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
<template v-slot:left> <template v-slot:left>
<h4>
<a <a
href="https://www.adam-french.co.uk/gitea/adamf/tour.git" href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
> >
tour.git tour.git
</a> </a>
</h4>
</template> </template>
<template v-slot:top> <template v-slot:top>
<small>Rust</small> <small>Rust</small>
<small>2026</small> <small>2026</small>
</template> </template>
<p> <p>
Created a command-line tool for building and viewing CLI tool for building and navigating interactive code
interactive code tutorials. Designed functionality analogous tutorials, with version-traversal semantics inspired by Git.
to Git for intuitive version traversal and educational use.
</p> </p>
</Project> </Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
<template v-slot:left> <template v-slot:left>
<h4>
<a <a
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git" href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
> >
rust-raytracer.git rust-raytracer.git
</a> </a>
</h4>
</template> </template>
<template v-slot:top> <template v-slot:top>
<small>Rust, Linear Algebra, Multithreading</small> <small>Rust, Linear Algebra, Multithreading</small>
<small>2023</small> <small>2023</small>
</template> </template>
<p> <p>
Built a parallelised, recursive ray tracer for realistic 3D Parallelised recursive ray tracer for realistic 3D rendering.
rendering as part of a university module. Focused on Emphasised algorithmic efficiency and low-level memory
algorithmic efficiency and low-level memory management in management in Rust.
Rust.
</p> </p>
</Project> </Project>
<Project> <Project>
<template #left> <template #left>
<p> <h4>
<a <a
class="text-center w-full" class="text-center w-full"
href="https://community.wolfram.com/groups/-/m/t/3210947" href="https://community.wolfram.com/groups/-/m/t/3210947"
> >
Wolfram Summer School Wolfram Summer School
</a> </a>
</p> </h4>
</template> </template>
<template #top> <template #top>
<small>Wolfram Mathematica</small> <small>Wolfram Mathematica</small>
<small>2024</small> <small>2024</small>
</template> </template>
<p> <p>
Designed and implemented a research project on Mobile Research project on Mobile Automata with data visualisation
Automata, including data visualisation and presentation of and academic presentation. Delivered within a tight deadline
findings. Completed the project within a short deadline and in collaboration with academic mentors.
collaborated with academic mentors to refine outcomes.
</p> </p>
</Project> </Project>
<h2>University & Modules</h2>
<h2>Education</h2>
<div class="w-full h-fit flex-row flex gap-5"> <div class="w-full h-fit flex-row flex gap-5">
<div class="flex-1 border-r border-dotted pr-3"> <div class="flex-1 border-r border-dotted pr-3">
<h3>University of Leeds</h3> <h3>
<a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
University of Leeds
</a>
</h3>
<div <div
class="flex-row flex place-content-between m-auto place-items-center" class="flex-row flex place-content-between m-auto place-items-center"
> >
<small> 81.1% (First Class Honours)</small> <small>81.1% First Class Honours</small>
<small> 2021-2025 </small> <small>20212025</small>
</div> </div>
<small>BSc Computer Science with Mathematics </small> <small>BSc Computer Science with Mathematics (International)</small>
<ul> <ul>
<li>Procedural & Object Oriented Programming,</li> <li>Algorithms & Data Structures I & II</li>
<li></li>
<li>Algorithms and Data Structures I & II</li>
<li>Databases</li>
<li>Computer Processors</li>
<li>Compiler Design and Construction</li> <li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li>
<li>Formal Languages and Finite Automata</li>
<li>Probability and Statistics I</li>
<li>Machine Learning</li>
<li>Graph Algorithms & Complexity Theory</li> <li>Graph Algorithms & Complexity Theory</li>
<li>Machine Learning · Databases · Computer Processors</li>
<li>Probability and Statistics I</li>
</ul> </ul>
</div> </div>
<div class="flex-1 pl-3"> <div class="flex-1 pl-3">
<h3>The University of Waterloo</h3> <h3>University of Waterloo</h3>
<div <div
class="flex-row flex place-content-between m-auto place-items-center" class="flex-row flex place-content-between m-auto place-items-center"
> >
<small>---</small> <small>Year abroad</small>
<small> 2023-2024 </small> <small>20232024</small>
</div> </div>
<div class="flex-row flex place-content-between"></div>
<ul> <ul>
<li>Applied Cryptography</li> <li>Applied Cryptography</li>
<li>Introduction to Computer Graphics</li> <li>Introduction to Computer Graphics</li>
<li> <li>Introduction to Rings and Fields with Applications</li>
Introduction to Rings and Fields with Applications
</li>
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
<!-- <div class="a4page"> -->
<!-- <h2>Experience</h2> --> <div class="no-print w-full h-20">
<!-- <Project> -->
<!-- <template #left> --> </div>
<!-- <p>Hospitality</p> -->
<!-- </template> --> <div class="a4page">
<!-- <template #top> --> <div class="flex-1 pl-3">
<!-- <small>Cashier, Bartender, Waiter</small> --> <h2>Experience</h2>
<!-- <small>2018-2023</small> --> <Project>
<!-- </template> --> <template #left>
<!-- <p> --> <p>Hospitality</p>
<!-- Worked at venues including: --> </template>
<!-- <em>Belgrave Music Hall</em>, --> <template #top>
<!-- <em>The Crown and Anchor Eastbourne</em>, --> <small>Cashier, Bartender, Waiter</small>
<!-- <em>To The Rise Bakery</em>, --> <small>20182023</small>
<!-- <em>BFI Riverfront Kitchen</em>. --> </template>
<!-- </p> --> <p>
<!-- </Project> --> Worked at <em>Belgrave Music Hall</em>,
<!-- <h2>Commitments</h2> --> <em>The Crown and Anchor</em>, and
<!-- <ul> --> <em>BFI Riverfront Kitchen</em>. Developed
<!-- <li>Gym</li> --> communication, composure under pressure, and
<!-- <li>Climbing</li> --> reliability in customer-facing roles.
<!-- <li>Meetup.com</li> --> </p>
<!-- <li>Boardgames</li> --> </Project>
<!-- <li>Leetcode</li> --> <h2>Interests</h2>
<!-- <li>Learning Mandarin</li> --> <ul>
<!-- </ul> --> <li>Leetcode daily competitive problem solving</li>
<!-- </div> --> <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> </main>
</template> </template>
@@ -206,7 +224,6 @@ import Project from "./Project.vue";
/* Variables */ /* Variables */
* { * {
/* Black - White */
--primary: black; --primary: black;
--secondary: #0000ff; --secondary: #0000ff;
--tertiary: #ff0000; --tertiary: #ff0000;
@@ -228,18 +245,14 @@ import Project from "./Project.vue";
line-height: 1.6; line-height: 1.6;
font-family: var(--font-text); font-family: var(--font-text);
width: 210mm; width: 210mm;
/* Standard A4 width */
height: 297mm; height: 297mm;
/* Standard A4 height */
padding: 5mm; padding: 5mm;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--background); background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary); border: 1px solid var(--primary);
overflow: hidden; overflow: hidden;
/* Enables scrolling when content exceeds height */
margin: auto auto; margin: auto auto;
/* Centers the page horizontally */
} }
/* Component Styling */ /* Component Styling */
@@ -298,7 +311,6 @@ table {
} }
td { td {
/* border: 2px solid var(--tertiary); */
color: var(--secondary); color: var(--secondary);
border-top: 1px solid var(--tertiary); border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px; padding: 1px 10px 1px 10px;
@@ -321,16 +333,27 @@ th {
display: none !important; display: none !important;
} }
} }
small { small {
font-size: var(--font-size-small); font-size: var(--font-size-small);
color: var(--primary); color: var(--primary);
} }
ul { ul {
font-size: var(--font-size-small); font-size: var(--font-size-small);
margin: 0;
padding-left: 1.2em;
} }
li { li {
font-size: var(--font-size-small); font-size: var(--font-size-small);
color: var(--primary); color: var(--primary);
} }
.skills-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.3em 1em;
margin-bottom: 0.2em;
}
</style> </style>

View File

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

View File

@@ -1,76 +1,15 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from "vue"; import Slideshow from "@/components/util/Slideshow.vue";
import { Transition } from "vue";
import Header from "@/components/text/Header.vue";
const images = [ const images = [
{ url: "/img/memes/pidgeon.gif", comment: "鸟" }, { url: "/img/memes/pidgeon.gif", comment: "鸟" },
// { url: "/img/memes/no_slip.png" }, // { url: "/img/memes/no_slip.png" },
//{ url: "/img/memes/epic.jpeg" }, // { url: "/img/memes/epic.jpeg" },
// { url: "/img/bedroom/img2.png", comment: "办公桌" }, // { url: "/img/bedroom/img2.png", comment: "办公桌" },
// { url: "/img/bedroom/img1.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);
currentIndex.value = (currentIndex.value + 1) % images.length;
nextId = setTimeout(nextImage, 10000);
}
function nextRandomImage() {
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> </script>
<template> <template>
<Transition name="fade" mode="out-in"> <Slideshow :images="images" />
<div class="image-viewer" @click="nextImage" :key="currentIndex">
<Header v-if="currentComment">
{{ currentComment }}
</Header>
<img :src="currentUrl" alt="Image Viewer" />
</div>
</Transition>
</template> </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

@@ -10,20 +10,34 @@ import CommitHistory from "@/components/util/CommitHistory.vue";
import Intro from "./Intro.vue"; import Intro from "./Intro.vue";
import Intro2 from "./Intro2.vue"; import Intro2 from "./Intro2.vue";
import BadApple from "./BadApple.vue"; import BadApple from "./BadApple.vue";
import Miku from "./Miku.vue";
import Stamps from "./Stamps.vue"; import Stamps from "./Stamps.vue";
import Listening from "./Listening.vue"; import Listening from "./Listening.vue";
import Links from "./Links.vue"; import Links from "./Links.vue";
import Feed from "./Feed.vue"; import Feed from "./Feed.vue";
import Collage from "./Collage.vue"; import Collage from "./Collage.vue";
import Favorites from "./Favorites.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 Consumption from "./Consumption.vue";
</script> </script>
<template> <template>
<main class="halftone justify-center flex flex-row w-full h-full"> <main class="halftone justify-center flex flex-row w-full h-full">
<div class="h-fit flex flex-row"> <div class="outerWrap h-fit flex flex-row">
<div class="a4page-portrait homeGrid relative bdr-1"> <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" /> --> <!-- <Intro class="intro" /> -->
<Intro2 class="intro" /> <Intro2 class="intro" />
<!-- <BadApple class="intro" /> --> <!-- <BadApple class="intro" /> -->
@@ -34,31 +48,28 @@ import Consumption from "./Consumption.vue";
<Collage class="collage" /> <Collage class="collage" />
<Consumption class="consumption" /> <Consumption class="consumption" />
<Favorites class="favorites" /> <Favorites class="favorites" />
<Gym class="gym" /> <!-- <Gym class="gym" /> -->
<Gym2 class="gym" />
</div> </div>
<div <div
class="sidebar border-quaternary place-content-between flex-1 flex flex-col m-10 w-60" class="sidebar place-content-between flex-1 flex flex-col m-10 w-60"
> >
<div class="flex flex-col flex-1 gap-2"> <div
<Time class="flex flex-col background-children border-children flex-1 gap-2"
class="bg-bg_primary border-primary border text-center" >
/> <Time />
<Timer class="border-primary border bg-bg_primary" /> <Timer />
<Radio <Radio />
class="border-primary border bg-bg_primary text-center" <CommitHistory />
/>
<CommitHistory
class="border-primary border bg-bg_primary text-center"
/>
<!-- <Elle class="flex-1" /> --> <!-- <Elle class="flex-1" /> -->
<!-- <Chat class="bdr-2 bg-bg_primary" /> -->
<!-- <MusicPlayer /> --> <!-- <MusicPlayer /> -->
</div> </div>
<div> <div>
<img <img
src="/img/memes/fire-woman.gif" src="/img/memes/fire-woman.gif"
class="border-tertiary border" class="border-tertiary border"
loading="lazy"
/> />
</div> </div>
</div> </div>
@@ -67,32 +78,74 @@ import Consumption from "./Consumption.vue";
</template> </template>
<style scoped> <style scoped>
.homeGrid > * { .border-children > * {
border: 2px solid var(--quaternary); border: 2px solid var(--quaternary);
border-color: var(--quaternary); }
.background-children > * {
background-color: var(--bg_primary); background-color: var(--bg_primary);
} }
.homeGrid { .homeGrid {
display: grid; display: grid;
grid-gap: 5px; gap: 5px;
grid-template-columns: repeat(10, 1fr); grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(10, 1fr); grid-template-rows: repeat(10, 1fr);
} }
@media (max-width: 850px) { @media (max-width: 1200px) {
.homeGrid { .outerWrap {
width: 100%;
display: flex;
flex-direction: column; 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) { @media (max-width: 850px) {
.tr, .homeGrid {
.br, display: flex;
flex-direction: column;
height: auto;
}
.sidebar { .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;
} }
} }
@@ -140,4 +193,10 @@ import Consumption from "./Consumption.vue";
grid-column: span 3; grid-column: span 3;
grid-row: span 2; grid-row: span 2;
} }
.bg-random {
background-color: var(--bg_primary);
background-image: url("/img/miku/miku2.gif");
background-size: 10px 10px;
}
</style> </style>

View File

@@ -23,49 +23,91 @@ const phrases = [
"I like anime, all kinds of music and sci fic", "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[]>( const items = ref<Item[]>(
phrases.map((text, i) => ({ animState.map((s) => ({
x: i * 20, x: s.x,
y: i * 20, y: s.y,
dx: rand(0, 30) / 100, dx: s.dx,
dy: 0.5, dy: s.dy,
content: text, content: s.content,
})), })),
); );
let rafId = 0; let rafId = 0;
let cachedCW = 0;
let cachedCH = 0;
let lastFrameTime = 0;
const FRAME_INTERVAL = 1000 / 30;
function animate() { function measureSizes() {
const c = container.value; const c = container.value;
if (!c) return; if (c) {
cachedCW = c.clientWidth;
const cw = c.clientWidth; cachedCH = c.clientHeight;
const ch = c.clientHeight; }
itemEls.value.forEach((el, i) => {
items.value.forEach((item, i) => { if (el && animState[i]) {
const el = itemEls.value[i]; animState[i].cachedW = el.offsetWidth;
if (!el) return; animState[i].cachedH = el.offsetHeight;
}
const ew = el.offsetWidth;
const eh = el.offsetHeight;
item.x += item.dx;
item.y += item.dy;
if (item.x < 0 || item.x > cw - ew) item.dx *= -1;
if (item.y < 0 || item.y > ch - eh) item.dy *= -1;
}); });
}
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); rafId = requestAnimationFrame(animate);
} }
let resizeObserver: ResizeObserver;
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
measureSizes();
rafId = requestAnimationFrame(animate); rafId = requestAnimationFrame(animate);
resizeObserver = new ResizeObserver(measureSizes);
resizeObserver.observe(container.value!);
}); });
onUnmounted(() => { onUnmounted(() => {
cancelAnimationFrame(rafId); cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
}); });
</script> </script>
@@ -79,9 +121,6 @@ onUnmounted(() => {
:key="i" :key="i"
ref="itemEls" ref="itemEls"
class="absolute w-fit h-fit" class="absolute w-fit h-fit"
:style="{
transform: `translate(${item.x}px, ${item.y}px)`,
}"
> >
<h1> <h1>
{{ item.content }} {{ item.content }}

View File

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

@@ -13,4 +13,11 @@ export default defineConfig({
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),
}, },
}, },
server: {
proxy: {
"/api": "http://localhost:8080",
"/gitea": "http://localhost:3000",
"/radio": "http://localhost:8000",
},
},
}); });

View File

@@ -1,14 +1,79 @@
# Introduction # My Web
## Important TODO
- Get a new background
![screenshot](nginx/vue/public/img/screenshot.png) ![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. 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 Rasberry PI. Any interference and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :). 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 :).
# Future ideas ## Architecture
- Rust to wasm 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 - ML for chatboards
- Cache requests - Cache requests
- Design more webpages - Design more webpages
@@ -17,12 +82,11 @@ This website is currently self hosted on my Rasberry PI. Any interference and th
- Design shrines - Design shrines
- Redis (not really but practical experience) - Redis (not really but practical experience)
# .env ## .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. 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_USER=
POSTGRES_PASSWORD= POSTGRES_PASSWORD=
POSTGRES_DB= POSTGRES_DB=
@@ -42,6 +106,10 @@ BACKEND_HOST=
BACKEND_SECRET= BACKEND_SECRET=
BACKEND_ENDPOINT= BACKEND_ENDPOINT=
CLAUDE_API_KEY=
SEED_DB=
OBSIDIAN_DIR= OBSIDIAN_DIR=
SPOTIFY_CLIENT_ID= SPOTIFY_CLIENT_ID=
@@ -59,5 +127,4 @@ ICECAST_MOUNT=
DOMAIN= DOMAIN=
EMAIL= EMAIL=
``` ```