Compare commits
119 Commits
rowing
...
165852e738
| Author | SHA1 | Date | |
|---|---|---|---|
| 165852e738 | |||
| c58c19cc1e | |||
| 26ea0108e0 | |||
| 604576b46a | |||
| 33d72fd20a | |||
| d3cbc687d5 | |||
| d7b76e4742 | |||
| 64c2ba5562 | |||
| 6796367dbe | |||
| c2580c984d | |||
| 68db930049 | |||
| 63da086da2 | |||
| 6326a438dc | |||
| 7c980f1b1f | |||
| 141ceab7e6 | |||
| d03f9668ad | |||
| 41d6cf0dac | |||
| 1e3c6adf5e | |||
| 99ddd7d494 | |||
| 8e50537333 | |||
| 85a2325683 | |||
| 0a8a752433 | |||
| 4c396ef30f | |||
| 77e2c272cb | |||
| 1578a05762 | |||
| a6bc1d5126 | |||
| 2737b4f0d0 | |||
| 9fa953c969 | |||
| 5a45f1f427 | |||
| 4458844029 | |||
| 3200ef5bee | |||
| 0da6d3f0ed | |||
| 88ce32abeb | |||
| adcf1bda48 | |||
| 7450b5a624 | |||
| ab2b0a1e3d | |||
| ff82b8bdf9 | |||
| 1429a6a5cb | |||
| 7a71484ecc | |||
| e1563b55f4 | |||
| 4fbeabc3ae | |||
| a83b98eb2b | |||
| 5346b24999 | |||
| 3779a1cbcc | |||
| 3f39f6327c | |||
| 9dc9a3a063 | |||
| a6b543cf65 | |||
| 4a65836210 | |||
| 95635c86b3 | |||
| 3056b23b50 | |||
| 72013f5cdd | |||
| 7aa62659e5 | |||
| aa3f0a189d | |||
| 646f93136d | |||
| 54852eba82 | |||
| e43c07b30a | |||
| 190bc6076b | |||
| 88884121ab | |||
| e25fc5f1d1 | |||
| 5bcc65668e | |||
| 2c1ecce99a | |||
| f2ba3494b1 | |||
| d56bd5783d | |||
| f60636942f | |||
| b087172bb1 | |||
| 0c93c6bc27 | |||
| 48ae2f59ea | |||
| c9faa90abd | |||
| ef78974744 | |||
| 49499052b0 | |||
| dbb4914745 | |||
| 34fa96ddab | |||
| 8a9f3c373d | |||
| dc05ade798 | |||
| 1e47919a40 | |||
| 8e9734fca7 | |||
| da9a083f2d | |||
| 3c40eb9f08 | |||
| e016e3af46 | |||
| 0c91f512b4 | |||
| f63b61431b | |||
| f3ea83c477 | |||
| 4b5ed4787a | |||
| 747a403bcb | |||
| fe16ccab97 | |||
| 7bcb485fc6 | |||
| a3d73b12f4 | |||
| 47a8e6c35e | |||
| f885ff9175 | |||
| d574fa7692 | |||
| ac171f7846 | |||
| b5b86a2a37 | |||
| cfdb5b4d50 | |||
| 37580cdc42 | |||
| 711236b776 | |||
| 75454c2ed8 | |||
| 78c824c4c8 | |||
| ba3b933068 | |||
| 14c430bbad | |||
| 26c7422e34 | |||
| 470b1c79d8 | |||
| d849b606ec | |||
| 46a9da4c90 | |||
| 398a610cb2 | |||
| b506bae515 | |||
| 11ad0b5a83 | |||
| d7393e1419 | |||
| 0d32333c0c | |||
| 050a38a76f | |||
| bc43e9ed02 | |||
| 75b8b02825 | |||
| 5c69a1d0a7 | |||
| aa915e1071 | |||
| 7dc3f49273 | |||
| 21d3997a16 | |||
| c56ba217dd | |||
| 91804f1fe7 | |||
| 7e74ce5a2a | |||
| e92ac49140 |
19
.gitea/workflows/deploy.yaml
Normal file
19
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Deploy with Docker Compose
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Pull changes
|
||||
working-directory: /home/adamf/deploy/web_server
|
||||
run: git pull gitea main
|
||||
|
||||
- name: Run docker compose up
|
||||
working-directory: /home/adamf/deploy/web_server
|
||||
env:
|
||||
DOCKER_API_VERSION: "1.41"
|
||||
run: docker compose up -d --build --remove-orphans
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,6 +3,12 @@ certbot/www
|
||||
backend/token/
|
||||
.env
|
||||
|
||||
gitea/data/*
|
||||
gitea-runner/data/*
|
||||
|
||||
# Will add in future (webpack)
|
||||
nginx/vue/crates/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
@@ -68,10 +67,9 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
||||
claims, err := store.Auth.VerifyJWT(refreshToken)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("claims: %v\n", claims)
|
||||
|
||||
userIDF, ok := (*claims)["id"].(float64)
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
|
||||
@@ -93,6 +91,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(
|
||||
"access_token",
|
||||
tokens.AccessToken,
|
||||
@@ -122,12 +121,12 @@ func (store *Store) Login(ctx *gin.Context) {
|
||||
|
||||
user := models.User{}
|
||||
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
||||
ctx.JSON(http.StatusNotFound, err.Error())
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,6 +136,7 @@ func (store *Store) Login(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(
|
||||
"access_token",
|
||||
tokens.AccessToken,
|
||||
@@ -164,6 +164,7 @@ func (store *Store) Logout(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func removeCookies(ctx *gin.Context) {
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(
|
||||
"access_token",
|
||||
"",
|
||||
|
||||
97
backend/handlers/handle_message_upload.go
Normal file
97
backend/handlers/handle_message_upload.go
Normal 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})
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
)
|
||||
|
||||
type ExtractedRowingData struct {
|
||||
TimeMinutes float64 `json:"timeMinutes"`
|
||||
TimeSeconds float64 `json:"timeSeconds"`
|
||||
Distance float64 `json:"distance"`
|
||||
TimeMinutes uint64 `json:"timeMinutes"`
|
||||
TimeSeconds uint64 `json:"timeSeconds"`
|
||||
Distance uint64 `json:"distance"`
|
||||
}
|
||||
|
||||
func (store *Store) GetRowing(ctx *gin.Context) {
|
||||
@@ -82,6 +82,13 @@ func (store *Store) CreateRowing(ctx *gin.Context) {
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
// Reject duplicates: same EXIF datetime already recorded
|
||||
var existing models.Rowing
|
||||
if err := store.DB.Where("date = ?", dateTaken).First(&existing).Error; err == nil {
|
||||
ctx.JSON(http.StatusConflict, gin.H{"error": "duplicate entry for this date"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build the message with an image + text prompt
|
||||
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
|
||||
Model: anthropic.ModelClaudeHaiku4_5,
|
||||
@@ -110,21 +117,38 @@ No text, no markdown, no explanation. Just the JSON object.`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"})
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{
|
||||
"raw": message.Content[0].Text,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(message.Content) == 0 {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"})
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{
|
||||
"raw": message.Content[0].Text,
|
||||
"error": "empty response from Claude",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
extractedData := ExtractedRowingData{}
|
||||
err = json.Unmarshal([]byte(message.Content[0].Text), &extractedData)
|
||||
raw := message.Content[0].Text
|
||||
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
err = json.Unmarshal([]byte(raw), &extractedData)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"})
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "failed to parse JSON response",
|
||||
"detail": err.Error(),
|
||||
"raw": raw,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,15 +158,39 @@ No text, no markdown, no explanation. Just the JSON object.`),
|
||||
}
|
||||
|
||||
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
|
||||
totalDuration := time.Duration(totalSeconds * float64(time.Second))
|
||||
per500m := time.Duration(totalSeconds / extractedData.Distance * 500 * float64(time.Second))
|
||||
|
||||
// Validate for anomalous values
|
||||
const (
|
||||
minDistance = 100 // metres
|
||||
maxDistance = 100000 // metres
|
||||
minTotalSecs = 30 // 30 seconds
|
||||
maxTotalSecs = 7200 // 2 hours
|
||||
minPacePer500m = 80 // ~1:20 /500m (faster than any human)
|
||||
maxPacePer500m = 150 // ~2:30 /500m (slow, not important)
|
||||
)
|
||||
if extractedData.Distance < minDistance || extractedData.Distance > maxDistance {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous distance value"})
|
||||
return
|
||||
}
|
||||
if totalSeconds < minTotalSecs || totalSeconds > maxTotalSecs {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous time value"})
|
||||
return
|
||||
}
|
||||
|
||||
per500m := float64(totalSeconds) / float64(extractedData.Distance) * 500.0
|
||||
if per500m < minPacePer500m || per500m > maxPacePer500m {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous pace value"})
|
||||
return
|
||||
}
|
||||
|
||||
calories := float64(extractedData.Distance) / 7500.0 * 500.0
|
||||
|
||||
rowing := models.Rowing{
|
||||
Date: dateTaken,
|
||||
Time: totalDuration,
|
||||
Time: totalSeconds,
|
||||
TimePer500m: per500m,
|
||||
Distance: extractedData.Distance,
|
||||
Calories: extractedData.Distance / 7500.0 * 500.0,
|
||||
Calories: calories,
|
||||
}
|
||||
|
||||
if err := store.DB.Create(&rowing).Error; err != nil {
|
||||
|
||||
@@ -53,10 +53,16 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func (store *Store) RecentlyPlayed(ctx *gin.Context) {
|
||||
if store.SpotifyClient == nil {
|
||||
ctx.JSON(500, gin.H{"error": "Spotify not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
opts := spotify.RecentlyPlayedOptions{Limit: 3}
|
||||
|
||||
if store.RecentSongsFresh() {
|
||||
ctx.JSON(200, *store.RecentSongs)
|
||||
return
|
||||
}
|
||||
|
||||
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
||||
|
||||
@@ -15,6 +15,21 @@ type UserCredentials struct {
|
||||
}
|
||||
|
||||
func (store *Store) CreateUser(ctx *gin.Context) {
|
||||
claimsVal, ok := ctx.Get("userClaims")
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
|
||||
return
|
||||
}
|
||||
claims, ok := claimsVal.(*jwt.MapClaims)
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
||||
return
|
||||
}
|
||||
if !(*claims)["admin"].(bool) {
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||
return
|
||||
}
|
||||
|
||||
var input UserCredentials
|
||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
||||
@@ -31,32 +46,9 @@ func (store *Store) CreateUser(ctx *gin.Context) {
|
||||
tx := store.DB.Create(&user)
|
||||
if tx.Error != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
tokens, err := store.Auth.GenerateJWT(&user)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetCookie(
|
||||
"access_token",
|
||||
tokens.AccessToken,
|
||||
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||
store.Auth.Config.Endpoint,
|
||||
store.Auth.Config.Domain,
|
||||
true, true,
|
||||
)
|
||||
ctx.SetCookie(
|
||||
"refresh_token",
|
||||
tokens.RefreshToken,
|
||||
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||
store.Auth.Config.Endpoint,
|
||||
store.Auth.Config.Domain,
|
||||
true, true,
|
||||
)
|
||||
|
||||
ctx.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
@@ -141,6 +133,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(
|
||||
"access_token",
|
||||
"",
|
||||
|
||||
@@ -14,9 +14,8 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
logsDir := "/backend/logs"
|
||||
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -39,6 +38,11 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if os.Getenv("SEED_DB") == "true" {
|
||||
services.SeedDatabase(db)
|
||||
}
|
||||
domainName := os.Getenv("DOMAIN")
|
||||
services.InitWebSocket(db, domainName)
|
||||
|
||||
// SPOTIFY
|
||||
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
||||
@@ -54,7 +58,6 @@ func main() {
|
||||
claudeClient := services.InitClaude(&claudeConfig)
|
||||
|
||||
authSecret := os.Getenv("BACKEND_SECRET")
|
||||
domainName := os.Getenv("DOMAIN")
|
||||
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
|
||||
accessTokenLifetime := 24 * time.Hour
|
||||
refreshTokenLifetime := 365 * 24 * time.Hour
|
||||
@@ -93,7 +96,7 @@ func main() {
|
||||
protected.PUT("/user/:id", store.UpdateUser)
|
||||
protected.DELETE("/user/:id", store.DeleteUser)
|
||||
r.GET("/user", store.GetUsers)
|
||||
r.POST("/user", store.CreateUser)
|
||||
protected.POST("/user", store.CreateUser)
|
||||
|
||||
// AUTH
|
||||
r.POST("/auth/login", store.Login)
|
||||
@@ -109,6 +112,7 @@ func main() {
|
||||
|
||||
// MESSAGES
|
||||
r.GET("/ws", store.ConnectWebSocket)
|
||||
protected.POST("/messages/upload", store.UploadMessageFile)
|
||||
|
||||
// NOTES
|
||||
r.GET("/notes/*path", store.GetNoteFile)
|
||||
|
||||
@@ -30,8 +30,8 @@ type Post struct {
|
||||
type Message struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Content string `json:"text"`
|
||||
AuthorID uint `json:"-"`
|
||||
Author *User `gorm:"foreignKey:AuthorID" json:"author"`
|
||||
AuthorID uint `json:"authorId"`
|
||||
FileURL string `json:"fileUrl,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
@@ -61,8 +61,8 @@ type Rowing struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
Date time.Time `json:"date"`
|
||||
Time time.Duration `json:"time"`
|
||||
TimePer500m time.Duration `json:"timePer500m"`
|
||||
Distance float64 `json:"distance"`
|
||||
Time uint64 `json:"time"`
|
||||
Distance uint64 `json:"distance"`
|
||||
TimePer500m float64 `json:"timePer500m"`
|
||||
Calories float64 `json:"calories"`
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func migrateDatabase(db *gorm.DB) error {
|
||||
&models.Activity{},
|
||||
&models.Favorite{},
|
||||
&models.Rowing{},
|
||||
&models.Message{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
65
backend/services/seed.go
Normal file
65
backend/services/seed.go
Normal 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")
|
||||
}
|
||||
@@ -1,33 +1,64 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const maxMessages = 50
|
||||
|
||||
var allowedDomain string
|
||||
|
||||
var Upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
origin = strings.TrimPrefix(origin, "https://")
|
||||
origin = strings.TrimPrefix(origin, "http://")
|
||||
return origin == allowedDomain || origin == "www."+allowedDomain
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
clients = make(map[*websocket.Conn]bool)
|
||||
messages = make([]models.Message, 0)
|
||||
mu sync.Mutex
|
||||
wsDB *gorm.DB
|
||||
nextAuthorID uint
|
||||
)
|
||||
|
||||
const (
|
||||
rateLimitWindow = time.Second
|
||||
rateLimitMaxMsgs = 10
|
||||
)
|
||||
|
||||
func InitWebSocket(database *gorm.DB, domain string) {
|
||||
wsDB = database
|
||||
allowedDomain = domain
|
||||
}
|
||||
|
||||
func HandleWebSocket(conn *websocket.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
mu.Lock()
|
||||
clients[conn] = true
|
||||
nextAuthorID++
|
||||
authorID := nextAuthorID
|
||||
|
||||
// Send existing message history to new client
|
||||
for _, msg := range messages {
|
||||
var history []models.Message
|
||||
wsDB.Order("created_at ASC").Limit(maxMessages).Find(&history)
|
||||
|
||||
for _, msg := range history {
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
mu.Unlock()
|
||||
return
|
||||
@@ -35,17 +66,32 @@ func HandleWebSocket(conn *websocket.Conn) {
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
msgCount := 0
|
||||
windowStart := time.Now()
|
||||
|
||||
for {
|
||||
var incoming models.Message
|
||||
if err := conn.ReadJSON(&incoming); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
incoming.CreatedAt = time.Now()
|
||||
now := time.Now()
|
||||
if now.Sub(windowStart) > rateLimitWindow {
|
||||
msgCount = 0
|
||||
windowStart = now
|
||||
}
|
||||
msgCount++
|
||||
if msgCount > rateLimitMaxMsgs {
|
||||
continue
|
||||
}
|
||||
|
||||
incoming.AuthorID = authorID
|
||||
|
||||
// Store and broadcast
|
||||
mu.Lock()
|
||||
messages = append(messages, incoming)
|
||||
wsDB.Create(&incoming)
|
||||
wsDB.Where("id NOT IN (?)",
|
||||
wsDB.Model(&models.Message{}).Select("id").Order("created_at DESC").Limit(maxMessages),
|
||||
).Delete(&models.Message{})
|
||||
|
||||
for client := range clients {
|
||||
if err := client.WriteJSON(incoming); err != nil {
|
||||
@@ -56,7 +102,6 @@ func HandleWebSocket(conn *websocket.Conn) {
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Cleanup on disconnect
|
||||
mu.Lock()
|
||||
delete(clients, conn)
|
||||
mu.Unlock()
|
||||
|
||||
10
docker-compose.dev.yml
Normal file
10
docker-compose.dev.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
services:
|
||||
nginx:
|
||||
environment:
|
||||
- DEV_MODE=true
|
||||
- SEED_DB=true
|
||||
ports:
|
||||
- 80:80
|
||||
certbot:
|
||||
profiles:
|
||||
- disabled
|
||||
@@ -4,6 +4,7 @@ networks:
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
uploads:
|
||||
|
||||
services:
|
||||
nginx:
|
||||
@@ -12,10 +13,11 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
container_name: nginx
|
||||
env_file: ./.env
|
||||
restart: unless-stopped
|
||||
restart: always
|
||||
depends_on:
|
||||
- backend
|
||||
- icecast2
|
||||
- gitea
|
||||
networks:
|
||||
- app-network
|
||||
ports:
|
||||
@@ -24,6 +26,7 @@ services:
|
||||
volumes:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
- uploads:/uploads
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
@@ -33,6 +36,8 @@ services:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
entrypoint: ["/entrypoint.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
@@ -41,7 +46,7 @@ services:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: "${BACKEND_HOST}"
|
||||
restart: unless-stopped
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
@@ -52,11 +57,12 @@ services:
|
||||
- ./backend/token/:/backend/token
|
||||
- ${OBSIDIAN_DIR}:/backend/notes
|
||||
- ./logs:/backend/logs
|
||||
- uploads:/backend/uploads
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: "${POSTGRES_HOST}"
|
||||
restart: unless-stopped
|
||||
restart: always
|
||||
env_file:
|
||||
- ./.env
|
||||
networks:
|
||||
@@ -74,5 +80,47 @@ services:
|
||||
- app-network
|
||||
env_file:
|
||||
- ./.env
|
||||
|
||||
gitea-runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: "${GITEA_RUNNER_HOST}"
|
||||
profiles:
|
||||
- disabled
|
||||
environment:
|
||||
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
|
||||
CONFIG_FILE: /config.yaml
|
||||
GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN}
|
||||
GITEA_INSTANCE_URL: "http://${GITEA_HOST}:3000"
|
||||
GITEA_RUNNER_LABELS: "self-hosted:host"
|
||||
volumes:
|
||||
- ./gitea-runner/config.yaml:/config.yaml
|
||||
- ./gitea-runner/data:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock # WARNING: Docker socket mount gives container host-level access. Runner is in 'disabled' profile to mitigate risk.
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
gitea:
|
||||
image: docker.gitea.com/gitea:1.25.4-rootless
|
||||
container_name: "${GITEA_HOST}"
|
||||
networks:
|
||||
- app-network
|
||||
environment:
|
||||
- GITEA__database__DB_TYPE=postgres
|
||||
- GITEA__database__HOST=${POSTGRES_HOST}
|
||||
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
|
||||
- GITEA__database__USER=${POSTGRES_USER}
|
||||
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
restart: always
|
||||
volumes:
|
||||
- ./gitea/data:/var/lib/gitea
|
||||
- ./gitea/config:/etc/gitea
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "${ICECAST_PORT}:${ICECAST_PORT}"
|
||||
- "2222:2222"
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
110
gitea-runner/config.yaml
Normal file
110
gitea-runner/config.yaml
Normal file
@@ -0,0 +1,110 @@
|
||||
# Example configuration file, it's safe to copy this as the default config file without any modification.
|
||||
|
||||
# You don't have to copy this file to your instance,
|
||||
# just run `./act_runner generate-config > config.yaml` to generate a config file.
|
||||
|
||||
log:
|
||||
# The level of logging, can be trace, debug, info, warn, error, fatal
|
||||
level: info
|
||||
|
||||
runner:
|
||||
# Where to store the registration result.
|
||||
file: .runner
|
||||
# Execute how many tasks concurrently at the same time.
|
||||
capacity: 1
|
||||
# Extra environment variables to run jobs.
|
||||
envs:
|
||||
A_TEST_ENV_NAME_1: a_test_env_value_1
|
||||
A_TEST_ENV_NAME_2: a_test_env_value_2
|
||||
# Extra environment variables to run jobs from a file.
|
||||
# It will be ignored if it's empty or the file doesn't exist.
|
||||
env_file: .env
|
||||
# The timeout for a job to be finished.
|
||||
# Please note that the Gitea instance also has a timeout (3h by default) for the job.
|
||||
# So the job could be stopped by the Gitea instance if it's timeout is shorter than this.
|
||||
timeout: 3h
|
||||
# The timeout for the runner to wait for running jobs to finish when shutting down.
|
||||
# Any running jobs that haven't finished after this timeout will be cancelled.
|
||||
shutdown_timeout: 0s
|
||||
# Whether skip verifying the TLS certificate of the Gitea instance.
|
||||
insecure: false
|
||||
# The timeout for fetching the job from the Gitea instance.
|
||||
fetch_timeout: 5s
|
||||
# The interval for fetching the job from the Gitea instance.
|
||||
fetch_interval: 2s
|
||||
# The github_mirror of a runner is used to specify the mirror address of the github that pulls the action repository.
|
||||
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
||||
# and github_mirror is not empty. In this case,
|
||||
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
||||
github_mirror: ''
|
||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
||||
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
# Find more images provided by Gitea at https://gitea.com/docker.gitea.com/runner-images .
|
||||
# If it's empty when registering, it will ask for inputting labels.
|
||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||
labels:
|
||||
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
||||
- "ubuntu-20.04:docker://docker.gitea.com/runner-images:ubuntu-20.04"
|
||||
|
||||
cache:
|
||||
# Enable cache server to use actions/cache.
|
||||
enabled: true
|
||||
# The directory to store the cache data.
|
||||
# If it's empty, the cache data will be stored in $HOME/.cache/actcache.
|
||||
dir: ""
|
||||
# The host of the cache server.
|
||||
# It's not for the address to listen, but the address to connect from job containers.
|
||||
# So 0.0.0.0 is a bad choice, leave it empty to detect automatically.
|
||||
host: ""
|
||||
# The port of the cache server.
|
||||
# 0 means to use a random available port.
|
||||
port: 0
|
||||
# The external cache server URL. Valid only when enable is true.
|
||||
# If it's specified, act_runner will use this URL as the ACTIONS_CACHE_URL rather than start a server by itself.
|
||||
# The URL should generally end with "/".
|
||||
external_server: ""
|
||||
|
||||
container:
|
||||
# Specifies the network to which the container will connect.
|
||||
# Could be host, bridge or the name of a custom network.
|
||||
# If it's empty, act_runner will create a network automatically.
|
||||
network: ""
|
||||
# Whether to use privileged mode or not when launching task containers (privileged mode is required for Docker-in-Docker).
|
||||
privileged: false
|
||||
# And other options to be used when the container is started (eg, --add-host=my.gitea.url:host-gateway).
|
||||
options:
|
||||
# The parent directory of a job's working directory.
|
||||
# NOTE: There is no need to add the first '/' of the path as act_runner will add it automatically.
|
||||
# If the path starts with '/', the '/' will be trimmed.
|
||||
# For example, if the parent directory is /path/to/my/dir, workdir_parent should be path/to/my/dir
|
||||
# If it's empty, /workspace will be used.
|
||||
workdir_parent:
|
||||
# Volumes (including bind mounts) can be mounted to containers. Glob syntax is supported, see https://github.com/gobwas/glob
|
||||
# You can specify multiple volumes. If the sequence is empty, no volumes can be mounted.
|
||||
# For example, if you only allow containers to mount the `data` volume and all the json files in `/src`, you should change the config to:
|
||||
# valid_volumes:
|
||||
# - data
|
||||
# - /src/*.json
|
||||
# If you want to allow any volume, please use the following configuration:
|
||||
# valid_volumes:
|
||||
# - '**'
|
||||
valid_volumes: []
|
||||
# overrides the docker client host with the specified one.
|
||||
# If it's empty, act_runner will find an available docker host automatically.
|
||||
# If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
||||
# If it's not empty or "-", the specified docker host will be used. An error will be returned if it doesn't work.
|
||||
docker_host: ""
|
||||
# Pull docker image(s) even if already present
|
||||
force_pull: true
|
||||
# Rebuild docker image(s) even if already present
|
||||
force_rebuild: false
|
||||
# Always require a reachable docker daemon, even if not required by act_runner
|
||||
require_docker: false
|
||||
# Timeout to wait for the docker daemon to be reachable, if docker is required by require_docker or act_runner
|
||||
docker_timeout: 0s
|
||||
|
||||
host:
|
||||
# The parent directory of a job's working directory.
|
||||
# If it's empty, $HOME/.cache/act/ will be used.
|
||||
workdir_parent:
|
||||
98
gitea/config/app.ini
Normal file
98
gitea/config/app.ini
Normal file
@@ -0,0 +1,98 @@
|
||||
APP_NAME = Gitea: Git with a cup of tea
|
||||
RUN_USER = git
|
||||
RUN_MODE = prod
|
||||
WORK_PATH = /var/lib/gitea
|
||||
|
||||
[repository]
|
||||
ROOT = /var/lib/gitea/git/repositories
|
||||
|
||||
[repository.local]
|
||||
LOCAL_COPY_PATH = /tmp/gitea/local-repo
|
||||
|
||||
[repository.upload]
|
||||
TEMP_PATH = /tmp/gitea/uploads
|
||||
|
||||
[server]
|
||||
APP_DATA_PATH = /var/lib/gitea
|
||||
SSH_DOMAIN = adam-french.co.uk
|
||||
HTTP_PORT = 3000
|
||||
ROOT_URL = https://adam-french.co.uk/gitea/
|
||||
DISABLE_SSH = false
|
||||
; In rootless gitea container only internal ssh server is supported
|
||||
START_SSH_SERVER = true
|
||||
SSH_PORT = 2222
|
||||
SSH_LISTEN_PORT = 2222
|
||||
BUILTIN_SSH_SERVER_USER = git
|
||||
LFS_START_SERVER = true
|
||||
DOMAIN = stppi.local
|
||||
LFS_JWT_SECRET = XHIJprS_aMv0tizioZpUD38GGqTtNMFXMz1R6LuPvjU
|
||||
OFFLINE_MODE = true
|
||||
|
||||
[database]
|
||||
PATH = /var/lib/gitea/data/gitea.db
|
||||
DB_TYPE = postgres
|
||||
HOST = db
|
||||
NAME = gitea
|
||||
USER = postgres
|
||||
PASSWD = password
|
||||
SCHEMA =
|
||||
SSL_MODE = disable
|
||||
LOG_SQL = false
|
||||
|
||||
[session]
|
||||
PROVIDER_CONFIG = /var/lib/gitea/data/sessions
|
||||
PROVIDER = file
|
||||
|
||||
[picture]
|
||||
AVATAR_UPLOAD_PATH = /var/lib/gitea/data/avatars
|
||||
REPOSITORY_AVATAR_UPLOAD_PATH = /var/lib/gitea/data/repo-avatars
|
||||
|
||||
[attachment]
|
||||
PATH = /var/lib/gitea/data/attachments
|
||||
|
||||
[log]
|
||||
ROOT_PATH = /var/lib/gitea/data/log
|
||||
MODE = console
|
||||
LEVEL = info
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
SECRET_KEY =
|
||||
REVERSE_PROXY_LIMIT = 1
|
||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzEyNDMyMTd9.yHsgFcEwDNWmZebftpe8tpWRFa5aR5tkpQuVYybeVaY
|
||||
PASSWORD_HASH_ALGO = pbkdf2
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
REGISTER_EMAIL_CONFIRM = false
|
||||
ENABLE_NOTIFY_MAIL = false
|
||||
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
|
||||
ENABLE_CAPTCHA = false
|
||||
DEFAULT_KEEP_EMAIL_PRIVATE = false
|
||||
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
||||
DEFAULT_ENABLE_TIMETRACKING = true
|
||||
NO_REPLY_ADDRESS = noreply.localhost
|
||||
|
||||
[lfs]
|
||||
PATH = /var/lib/gitea/git/lfs
|
||||
|
||||
[mailer]
|
||||
ENABLED = false
|
||||
|
||||
[openid]
|
||||
ENABLE_OPENID_SIGNIN = true
|
||||
ENABLE_OPENID_SIGNUP = true
|
||||
|
||||
[cron.update_checker]
|
||||
ENABLED = false
|
||||
|
||||
[repository.pull-request]
|
||||
DEFAULT_MERGE_STYLE = merge
|
||||
|
||||
[repository.signing]
|
||||
DEFAULT_TRUST_MODEL = committer
|
||||
|
||||
[oauth2]
|
||||
JWT_SECRET = pYiwW8xxGi23gysl2pa-02Cf567Z5ERvR6DDFGIn2iQ
|
||||
@@ -26,6 +26,7 @@ RUN mkdir -p /etc/nginx/html \
|
||||
|
||||
COPY nginx.conf.template /etc/nginx/nginx.conf.template
|
||||
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.conf.template
|
||||
COPY nginx_dev.conf.template /etc/nginx/nginx_dev.conf.template
|
||||
COPY robots.txt /etc/nginx/html/robots.txt
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Check if certificate exists
|
||||
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
|
||||
# Check if dev mode, certificate exists, or setup mode
|
||||
if [ "$DEV_MODE" = "true" ]; then
|
||||
echo "Dev mode. Using HTTP-only nginx config."
|
||||
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
|
||||
</etc/nginx/nginx_dev.conf.template \
|
||||
>/etc/nginx/nginx.conf
|
||||
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
|
||||
echo "Certificates found. Using production nginx config."
|
||||
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT}' \
|
||||
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
|
||||
@@ -12,5 +17,8 @@ else
|
||||
envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf
|
||||
fi
|
||||
|
||||
# Ensure upload directory is traversable by nginx worker
|
||||
chmod 755 /uploads 2>/dev/null || true
|
||||
|
||||
# Start nginx
|
||||
nginx -g 'daemon off;'
|
||||
|
||||
@@ -9,6 +9,12 @@ http {
|
||||
server_tokens off;
|
||||
charset utf-8;
|
||||
|
||||
client_max_body_size 10M;
|
||||
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||
|
||||
log_format compact
|
||||
'$remote_addr "$request" $status rt=$request_time';
|
||||
|
||||
@@ -55,6 +61,13 @@ http {
|
||||
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
||||
|
||||
location /uploads/ {
|
||||
alias /uploads/;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Content-Disposition "inline" always;
|
||||
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
@@ -77,7 +90,40 @@ http {
|
||||
return 301 $BACKEND_ENDPOINT/;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/ws {
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/auth/login {
|
||||
limit_req zone=login burst=3 nodelay;
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/messages/upload {
|
||||
limit_req zone=upload burst=3 nodelay;
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
@@ -98,6 +144,18 @@ http {
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /gitea {
|
||||
return 301 /gitea/;
|
||||
}
|
||||
|
||||
location /gitea/ {
|
||||
proxy_pass http://$GITEA_HOST:$GITEA_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
127
nginx/nginx_dev.conf.template
Normal file
127
nginx/nginx_dev.conf.template
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
281
nginx/vue/package-lock.json
generated
281
nginx/vue/package-lock.json
generated
@@ -10,12 +10,14 @@
|
||||
"dependencies": {
|
||||
"@mdit/plugin-katex": "^0.24.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"axios": "^1.13.2",
|
||||
"katex": "^0.16.27",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-wikilinks": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
@@ -1022,9 +1024,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
||||
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1035,9 +1037,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1048,9 +1050,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1061,9 +1063,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1074,9 +1076,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1087,9 +1089,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1100,9 +1102,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
|
||||
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1113,9 +1115,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
|
||||
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1126,9 +1128,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1139,9 +1141,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1152,9 +1154,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1165,9 +1167,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1178,9 +1180,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1191,9 +1193,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1204,9 +1206,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1217,9 +1219,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1230,9 +1232,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1243,9 +1245,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1256,9 +1258,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1269,9 +1271,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1282,9 +1284,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1295,9 +1297,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1308,9 +1310,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1321,9 +1323,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1334,9 +1336,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1631,6 +1633,12 @@
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-bluetooth": {
|
||||
"version": "0.0.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
|
||||
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
|
||||
@@ -1916,6 +1924,44 @@
|
||||
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vueuse/core": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz",
|
||||
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/web-bluetooth": "^0.0.21",
|
||||
"@vueuse/metadata": "14.2.1",
|
||||
"@vueuse/shared": "14.2.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/metadata": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz",
|
||||
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/@vueuse/shared": {
|
||||
"version": "14.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz",
|
||||
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansis": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
|
||||
@@ -1939,13 +1985,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.13.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||
"version": "1.13.5",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
|
||||
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.4",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
@@ -2936,9 +2982,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
@@ -3199,9 +3245,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
||||
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -3214,31 +3260,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.55.1",
|
||||
"@rollup/rollup-android-arm64": "4.55.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.55.1",
|
||||
"@rollup/rollup-darwin-x64": "4.55.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.55.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.55.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.55.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.55.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.55.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.55.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.55.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.55.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.55.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -3382,6 +3428,19 @@
|
||||
"utf8-byte-length": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
"dependencies": {
|
||||
"@mdit/plugin-katex": "^0.24.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"axios": "^1.13.2",
|
||||
"katex": "^0.16.27",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-wikilinks": "^1.4.0",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5.9.3",
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.6.3"
|
||||
},
|
||||
|
||||
BIN
nginx/vue/public/img/miku/miku1.gif
Normal file
BIN
nginx/vue/public/img/miku/miku1.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
nginx/vue/public/img/miku/miku2.gif
Normal file
BIN
nginx/vue/public/img/miku/miku2.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
BIN
nginx/vue/public/img/miku/miku2.png
Normal file
BIN
nginx/vue/public/img/miku/miku2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
BIN
nginx/vue/public/img/screenshot.png
Normal file
BIN
nginx/vue/public/img/screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 650 KiB |
BIN
nginx/vue/public/pdf/transcript.pdf
Normal file
BIN
nginx/vue/public/pdf/transcript.pdf
Normal file
Binary file not shown.
@@ -2,6 +2,7 @@
|
||||
|
||||
/* PRINTING */
|
||||
@media print {
|
||||
|
||||
.no-print,
|
||||
.no-print * {
|
||||
display: none !important;
|
||||
@@ -11,6 +12,7 @@
|
||||
height: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
/* END OF PRINTING */
|
||||
|
||||
/* FONTS */
|
||||
@@ -27,6 +29,7 @@
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* END OF FONTS */
|
||||
|
||||
/* VARIABLES */
|
||||
@@ -75,6 +78,7 @@
|
||||
--font-heading: var(--font_heading);
|
||||
--default-font-family: var(--font_default);
|
||||
}
|
||||
|
||||
/* END OF VARIABLES */
|
||||
/* ELEMENTS */
|
||||
body {
|
||||
@@ -118,9 +122,11 @@ h3,
|
||||
h4 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-xl;
|
||||
}
|
||||
@@ -130,7 +136,7 @@ p {
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-primary bg-link text-center font-heading text-xl tracking-wide;
|
||||
@apply text-primary bg-link text-center font-heading tracking-wide;
|
||||
}
|
||||
|
||||
input,
|
||||
@@ -219,18 +225,24 @@ td {
|
||||
/* PHONE */
|
||||
@media (max-width: 850px) {
|
||||
.a4page-portrait {
|
||||
width: 100%; /* fill mobile width */
|
||||
width: 100%;
|
||||
/* fill mobile width */
|
||||
height: fit-content;
|
||||
margin: 0 auto; /* center horizontally */
|
||||
margin: 0 auto;
|
||||
/* center horizontally */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.a4page-landscape {
|
||||
width: 100%; /* fill mobile width */
|
||||
width: 100%;
|
||||
/* fill mobile width */
|
||||
height: fit-content;
|
||||
margin: 0 auto; /* center horizontally */
|
||||
margin: 0 auto;
|
||||
/* center horizontally */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.a5page-portrait {
|
||||
width: 100%;
|
||||
@@ -238,6 +250,7 @@ td {
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.a5page-landscape {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
@@ -249,12 +262,15 @@ td {
|
||||
.tl {
|
||||
@apply absolute top-0 left-0;
|
||||
}
|
||||
|
||||
.tr {
|
||||
@apply absolute top-0 right-0;
|
||||
}
|
||||
|
||||
.bl {
|
||||
@apply absolute bottom-0 left-0;
|
||||
}
|
||||
|
||||
.br {
|
||||
@apply absolute bottom-0 right-0;
|
||||
}
|
||||
@@ -270,17 +286,9 @@ td {
|
||||
--blur: 0%;
|
||||
|
||||
background-color: var(--bg_secondary);
|
||||
background-image: radial-gradient(
|
||||
circle at center,
|
||||
background-image: radial-gradient(circle at center,
|
||||
var(--bg_primary) var(--dot_size),
|
||||
transparent var(--blur)
|
||||
);
|
||||
transparent var(--blur));
|
||||
background-size: var(--bg_size) var(--bg_size);
|
||||
background-position: 0 0;
|
||||
|
||||
mask-image: linear-gradient(
|
||||
30deg,
|
||||
rgba(1, 1, 1, 1) 0%,
|
||||
rgba(1, 1, 1, 0.9) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,14 +49,10 @@ const faces_string = faces.join(" ");
|
||||
<RouterLink class="bdr-2 bg-bg_primary" to="/" v-if="!inHome">
|
||||
<a>HOME</a>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
class="bdr-2 bg-bg_primary"
|
||||
v-if="parentPath"
|
||||
:to="parentPath"
|
||||
>
|
||||
<RouterLink class="bdr-2 bg-bg_primary" v-if="parentPath" :to="parentPath">
|
||||
<a>UP</a>
|
||||
</RouterLink>
|
||||
<Headline class="border flex-1">
|
||||
<Headline class="border flex-1 max-w-full">
|
||||
<code class="whitespace-pre">{{ faces_string }}</code>
|
||||
</Headline>
|
||||
</nav>
|
||||
|
||||
@@ -1,79 +1,85 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, useTemplateRef, onUnmounted } from "vue";
|
||||
import { onMounted, useTemplateRef, onUnmounted } from "vue";
|
||||
|
||||
const container = useTemplateRef("container");
|
||||
const item1 = useTemplateRef("item1");
|
||||
const item2 = useTemplateRef("item2");
|
||||
|
||||
let offset = 0;
|
||||
let cachedWidth = 0;
|
||||
|
||||
let rafId;
|
||||
|
||||
const speed = 0.5; // pixels per frame
|
||||
|
||||
function animate() {
|
||||
function measureWidth() {
|
||||
const ctnr = container.value;
|
||||
const it1 = item1.value;
|
||||
const it2 = item2.value;
|
||||
if (ctnr && it1) {
|
||||
cachedWidth = Math.max(ctnr.offsetWidth, it1.scrollWidth);
|
||||
}
|
||||
}
|
||||
|
||||
const width = Math.max(ctnr.offsetWidth, it1.scrollWidth);
|
||||
function animate() {
|
||||
const ctnr = container.value;
|
||||
if (!ctnr || cachedWidth === 0) {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
offset -= speed;
|
||||
|
||||
if (offset <= -width) {
|
||||
offset += width;
|
||||
if (offset <= -cachedWidth) {
|
||||
offset += cachedWidth;
|
||||
}
|
||||
|
||||
it1.style.transform = `translateX(${offset}px)`;
|
||||
it2.style.transform = `translateX(${width + offset}px)`;
|
||||
ctnr.style.transform = `translateX(${offset}px)`;
|
||||
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
let resizeObserver;
|
||||
|
||||
onMounted(() => {
|
||||
measureWidth();
|
||||
rafId = requestAnimationFrame(animate);
|
||||
|
||||
resizeObserver = new ResizeObserver(measureWidth);
|
||||
resizeObserver.observe(container.value);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(rafId);
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="marquee">
|
||||
<div class="root">
|
||||
<div class="container" ref="container">
|
||||
<div class="item" ref="item1"><slot /></div>
|
||||
<div class="item item2" ref="item2"><slot /></div>
|
||||
<div ref="item1">
|
||||
<slot />
|
||||
</div>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.marquee {
|
||||
.root {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
position: relative;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.item {
|
||||
height: fit-content;
|
||||
top: 0px;
|
||||
padding-right: 3em;
|
||||
width: fit-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item1 {
|
||||
left: 0px;
|
||||
}
|
||||
|
||||
.item2 {
|
||||
position: absolute;
|
||||
height: fit-content;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: max-content;
|
||||
/* Each column fits its content */
|
||||
overflow-x: visible;
|
||||
will-change: transform;
|
||||
gap: 10em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,12 +9,19 @@ import { useTemplateRef, onMounted, onBeforeUnmount } from "vue";
|
||||
|
||||
const container = useTemplateRef("container");
|
||||
|
||||
const SPEED = 1; // px per frame
|
||||
const SPEED = 0.0005; // % per frame
|
||||
const PAUSE = 2000; // ms at top/bottom
|
||||
|
||||
let pos = 0;
|
||||
let direction = 1; // 1 = down, -1 = up
|
||||
let timeoutId;
|
||||
let timeoutId2;
|
||||
let cachedScrollHeight = 0;
|
||||
|
||||
function measureScrollHeight() {
|
||||
const el = container.value;
|
||||
if (el) cachedScrollHeight = el.scrollHeight;
|
||||
}
|
||||
|
||||
function handleHover() {
|
||||
cancelAnimationFrame(timeoutId);
|
||||
@@ -27,25 +34,45 @@ function handleHover() {
|
||||
|
||||
function tick() {
|
||||
const el = container.value;
|
||||
el.scrollTop += SPEED * direction;
|
||||
if (!el || cachedScrollHeight === 0) {
|
||||
timeoutId = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
|
||||
const reachedBottom = el.scrollTop + el.clientHeight >= el.scrollHeight;
|
||||
const reachedTop = el.scrollTop <= 0;
|
||||
const reachedBottom = pos <= 0;
|
||||
const reachedTop = pos >= 1;
|
||||
|
||||
if (reachedBottom || reachedTop) {
|
||||
direction *= -1;
|
||||
if (reachedBottom) {
|
||||
pos = 0.001;
|
||||
direction = 1;
|
||||
handleHover();
|
||||
return;
|
||||
} else if (reachedTop) {
|
||||
pos = 0.999;
|
||||
direction = -1;
|
||||
handleHover();
|
||||
return;
|
||||
}
|
||||
|
||||
pos += direction * SPEED;
|
||||
|
||||
el.scrollTop = pos * cachedScrollHeight;
|
||||
|
||||
timeoutId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
let resizeObserver;
|
||||
|
||||
onMounted(() => {
|
||||
measureScrollHeight();
|
||||
timeoutId = requestAnimationFrame(tick);
|
||||
|
||||
resizeObserver = new ResizeObserver(measureScrollHeight);
|
||||
resizeObserver.observe(container.value);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(timeoutId);
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,75 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
|
||||
import Button from "@/components/input/Button.vue";
|
||||
import { useMessagesStore } from "@/stores/messages";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import Header from "@/components/text/Header.vue";
|
||||
|
||||
const messagesStore = useMessagesStore();
|
||||
const authStore = useAuthStore();
|
||||
const messages = computed(() => messagesStore.messages);
|
||||
const messageInput = ref("");
|
||||
const messagesContainer = ref(null);
|
||||
const fileInput = ref(null);
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop =
|
||||
messagesContainer.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watch(messages, scrollToBottom, { deep: true });
|
||||
|
||||
function sendMessage() {
|
||||
const text = messageInput.value.trim();
|
||||
if (!text) return;
|
||||
messagesStore.sendMessage(text);
|
||||
messageInput.value = "";
|
||||
}
|
||||
|
||||
async function onFileSelected(e) {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
await messagesStore.uploadAndSendFile(file);
|
||||
fileInput.value.value = "";
|
||||
}
|
||||
|
||||
function isImageUrl(url) {
|
||||
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
|
||||
}
|
||||
|
||||
function isVideoUrl(url) {
|
||||
return /\.(mp4|webm|ogg|mov)$/i.test(url);
|
||||
}
|
||||
|
||||
function isSafeFileUrl(url) {
|
||||
return typeof url === "string" && url.startsWith("/uploads/");
|
||||
}
|
||||
|
||||
const urlRegex = /(https?:\/\/[^\s<]+)/g;
|
||||
|
||||
function parseMessageParts(text) {
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
while ((match = urlRegex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({
|
||||
type: "text",
|
||||
value: text.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
parts.push({ type: "link", value: match[1] });
|
||||
lastIndex = urlRegex.lastIndex;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
parts.push({ type: "text", value: text.slice(lastIndex) });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
messagesStore.connect();
|
||||
@@ -14,15 +80,67 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col">
|
||||
<Header>Chat</Header>
|
||||
<div
|
||||
ref="messagesContainer"
|
||||
class="flex flex-col flex-1 overflow-y-auto p-2"
|
||||
>
|
||||
<p v-for="message in messages" :key="message.id">
|
||||
{{ message.content }}
|
||||
<span class="text-tertiary">{{ message.authorId }}:</span>
|
||||
<template
|
||||
v-for="(part, i) in parseMessageParts(message.text || '')"
|
||||
:key="i"
|
||||
>
|
||||
<a
|
||||
v-if="part.type === 'link'"
|
||||
:href="part.value"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary underline"
|
||||
>{{ part.value }}</a
|
||||
>
|
||||
<span v-else>{{ part.value }}</span>
|
||||
</template>
|
||||
<template
|
||||
v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageUrl(message.fileUrl)"
|
||||
:src="message.fileUrl"
|
||||
class="max-w-xs max-h-48 rounded"
|
||||
/>
|
||||
<video
|
||||
v-else-if="isVideoUrl(message.fileUrl)"
|
||||
:src="message.fileUrl"
|
||||
controls
|
||||
class="max-w-xs max-h-48 rounded"
|
||||
/>
|
||||
<a
|
||||
v-else
|
||||
:href="message.fileUrl"
|
||||
target="_blank"
|
||||
class="underline"
|
||||
>{{ message.fileUrl.split("/").pop() }}</a
|
||||
>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-row">
|
||||
<input v-model="messageInput" @keyup.enter="sendMessage" />
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
<div class="flex justify-between">
|
||||
<Button @click="sendMessage">Send</Button>
|
||||
<Button v-if="authStore.user.admin" @click="fileInput.click()"
|
||||
>Attach</Button
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<Button @click="scrollToBottom">Bottom</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
61
nginx/vue/src/components/util/CommitHistory.vue
Normal file
61
nginx/vue/src/components/util/CommitHistory.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
import axios from "axios";
|
||||
import { ref, onMounted } from "vue";
|
||||
import Header from "@/components/text/Header.vue";
|
||||
|
||||
const url =
|
||||
"/gitea/api/v1/users/adamf/activities/feeds?limit=1";
|
||||
|
||||
const feed = ref(null);
|
||||
const isLoading = ref(true);
|
||||
const hasError = ref(false);
|
||||
|
||||
async function checkFeed() {
|
||||
try {
|
||||
const res = await axios.get(url);
|
||||
feed.value = res.data[0] || null;
|
||||
hasError.value = false;
|
||||
} catch (err) {
|
||||
hasError.value = true;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkFeed();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="justify-center text-center">
|
||||
<Header class="text-left">Commits</Header>
|
||||
|
||||
<div v-if="isLoading">
|
||||
<p>Loading latest activity...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasError">
|
||||
<p>Could not fetch feed. Please try again later.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="feed" class="flex-col justify-center flex">
|
||||
<h3>Last git activity</h3>
|
||||
<img
|
||||
:src="feed.act_user.avatar_url"
|
||||
alt="User avatar"
|
||||
class="avatar"
|
||||
/>
|
||||
<a :href="feed.repo.html_url">
|
||||
<h3>repo: {{ feed.repo.full_name }}</h3>
|
||||
</a>
|
||||
<p>Action: {{ feed.op_type }}</p>
|
||||
<p>Message: {{ JSON.parse(feed.content).Commits[0].Message }}</p>
|
||||
<small> {{ new Date(feed.created).toLocaleString() }}</small>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<p>No activity found.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -13,7 +13,7 @@ const keys = ["name", "link"];
|
||||
|
||||
<template>
|
||||
<a v-for="(row, rowIndex) in linkArr" :key="rowIndex" :href="row.link">
|
||||
<p class="bdr-2 bg-bg_primary">
|
||||
<p class="bdr-2 bg-bg_tertiary">
|
||||
{{ row.name }}
|
||||
</p>
|
||||
</a>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<template>
|
||||
<div v-if="streamLive">
|
||||
<Header>Radio</Header>
|
||||
<img src="/img/tmpen31z3pe.PNG" />
|
||||
<audio controls :src="streamUrl" ref="audio"></audio>
|
||||
</div>
|
||||
<div v-else>
|
||||
<Header>Radio</Header>
|
||||
<img src="/img/tmpen31z3pe.PNG" />
|
||||
<div class="m-1">
|
||||
<p>Stream is offline. Tune in Fridays @ 6:00pm, Monday @ 8:00am</p>
|
||||
<Button @click="checkStream()">Check Stream</Button>
|
||||
<div class="m-1 text-center">
|
||||
<p>Radio is offline. Message for info!</p>
|
||||
<Button class="w-full" @click="checkStream()">Check Stream</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Button from "@/components/input/Button.vue";
|
||||
import Header from "@/components/text/Header.vue";
|
||||
import { ref, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
83
nginx/vue/src/components/util/Slideshow.vue
Normal file
83
nginx/vue/src/components/util/Slideshow.vue
Normal 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>
|
||||
@@ -24,8 +24,8 @@ setInterval(updateDateTime, 60000);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="items-center flex flex-col">
|
||||
<div class="flex flex-col">
|
||||
<Header>{{ weekday }} {{ day }}, {{ month }}</Header>
|
||||
<h1>{{ time }}</h1>
|
||||
<h1>{{ weekday }} {{ day }}, {{ month }}</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script setup>
|
||||
import Button from "@/components/input/Button.vue";
|
||||
import Header from "@/components/text/Header.vue";
|
||||
|
||||
import { ref } from "vue";
|
||||
|
||||
const timer = ref(null);
|
||||
@@ -64,7 +66,7 @@ function playFinishedSound() {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1 p-1 items-center">
|
||||
<h2 class="items-center">Timer</h2>
|
||||
<Header>Timer</Header>
|
||||
<div v-if="finished && paused" class="flex flex-col">
|
||||
<div class="flex flex-row p-2 place-content-around">
|
||||
<input
|
||||
|
||||
@@ -12,12 +12,12 @@ const router = createRouter({
|
||||
{
|
||||
path: "/cv",
|
||||
name: "cv",
|
||||
component: () => import("../views/CV.vue"),
|
||||
component: () => import("../views/CV/CV.vue"),
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
name: "admin",
|
||||
component: () => import("../views/Admin.vue"),
|
||||
component: () => import("../views/admin/Admin.vue"),
|
||||
},
|
||||
{
|
||||
path: "/bookmarks",
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
const URL = "/api/ws";
|
||||
|
||||
const message_template = {
|
||||
id: 0,
|
||||
content: "Yo",
|
||||
};
|
||||
function getWebSocketURL() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/api/ws`;
|
||||
}
|
||||
|
||||
export const useMessagesStore = defineStore("messages", () => {
|
||||
const socket = ref(null);
|
||||
const messages = ref([message_template]);
|
||||
const messages = ref([]);
|
||||
const isConnected = ref(false);
|
||||
const lastError = ref(null);
|
||||
|
||||
@@ -19,7 +18,7 @@ export const useMessagesStore = defineStore("messages", () => {
|
||||
function connect() {
|
||||
if (socket.value && isConnected.value) return;
|
||||
|
||||
socket.value = new WebSocket(URL);
|
||||
socket.value = new WebSocket(getWebSocketURL());
|
||||
|
||||
socket.value.onopen = () => {
|
||||
isConnected.value = true;
|
||||
@@ -31,8 +30,7 @@ export const useMessagesStore = defineStore("messages", () => {
|
||||
const data = JSON.parse(event.data);
|
||||
messages.value.push(data);
|
||||
} catch {
|
||||
// fallback if server sends plain text
|
||||
messages.value.push(event.data);
|
||||
messages.value.push({ text: event.data });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -53,15 +51,28 @@ export const useMessagesStore = defineStore("messages", () => {
|
||||
isConnected.value = false;
|
||||
}
|
||||
|
||||
function sendMessage(payload) {
|
||||
function sendMessage(text) {
|
||||
if (!socket.value || !isConnected.value) return;
|
||||
socket.value.send(JSON.stringify(payload));
|
||||
socket.value.send(JSON.stringify({ text }));
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
messages.value = [];
|
||||
}
|
||||
|
||||
async function uploadAndSendFile(file) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const res = await axios.post("/api/messages/upload", formData);
|
||||
const { url } = res.data;
|
||||
if (!socket.value || !isConnected.value) return;
|
||||
socket.value.send(JSON.stringify({ text: "", fileUrl: url }));
|
||||
} catch (err) {
|
||||
lastError.value = err;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
isConnected,
|
||||
@@ -73,5 +84,6 @@ export const useMessagesStore = defineStore("messages", () => {
|
||||
disconnect,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
uploadAndSendFile,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,603 +0,0 @@
|
||||
<template>
|
||||
<main>
|
||||
<div class="a4page">
|
||||
<div class="contact">
|
||||
<h1>Adam French</h1>
|
||||
<!-- <a href="covers.html"><img width=25 height=50 src="img/rune.png"></a> -->
|
||||
<div class="contact-details">
|
||||
<p>+447563266931</p>
|
||||
<p>adam.a.french@outlook.com</p>
|
||||
<p>www.adam-french.co.uk</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Profile</h2>
|
||||
<p>
|
||||
Recently graduated from the University of Leeds with a BSc
|
||||
Computer Science with Mathematics (International) degree.
|
||||
Currently self-studying and building projects aligned with the
|
||||
type of roles I am seeking. I have a strong background across a
|
||||
variety of programming languages and will be able to quickly get
|
||||
on board with any codebase.
|
||||
</p>
|
||||
<p>
|
||||
I am most keen to work for a company with altruistic values and
|
||||
a focus on durable solutions. Looking forward to learning from
|
||||
experts and collaborating with motivated individuals.
|
||||
</p>
|
||||
|
||||
<h2>Personal Projects</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Skills</th>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Personal Websites</td>
|
||||
<td>Nginx, Vue, Postgres, Docker, Go, Python</td>
|
||||
<td>Ongoing</td>
|
||||
<td class="row-leftalign">
|
||||
My personal site, Currently
|
||||
<b>self hosted</b>
|
||||
using <b>listed skills</b>. In the past, I have used
|
||||
Svelte, React/Redux, SQLite, Rust and Deno.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Computer Graphics</td>
|
||||
<td>Rust, Linear Algebra, Multithreading</td>
|
||||
<td>2023</td>
|
||||
<td class="row-leftalign">
|
||||
A multithreaded, recursive ray tracer implemented in
|
||||
Rust.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Mobile Automata</td>
|
||||
<td>Mathematica, JS</td>
|
||||
<td>2024</td>
|
||||
<td class="row-leftalign">
|
||||
Investigated properties of cellular automata by
|
||||
observing emergent behaviors through custom
|
||||
simulations.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Arduino Programming & Circuits</td>
|
||||
<td>C++, Soldering, Embedded Systems</td>
|
||||
<td>2022 - 2025</td>
|
||||
<td class="row-leftalign">
|
||||
Created decorations using salvaged components from
|
||||
discarded electronics.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Memory Palace Website</td>
|
||||
<td>TS, Rust, React, Redux, SQLite</td>
|
||||
<td>2025</td>
|
||||
<td class="row-leftalign">
|
||||
Full-stack web application aiming to make the
|
||||
“memory palace” memorization technique easy.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3D Printing</td>
|
||||
<td>FreeCAD</td>
|
||||
<td>Ongoing</td>
|
||||
<td class="row-leftalign">
|
||||
Designing quality of life objects using FreeCAD and
|
||||
printing with a BambuLab A1.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Education</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Location</th>
|
||||
<th>Date</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>The University of Leeds</td>
|
||||
<td>
|
||||
<!-- <div style="display: flex; flex-direction: column; align-items: center;"> -->
|
||||
<!-- <span>2021</span> -->
|
||||
<!-- <span>to</span> -->
|
||||
<!-- <span>2025</span> -->
|
||||
<!-- </div> -->
|
||||
2021-2025
|
||||
</td>
|
||||
<td class="row-leftalign">
|
||||
<strong
|
||||
>BSc Computer Science with Mathematics
|
||||
(International)</strong
|
||||
><br />
|
||||
<strong
|
||||
>Average:
|
||||
81.1%           (First
|
||||
Class Honours) </strong
|
||||
><br />
|
||||
<strong>Relevant Courses: </strong>
|
||||
Procedural Programming, Object Oriented Programming,
|
||||
Algorithms and Data Structures I & II, Databases,
|
||||
Computer Processors, Compiler Design and
|
||||
Construction, Formal Languages and Finite Automata,
|
||||
Probability and Statistics I, Machine Learning,
|
||||
Graph Algorithms & Complexity Theory
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>The University of Waterloo</td>
|
||||
<td>
|
||||
<!-- <div style="display: flex; flex-direction: column; align-items: center;"> -->
|
||||
<!-- <span>2023</span> -->
|
||||
<!-- <span>to</span> -->
|
||||
<!-- <span>2024</span> -->
|
||||
<!-- </div> -->
|
||||
2023-2024
|
||||
</td>
|
||||
<td class="row-leftalign">
|
||||
<strong>Average: 74.5%</strong>
|
||||
<br />
|
||||
<strong>Relevant Courses:</strong>
|
||||
Applied Cryptography, Introduction to Computer
|
||||
Graphics, Introduction to Rings and Fields with
|
||||
Applications<br /><br />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="a4page">
|
||||
<h2>Experience</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>Location</th>
|
||||
<th>Date</th>
|
||||
<th>Duties</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Student</td>
|
||||
<td>Wolfram Summer School</td>
|
||||
<td>2024</td>
|
||||
<td class="row-leftalign">
|
||||
Designed and completed a time-constrained research
|
||||
project exploring Mobile Automata and conditions for
|
||||
computational reversibility. Communicated findings
|
||||
through visualizations and presentations.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Bartender, Waiter, Cashier</td>
|
||||
<td>Hospitality Venues</td>
|
||||
<td>2018-2023</td>
|
||||
<td class="row-leftalign">
|
||||
Delivered heartfelt customer service in various
|
||||
fast-paced, high-pressure hospitality environments.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Commitments</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Activity</th>
|
||||
<th>Date</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Learning Mandarin</td>
|
||||
<td>Ongoing</td>
|
||||
<td class="row-leftalign">
|
||||
Aiming to complete HSK 3 proficiency exam by
|
||||
December 2026
|
||||
</td>
|
||||
</tr>
|
||||
<!-- <tr> -->
|
||||
<!-- <td>Cybersecurity Training</td> -->
|
||||
<!-- <td>Ongoing</td> -->
|
||||
<!-- <td class="row-leftalign"> -->
|
||||
<!-- Using <em>pwn.college, tryhackme.com</em> to learn pentesting techniques.</td> -->
|
||||
<!-- </tr> -->
|
||||
<tr>
|
||||
<td>Sports Activities</td>
|
||||
<td>Ongoing</td>
|
||||
<td class="row-leftalign">
|
||||
Run weekly, active gym attendee, regularly go
|
||||
hiking.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>meetup.com</td>
|
||||
<td>Ongoing</td>
|
||||
<td class="row-leftalign">
|
||||
Attending various tech meetups and social events.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Boardgames</td>
|
||||
<td>Ongoing</td>
|
||||
<td class="row-leftalign">
|
||||
Meet up regularly to play the game
|
||||
<i>Root</i>.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Leetcode</td>
|
||||
<td>Ongoing</td>
|
||||
<td class="row-leftalign">
|
||||
Do the leetcode daily challenge and hone in on
|
||||
different programming languages.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Construction and Landscaping</td>
|
||||
<td>Ongoing</td>
|
||||
<td class="row-leftalign">
|
||||
Involved in building a house in Bulgaria.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>University of Waterloo Film Club</td>
|
||||
<td>2023-2024</td>
|
||||
<td class="row-leftalign">
|
||||
Worked on student films <em>“Moon King”</em> and
|
||||
<em>“HAM”</em>, available online.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Socratica</td>
|
||||
<td>2023-2024</td>
|
||||
<td class="row-leftalign">
|
||||
Worked with individuals exploring innovative tech.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>University of Leeds Hockey Club</td>
|
||||
<td>2022-2023</td>
|
||||
<td class="row-leftalign">
|
||||
Played for the University of Leeds Hockey Club.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Royal Air Force Air Cadets</td>
|
||||
<td>2017-2020</td>
|
||||
<td class="row-leftalign">
|
||||
Achieved the role of Sergeant and “Best Cadet"
|
||||
award.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- <div class="interests"> -->
|
||||
<!-- <table> -->
|
||||
<!-- <tr><th>Personal qualities</th></tr> -->
|
||||
<!-- <tr><td>Intuitive</td></tr> -->
|
||||
<!-- <tr><td>Communicative</td></tr> -->
|
||||
<!-- <tr><td>Adaptable</td></tr> -->
|
||||
<!-- <tr><td>Versatile</td></tr> -->
|
||||
<!-- <tr><td>Diligent</td></tr> -->
|
||||
<!-- </table> -->
|
||||
<!-- <table> -->
|
||||
<!-- <tr><th>Interests</th></tr> -->
|
||||
<!-- <tr><td>Neuroscience</td></tr> -->
|
||||
<!-- <tr><td>Bouldering</td></tr> -->
|
||||
<!-- <tr><td>Science Fiction</td></tr> -->
|
||||
<!-- <tr><td>Mathematics</td></tr> -->
|
||||
<!-- <tr><td>Hiking</td></tr> -->
|
||||
<!-- </table> -->
|
||||
<!-- <table> -->
|
||||
<!-- <tr><th>Languages</th></tr> -->
|
||||
<!-- <tr><td>Rust</td></tr> -->
|
||||
<!-- <tr><td>HTML/JS</td></tr> -->
|
||||
<!-- <tr><td>C/C++</td></tr> -->
|
||||
<!-- <tr><td>React/Vue</td></tr> -->
|
||||
<!-- <tr><td>Python</td></tr> -->
|
||||
<!-- </table> -->
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Fonts */
|
||||
/*@font-face {
|
||||
font-family: "AldoTheApache";
|
||||
src: url("/fonts/AldotheApache.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "RobotFont";
|
||||
src: url("/fonts/Robot_Font.otf") format("opentype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "m12";
|
||||
src: url("/fonts/m12.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}*/
|
||||
|
||||
@font-face {
|
||||
font-family: "big_noodle_titling";
|
||||
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "CreatoDisplay";
|
||||
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Variables */
|
||||
* {
|
||||
/* Blue - Beige */
|
||||
/* --primary: #153448;
|
||||
--secondary: #3C5B6F;
|
||||
--tertiary: #948979;
|
||||
--quaternary: #f5bb78;
|
||||
--background: #DFD0B8; */
|
||||
|
||||
/* Blue - Turqouise */
|
||||
/* --primary: #161D6F;
|
||||
--secondary: #0B2F9F;
|
||||
--tertiary: #98DED9;
|
||||
--quaternary: #C7FFD8;
|
||||
--background: #C2EFD1; */
|
||||
|
||||
/* Red - Blue */
|
||||
/* --primary: #ff204e; */
|
||||
/* --secondary: #a0153e; */
|
||||
/* --tertiary: #5d0341; */
|
||||
/* --quaternary: #3a0e41; */
|
||||
/* --background: #00224d; */
|
||||
|
||||
/* Blue - Brown */
|
||||
/* --primary: #35374B; */
|
||||
/* --secondary: #344955; */
|
||||
/* --tertiary: #50727b; */
|
||||
/* --quaternary: #78a083; */
|
||||
/* --background: #c7b077; */
|
||||
|
||||
/* Black - White */
|
||||
--primary: black;
|
||||
--secondary: black;
|
||||
--tertiary: black;
|
||||
--quaternary: #cccccc;
|
||||
--background: white;
|
||||
|
||||
/* Blue - White */
|
||||
/* --primary: #201e43; */
|
||||
/* --secondary: #134b70; */
|
||||
/* --tertiary: #508c9b; */
|
||||
/* --quaternary: #cceeee; */
|
||||
/* --background: #eeeeee; */
|
||||
|
||||
--font-heading: big_noodle_titling;
|
||||
--font-text: CreatoDisplay;
|
||||
--font-size-text: 90%;
|
||||
--font-size-heading: 2.5em;
|
||||
--font-size-subheading: 1.5em;
|
||||
--font-size-tableheading: 1.2em;
|
||||
}
|
||||
|
||||
/* A5 Page */
|
||||
.a5page {
|
||||
/* overflow: scroll; */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: var(--font-text);
|
||||
height: 148mm;
|
||||
width: 210mm;
|
||||
padding: 4mm;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background);
|
||||
box-shadow: 0 20px 20px rgba(0, 0, 0, 0.2);
|
||||
border: solid 2px var(--primary);
|
||||
}
|
||||
|
||||
/* A4 Page */
|
||||
.a4page {
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-text);
|
||||
width: 210mm;
|
||||
/* Standard A4 width */
|
||||
height: 297mm;
|
||||
/* Standard A4 height */
|
||||
padding: 8mm;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--primary);
|
||||
overflow: auto;
|
||||
/* Enables scrolling when content exceeds height */
|
||||
margin: auto auto;
|
||||
/* Centers the page horizontally */
|
||||
}
|
||||
|
||||
/* Component Styling */
|
||||
main {
|
||||
padding: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
span {
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
font-family: var(--font-heading);
|
||||
text-transform: capitalize;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-heading);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0px;
|
||||
margin-bottom: 3px;
|
||||
border-bottom: 1px solid var(--primary);
|
||||
font-size: var(--font-size-subheading);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--secondary);
|
||||
font-size: var(--font-size-text);
|
||||
margin-top: 0.3em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
table {
|
||||
color: var(--secondary);
|
||||
border-collapse: collapse;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
td {
|
||||
/* border: 2px solid var(--tertiary); */
|
||||
color: var(--secondary);
|
||||
border-top: 1px solid var(--tertiary);
|
||||
padding: 1px 10px 1px 10px;
|
||||
font-size: var(--font-size-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--secondary);
|
||||
border: 2px solid var(--tertiary);
|
||||
padding: 1px 0px 1px 7px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-size-tableheading);
|
||||
background-color: var(--quaternary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:visited {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Classes */
|
||||
/* Cover Navigation (for ease of use) */
|
||||
.cover-nav {
|
||||
position: fixed;
|
||||
top: 0.5vh;
|
||||
/* Position the element at the top of the screen */
|
||||
left: 80vw;
|
||||
/* Position the element at the left of the screen */
|
||||
border: 2px solid var(--tertiary);
|
||||
width: 19.5vw;
|
||||
/* Make the element span the width of the screen (optional) */
|
||||
background-color: var(--background);
|
||||
/* Set a background color to avoid overlap issues */
|
||||
z-index: 1000;
|
||||
/* Ensures the element is above other content */
|
||||
}
|
||||
|
||||
.cover-nav td,
|
||||
tr {
|
||||
font-family: var(--font-text);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.cover-nav th {
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid var(--tertiary);
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Cover letter styling */
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
border: 0px solid var(--primary);
|
||||
resize: none;
|
||||
font-family: var(--font-text);
|
||||
}
|
||||
|
||||
/* Contact At Top of Page */
|
||||
.contact {
|
||||
all: unset;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.contact-details {
|
||||
all: unset;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.contact-details p {
|
||||
margin: 1px 0;
|
||||
}
|
||||
|
||||
/* Interests and Skills at bottom of page */
|
||||
.interests {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
border-top: solid 2px var(--primary);
|
||||
}
|
||||
|
||||
.interests td,
|
||||
tr,
|
||||
th {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.row-leftalign {
|
||||
/* background-image: url("https://www.fridakahlo.org/assets/img/paintings/without-hope.jpg"); */
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
359
nginx/vue/src/views/CV/CV.vue
Normal file
359
nginx/vue/src/views/CV/CV.vue
Normal file
@@ -0,0 +1,359 @@
|
||||
<script setup>
|
||||
import Project from "./Project.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<div class="no-print w-full h-20">
|
||||
|
||||
</div>
|
||||
<div class="a4page">
|
||||
<div class="flex flex-row justify-between">
|
||||
<h1 class="name">Adam French</h1>
|
||||
<div class="contact-details text-right">
|
||||
<p>+447563266931</p>
|
||||
<p>adam.a.french@outlook.com</p>
|
||||
<h4>
|
||||
<a href="https://www.adam-french.co.uk">
|
||||
www.adam-french.co.uk
|
||||
</a>
|
||||
</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Profile</h2>
|
||||
<p>
|
||||
First Class Honours graduate in Computer Science with Mathematics
|
||||
from the University of Leeds (81.1%), with a year abroad at the
|
||||
University of Waterloo. Proficient in full-stack development,
|
||||
systems programming, and CI/CD automation. Eager to contribute to
|
||||
a collaborative engineering team, apply strong academic
|
||||
foundations to real-world problems, and grow through hands-on
|
||||
experience.
|
||||
</p>
|
||||
|
||||
<h2>Skills</h2>
|
||||
<div class="skills-grid">
|
||||
<div><strong>Languages</strong><br /><small>Go, Rust, Python, JavaScript / TypeScript, SQL</small></div>
|
||||
<div><strong>Frontend</strong><br /><small>Vue, React / Redux, Svelte, Tailwind CSS, WebAssembly</small></div>
|
||||
<div><strong>Backend / Infra</strong><br /><small>Nginx, Docker, PostgreSQL, SQLite, JWT Auth, Git Actions</small></div>
|
||||
</div>
|
||||
|
||||
<h2>Projects</h2>
|
||||
|
||||
<Project class="border-b border-dotted">
|
||||
<template v-slot:left>
|
||||
<h4>
|
||||
<a
|
||||
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
|
||||
>
|
||||
web_server.git
|
||||
</a>
|
||||
</h4>
|
||||
</template>
|
||||
<template v-slot:top>
|
||||
<small>
|
||||
Nginx, Vue, Postgres, Docker, Go, Python, Rust → Wasm,
|
||||
Git Actions, JWT Auth
|
||||
</small>
|
||||
<small>2025</small>
|
||||
</template>
|
||||
<p>
|
||||
Self-hosted personal website with a fully automated CI/CD
|
||||
pipeline. Iterated across diverse tech stacks including
|
||||
Svelte, React/Redux, SQLite, Rust Actix, and Deno.
|
||||
</p>
|
||||
</Project>
|
||||
<Project class="border-b border-dotted">
|
||||
<template v-slot:left>
|
||||
<h4>
|
||||
<a
|
||||
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
|
||||
>
|
||||
tour.git
|
||||
</a>
|
||||
</h4>
|
||||
</template>
|
||||
<template v-slot:top>
|
||||
<small>Rust</small>
|
||||
<small>2026</small>
|
||||
</template>
|
||||
<p>
|
||||
CLI tool for building and navigating interactive code
|
||||
tutorials, with version-traversal semantics inspired by Git.
|
||||
</p>
|
||||
</Project>
|
||||
<Project class="border-b border-dotted">
|
||||
<template v-slot:left>
|
||||
<h4>
|
||||
<a
|
||||
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
|
||||
>
|
||||
rust-raytracer.git
|
||||
</a>
|
||||
</h4>
|
||||
</template>
|
||||
<template v-slot:top>
|
||||
<small>Rust, Linear Algebra, Multithreading</small>
|
||||
<small>2023</small>
|
||||
</template>
|
||||
<p>
|
||||
Parallelised recursive ray tracer for realistic 3D rendering.
|
||||
Emphasised algorithmic efficiency and low-level memory
|
||||
management in Rust.
|
||||
</p>
|
||||
</Project>
|
||||
<Project>
|
||||
<template #left>
|
||||
<h4>
|
||||
<a
|
||||
class="text-center w-full"
|
||||
href="https://community.wolfram.com/groups/-/m/t/3210947"
|
||||
>
|
||||
Wolfram Summer School
|
||||
</a>
|
||||
</h4>
|
||||
</template>
|
||||
<template #top>
|
||||
<small>Wolfram Mathematica</small>
|
||||
<small>2024</small>
|
||||
</template>
|
||||
<p>
|
||||
Research project on Mobile Automata with data visualisation
|
||||
and academic presentation. Delivered within a tight deadline
|
||||
in collaboration with academic mentors.
|
||||
</p>
|
||||
</Project>
|
||||
|
||||
<h2>Education</h2>
|
||||
<div class="w-full h-fit flex-row flex gap-5">
|
||||
<div class="flex-1 border-r border-dotted pr-3">
|
||||
<h3>
|
||||
<a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
|
||||
University of Leeds
|
||||
</a>
|
||||
</h3>
|
||||
<div
|
||||
class="flex-row flex place-content-between m-auto place-items-center"
|
||||
>
|
||||
<small>81.1% — First Class Honours</small>
|
||||
<small>2021–2025</small>
|
||||
</div>
|
||||
<small>BSc Computer Science with Mathematics (International)</small>
|
||||
<ul>
|
||||
<li>Algorithms & Data Structures I & II</li>
|
||||
<li>Compiler Design and Construction</li>
|
||||
<li>Formal Languages & Finite Automata</li>
|
||||
<li>Graph Algorithms & Complexity Theory</li>
|
||||
<li>Machine Learning · Databases · Computer Processors</li>
|
||||
<li>Probability and Statistics I</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex-1 pl-3">
|
||||
<h3>University of Waterloo</h3>
|
||||
<div
|
||||
class="flex-row flex place-content-between m-auto place-items-center"
|
||||
>
|
||||
<small>Year abroad</small>
|
||||
<small>2023–2024</small>
|
||||
</div>
|
||||
<ul>
|
||||
<li>Applied Cryptography</li>
|
||||
<li>Introduction to Computer Graphics</li>
|
||||
<li>Introduction to Rings and Fields with Applications</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="no-print w-full h-20">
|
||||
|
||||
</div>
|
||||
|
||||
<div class="a4page">
|
||||
<div class="flex-1 pl-3">
|
||||
<h2>Experience</h2>
|
||||
<Project>
|
||||
<template #left>
|
||||
<p>Hospitality</p>
|
||||
</template>
|
||||
<template #top>
|
||||
<small>Cashier, Bartender, Waiter</small>
|
||||
<small>2018–2023</small>
|
||||
</template>
|
||||
<p>
|
||||
Worked at <em>Belgrave Music Hall</em>,
|
||||
<em>The Crown and Anchor</em>, and
|
||||
<em>BFI Riverfront Kitchen</em>. Developed
|
||||
communication, composure under pressure, and
|
||||
reliability in customer-facing roles.
|
||||
</p>
|
||||
</Project>
|
||||
<h2>Interests</h2>
|
||||
<ul>
|
||||
<li>Leetcode — daily competitive problem solving</li>
|
||||
<li>Learning Mandarin</li>
|
||||
<li>Rhythm Games</li>
|
||||
<li>Climbing · Gym</li>
|
||||
<li>Board games · Meetup.com</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="no-print w-full h-20">
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Fonts */
|
||||
@font-face {
|
||||
font-family: "big_noodle_titling";
|
||||
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "CreatoDisplay";
|
||||
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Variables */
|
||||
* {
|
||||
--primary: black;
|
||||
--secondary: #0000ff;
|
||||
--tertiary: #ff0000;
|
||||
--quaternary: #cccccc;
|
||||
--background: white;
|
||||
|
||||
--font-heading: big_noodle_titling;
|
||||
--font-text: CreatoDisplay;
|
||||
--font-size-name: 2.5em;
|
||||
--font-size-text: 100%;
|
||||
--font-size-small: 0.9em;
|
||||
--font-size-heading: 2.1em;
|
||||
--font-size-subheading: 1.7em;
|
||||
--font-size-subsubheading: 1.4em;
|
||||
}
|
||||
|
||||
/* A4 Page */
|
||||
.a4page {
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-text);
|
||||
width: 210mm;
|
||||
height: 297mm;
|
||||
padding: 5mm;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--primary);
|
||||
overflow: hidden;
|
||||
margin: auto auto;
|
||||
}
|
||||
|
||||
/* Component Styling */
|
||||
main {
|
||||
padding: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
span {
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
font-family: var(--font-heading);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--font-size-heading);
|
||||
}
|
||||
|
||||
h2 {
|
||||
border-bottom: 1px solid var(--primary);
|
||||
font-size: var(--font-size-subheading);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-subsubheading);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--tertiary);
|
||||
}
|
||||
a {
|
||||
background-color: transparent;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 0.2em;
|
||||
color: var(--primary);
|
||||
font-size: var(--font-size-text);
|
||||
}
|
||||
|
||||
table {
|
||||
color: var(--secondary);
|
||||
border-collapse: collapse;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
td {
|
||||
color: var(--secondary);
|
||||
border-top: 1px solid var(--tertiary);
|
||||
padding: 1px 10px 1px 10px;
|
||||
font-size: var(--font-size-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--secondary);
|
||||
border: 2px solid var(--tertiary);
|
||||
padding: 1px 0px 1px 7px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--font-size-subsubheading);
|
||||
background-color: var(--quaternary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
ul {
|
||||
font-size: var(--font-size-small);
|
||||
margin: 0;
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: var(--font-size-small);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.skills-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 0.3em 1em;
|
||||
margin-bottom: 0.2em;
|
||||
}
|
||||
</style>
|
||||
17
nginx/vue/src/views/CV/Project.vue
Normal file
17
nginx/vue/src/views/CV/Project.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup></script>
|
||||
|
||||
<template>
|
||||
<div class="flex-row flex">
|
||||
<div class="w-2/7 p-5 m-auto">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
<div class="w-full p-2">
|
||||
<div
|
||||
class="flex-row flex place-content-between m-auto place-items-center"
|
||||
>
|
||||
<slot name="top" />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,11 +2,12 @@
|
||||
import { ref } from "vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
import Login from "@/components/admin/Login.vue";
|
||||
import CreateUser from "@/components/admin/CreateUser.vue";
|
||||
import CreatePost from "@/components/admin/CreatePost.vue";
|
||||
import CreateFavorite from "@/components/admin/CreateFavorite.vue";
|
||||
import CreateActivity from "@/components/admin/CreateActivity.vue";
|
||||
import Login from "./Login.vue";
|
||||
import CreateUser from "./CreateUser.vue";
|
||||
import CreatePost from "./CreatePost.vue";
|
||||
import CreateFavorite from "./CreateFavorite.vue";
|
||||
import CreateActivity from "./CreateActivity.vue";
|
||||
import CreateRowing from "./CreateRowing.vue";
|
||||
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
@@ -21,6 +22,7 @@ const auth = useAuthStore();
|
||||
<CreatePost class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||
<CreateFavorite class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||
<CreateActivity class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||
<CreateRowing class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
51
nginx/vue/src/views/admin/CreateRowing.vue
Normal file
51
nginx/vue/src/views/admin/CreateRowing.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import Button from "@/components/input/Button.vue";
|
||||
import { ref } from "vue";
|
||||
import axios from "axios";
|
||||
|
||||
const images = ref([]);
|
||||
const results = ref([]);
|
||||
|
||||
function onFileChange(e) {
|
||||
images.value = Array.from(e.target.files);
|
||||
results.value = [];
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!images.value.length) return;
|
||||
results.value = images.value.map((f) => ({ name: f.name, status: "Uploading..." }));
|
||||
|
||||
await Promise.all(
|
||||
images.value.map(async (file, i) => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
try {
|
||||
const res = await axios.post("/api/rowing", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
const mins = Math.floor(res.data.Time / 1e9 / 60);
|
||||
const secs = String(Math.floor((res.data.Time / 1e9) % 60)).padStart(2, "0");
|
||||
results.value[i].status = `${res.data.Distance}m in ${mins}:${secs}`;
|
||||
results.value[i].ok = true;
|
||||
} catch (err) {
|
||||
results.value[i].status = err.response?.data?.error || "Upload failed";
|
||||
results.value[i].ok = false;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
images.value = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1>Create Rowing</h1>
|
||||
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple @change="onFileChange" />
|
||||
<Button @click="submit">Upload</Button>
|
||||
<div v-for="r in results" :key="r.name">
|
||||
<span>{{ r.name }}: </span>
|
||||
<span :class="r.ok ? '' : 'text-red-500'">{{ r.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
42
nginx/vue/src/views/home/BadApple.vue
Normal file
42
nginx/vue/src/views/home/BadApple.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef, ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const display = useTemplateRef('display')
|
||||
const displayText = ref("");
|
||||
|
||||
const charHeight: number = 14;
|
||||
const charWidth: number = charHeight * 0.6;
|
||||
let n: number;
|
||||
let m: number;
|
||||
|
||||
function setup() {
|
||||
display.value.style.fontSize = `${charHeight}px`;
|
||||
display.value.style.lineHeight = `${charHeight}px`;
|
||||
fillDisplay()
|
||||
}
|
||||
|
||||
function fillDisplay() {
|
||||
// M rows N columns
|
||||
m = Math.floor(display.value.offsetHeight / charHeight);
|
||||
n = Math.floor(display.value.offsetWidth / charWidth);
|
||||
const row = ' '.repeat(n);
|
||||
displayText.value = (row + '\n').repeat(m - 1) + row
|
||||
}
|
||||
|
||||
function close() {
|
||||
displayText.value = ""
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setup()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<pre class="overflow-scroll w-full h-full bg-black text-white m-0 p-0" id="container" ref="display">{{ displayText
|
||||
}}</pre>
|
||||
</template>
|
||||
@@ -1,70 +1,15 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { Transition } from "vue";
|
||||
import Header from "@/components/text/Header.vue";
|
||||
import Slideshow from "@/components/util/Slideshow.vue";
|
||||
|
||||
const images = [
|
||||
{ url: "/img/memes/pidgeon.gif", comment: "鸟" },
|
||||
// { url: "/img/memes/no_slip.png" },
|
||||
// { url: "/img/memes/epic.jpeg" },
|
||||
{ url: "/img/bedroom/img2.png", comment: "办公桌" },
|
||||
{ url: "/img/bedroom/img1.png", comment: "床" },
|
||||
// { url: "/img/bedroom/img2.png", comment: "办公桌" },
|
||||
// { url: "/img/bedroom/img1.png", comment: "床" },
|
||||
];
|
||||
|
||||
const currentIndex = ref(0);
|
||||
const currentComment = computed(() => images[currentIndex.value].comment);
|
||||
const currentUrl = computed(() => images[currentIndex.value].url);
|
||||
|
||||
let nextId;
|
||||
|
||||
function nextImage() {
|
||||
clearTimeout(nextId);
|
||||
let newIndex;
|
||||
do {
|
||||
newIndex = Math.floor(Math.random() * images.length);
|
||||
} while (newIndex === currentIndex.value);
|
||||
currentIndex.value = newIndex;
|
||||
nextId = setTimeout(nextImage, 10000);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextId = setTimeout(nextImage, 10000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearTimeout(nextId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div class="image-viewer" @click="nextImage" :key="currentIndex">
|
||||
<Header v-if="currentComment">
|
||||
{{ currentComment }}
|
||||
</Header>
|
||||
<img :src="currentUrl" alt="Image Viewer" />
|
||||
</div>
|
||||
</Transition>
|
||||
<Slideshow :images="images" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.image-viewer {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
270
nginx/vue/src/views/home/Gym2.vue
Normal file
270
nginx/vue/src/views/home/Gym2.vue
Normal 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>
|
||||
@@ -1,28 +1,46 @@
|
||||
<script setup>
|
||||
import Timer from "@/components/util/Timer.vue";
|
||||
import Elle from "@/components/elle/Elle.vue";
|
||||
import Time from "@/components/util/Time.vue";
|
||||
import Radio from "@/components/util/Radio.vue";
|
||||
import Elle from "@/components/elle/Elle.vue";
|
||||
import Chat from "@/components/util/Chat.vue";
|
||||
import MusicPlayer from "@/components/util/MusicPlayer.vue";
|
||||
import CommitHistory from "@/components/util/CommitHistory.vue";
|
||||
|
||||
import Intro from "./Intro.vue";
|
||||
import Intro2 from "./Intro2.vue";
|
||||
import BadApple from "./BadApple.vue";
|
||||
import Miku from "./Miku.vue";
|
||||
import Stamps from "./Stamps.vue";
|
||||
import Listening from "./Listening.vue";
|
||||
import Links from "./Links.vue";
|
||||
import Feed from "./Feed.vue";
|
||||
import Collage from "./Collage.vue";
|
||||
import Favorites from "./Favorites.vue";
|
||||
import Gym from "./Gym.vue";
|
||||
// import Gym from "./Gym.vue";
|
||||
import Gym2 from "./Gym2.vue";
|
||||
import Consumption from "./Consumption.vue";
|
||||
|
||||
import UtenaFrame from "@/components/borders/UtenaFrame.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="halftone justify-center flex flex-row w-full h-full">
|
||||
<div class="h-fit flex flex-row">
|
||||
<div class="a4page-portrait homeGrid relative bdr-1">
|
||||
<Intro class="intro" />
|
||||
<div class="outerWrap h-fit flex flex-row">
|
||||
<div
|
||||
class="sidebar flex-1 flex flex-col m-10 w-60 gap-2 place-content-between"
|
||||
>
|
||||
<div class="border-children background-children">
|
||||
<Chat class="h-200" />
|
||||
</div>
|
||||
<div>
|
||||
<Miku class="border-tertiary border bg-bg_secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="a4page-portrait homeGrid relative background-children border-children bdr-1"
|
||||
>
|
||||
<!-- <Intro class="intro" /> -->
|
||||
<Intro2 class="intro" />
|
||||
<!-- <BadApple class="intro" /> -->
|
||||
<Listening class="listening" />
|
||||
<Stamps class="stamps" />
|
||||
<Feed class="feed" />
|
||||
@@ -30,22 +48,28 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
|
||||
<Collage class="collage" />
|
||||
<Consumption class="consumption" />
|
||||
<Favorites class="favorites" />
|
||||
<Gym class="gym" />
|
||||
<!-- <Gym class="gym" /> -->
|
||||
<Gym2 class="gym" />
|
||||
</div>
|
||||
<div
|
||||
class="sidebar border-quaternary place-content-between flex-1 flex flex-col m-10 w-60 border-2"
|
||||
class="sidebar place-content-between flex-1 flex flex-col m-10 w-60"
|
||||
>
|
||||
<div class="flex flex-col flex-1">
|
||||
<Time class="bg-bg_primary border-primary border-b" />
|
||||
<Timer class="border-primary border-b bg-bg_primary" />
|
||||
<div
|
||||
class="flex flex-col background-children border-children flex-1 gap-2"
|
||||
>
|
||||
<Time />
|
||||
<Timer />
|
||||
<Radio />
|
||||
<CommitHistory />
|
||||
|
||||
<!-- <Elle class="flex-1" /> -->
|
||||
<!-- <Chat class="bdr-2 bg-bg_primary" /> -->
|
||||
<!-- <MusicPlayer /> -->
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
src="/img/memes/fire-woman.gif"
|
||||
class="border-tertiary border"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,32 +78,74 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.homeGrid > * {
|
||||
.border-children > * {
|
||||
border: 2px solid var(--quaternary);
|
||||
border-color: var(--quaternary);
|
||||
}
|
||||
|
||||
.background-children > * {
|
||||
background-color: var(--bg_primary);
|
||||
}
|
||||
|
||||
.homeGrid {
|
||||
display: grid;
|
||||
grid-gap: 5px;
|
||||
gap: 5px;
|
||||
grid-template-columns: repeat(10, 1fr);
|
||||
grid-template-rows: repeat(10, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 850px) {
|
||||
.homeGrid {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@media (max-width: 1200px) {
|
||||
.outerWrap {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.homeGrid {
|
||||
order: -1;
|
||||
width: 100%;
|
||||
height: 350mm;
|
||||
margin-inline: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
margin: 5px 10px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.tr,
|
||||
.br,
|
||||
@media (max-width: 850px) {
|
||||
.homeGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sidebar > * {
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
main {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.outerWrap {
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +183,7 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
|
||||
grid-column: span 4;
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.gym {
|
||||
grid-column: span 3;
|
||||
grid-row: span 2;
|
||||
@@ -126,4 +193,10 @@ import UtenaFrame from "@/components/borders/UtenaFrame.vue";
|
||||
grid-column: span 3;
|
||||
grid-row: span 2;
|
||||
}
|
||||
|
||||
.bg-random {
|
||||
background-color: var(--bg_primary);
|
||||
background-image: url("/img/miku/miku2.gif");
|
||||
background-size: 10px 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,34 +4,27 @@ import Paragraph from "@/components/text/Paragraph.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start"
|
||||
>
|
||||
<Header>Intro</Header>
|
||||
<Paragraph>
|
||||
Hi, I'm Adam, thank you for visiting my website. I'm currently a 20
|
||||
something graduate looking for work. I like to game, listen to lots
|
||||
of music and occasionally watch anime.
|
||||
</Paragraph>
|
||||
<Header>Getting around</Header>
|
||||
<Paragraph>
|
||||
Pages available can be traversed through links below. I am hoping to
|
||||
add some shrines, code-walkthoughs, live chat and page transitions
|
||||
at a later date.
|
||||
</Paragraph>
|
||||
<Header>Contact</Header>
|
||||
<Paragraph>
|
||||
Please email me <a href="mailto:adam.a.french@outlook.com">here</a>,
|
||||
or contact me though any of the social medias linked.
|
||||
</Paragraph>
|
||||
<Header>A Quote</Header>
|
||||
<!-- <p>
|
||||
What makes me a good demoman? If I were a bad demoman, I wouldn't be
|
||||
sittin' here discussin' it with you, now would I?!
|
||||
</p> -->
|
||||
<Paragraph>
|
||||
One crossed wire, one wayward pinch of potassium chlorate, one
|
||||
errant twitch, and KA-BLOOIE!
|
||||
</Paragraph>
|
||||
<div class="flex-1 border-box flex flex-col p-1 text-left items-start justify-start">
|
||||
<Header>Yo</Header>
|
||||
<!-- <Header>Intro</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- Hi, I'm Adam, thank you for visiting my website. -->
|
||||
<!-- </Paragraph> -->
|
||||
<!-- <Header>Getting around</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- Pages available can be traversed through links below. I am hoping to -->
|
||||
<!-- add some shrines, code-walkthoughs, live chat and page transitions -->
|
||||
<!-- at a later date. -->
|
||||
<!-- </Paragraph> -->
|
||||
<!-- <Header>Contact</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- Please email me <a href="mailto:adam.a.french@outlook.com">here</a>, -->
|
||||
<!-- or contact me though any of the social medias linked. -->
|
||||
<!-- </Paragraph> -->
|
||||
<!-- <Header>A Quote</Header> -->
|
||||
<!-- <Paragraph> -->
|
||||
<!-- One crossed wire, one wayward pinch of potassium chlorate, one -->
|
||||
<!-- errant twitch, and KA-BLOOIE! -->
|
||||
<!-- </Paragraph> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
130
nginx/vue/src/views/home/Intro2.vue
Normal file
130
nginx/vue/src/views/home/Intro2.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
import { rand } from "@vueuse/core";
|
||||
import { ref, onMounted, onUnmounted, nextTick } from "vue";
|
||||
|
||||
interface Item {
|
||||
x: number;
|
||||
y: number;
|
||||
dx: number;
|
||||
dy: number;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
const itemEls = ref<HTMLDivElement[]>([]);
|
||||
|
||||
const phrases = [
|
||||
"Welcome to my website",
|
||||
"I'm looking to do a big revamp",
|
||||
"A4 sheets of paper are what I'm used to",
|
||||
"I'd love to know your recommendations",
|
||||
"for very short books",
|
||||
"Message me by any means necessary",
|
||||
"I like anime, all kinds of music and sci fic",
|
||||
];
|
||||
|
||||
// Non-reactive animation state to avoid triggering Vue re-renders every frame
|
||||
const animState = phrases.map((text, i) => ({
|
||||
x: i * 20,
|
||||
y: i * 20,
|
||||
dx: rand(0, 60) / 100,
|
||||
dy: 1.0,
|
||||
content: text,
|
||||
cachedW: 0,
|
||||
cachedH: 0,
|
||||
}));
|
||||
|
||||
// Reactive items only for initial render
|
||||
const items = ref<Item[]>(
|
||||
animState.map((s) => ({
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
dx: s.dx,
|
||||
dy: s.dy,
|
||||
content: s.content,
|
||||
})),
|
||||
);
|
||||
|
||||
let rafId = 0;
|
||||
let cachedCW = 0;
|
||||
let cachedCH = 0;
|
||||
let lastFrameTime = 0;
|
||||
const FRAME_INTERVAL = 1000 / 30;
|
||||
|
||||
function measureSizes() {
|
||||
const c = container.value;
|
||||
if (c) {
|
||||
cachedCW = c.clientWidth;
|
||||
cachedCH = c.clientHeight;
|
||||
}
|
||||
itemEls.value.forEach((el, i) => {
|
||||
if (el && animState[i]) {
|
||||
animState[i].cachedW = el.offsetWidth;
|
||||
animState[i].cachedH = el.offsetHeight;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function animate(timestamp: number) {
|
||||
if (!cachedCW || !cachedCH) {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
if (timestamp - lastFrameTime < FRAME_INTERVAL) {
|
||||
rafId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
lastFrameTime = timestamp;
|
||||
|
||||
for (let i = 0; i < animState.length; i++) {
|
||||
const s = animState[i];
|
||||
const el = itemEls.value[i];
|
||||
if (!el) continue;
|
||||
|
||||
s.x += s.dx;
|
||||
s.y += s.dy;
|
||||
|
||||
if (s.x < 0 || s.x > cachedCW - s.cachedW) s.dx *= -1;
|
||||
if (s.y < 0 || s.y > cachedCH - s.cachedH) s.dy *= -1;
|
||||
|
||||
el.style.transform = `translate(${s.x}px, ${s.y}px)`;
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver;
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
measureSizes();
|
||||
rafId = requestAnimationFrame(animate);
|
||||
|
||||
resizeObserver = new ResizeObserver(measureSizes);
|
||||
resizeObserver.observe(container.value!);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
cancelAnimationFrame(rafId);
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="container"
|
||||
class="w-full h-full min-h-125 relative overflow-hidden"
|
||||
>
|
||||
<div
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
ref="itemEls"
|
||||
class="absolute w-fit h-fit"
|
||||
>
|
||||
<h1>
|
||||
{{ item.content }}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import RouterTable from "@/components/util/RouterTable.vue";
|
||||
import LinkTable from "@/components/util/LinkTable.vue";
|
||||
import Markdown from "@/components/util/Markdown.vue";
|
||||
|
||||
const site_links = [
|
||||
{ name: "CV", link: "/cv" },
|
||||
@@ -12,6 +11,7 @@ const site_links = [
|
||||
];
|
||||
|
||||
const social_links = [
|
||||
{ name: "Gitea", link: "/gitea/explore/repos" },
|
||||
{ name: "Steam", link: "https://steamcommunity.com/id/SteveThePug" },
|
||||
{ name: "Github", link: "https://github.com/SteveThePug" },
|
||||
{ name: "Spotify", link: "https://open.spotify.com/user/stevethepug" },
|
||||
|
||||
@@ -29,7 +29,8 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div class="listening-wrapper">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
@click="nextSong"
|
||||
:key="song.track.id"
|
||||
@@ -45,9 +46,16 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.listening-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 70%;
|
||||
}
|
||||
@@ -56,15 +64,17 @@ p {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.fade-enter-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
.fade-enter-from {
|
||||
opacity: 0;
|
||||
.fade-leave-active {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
13
nginx/vue/src/views/home/Miku.vue
Normal file
13
nginx/vue/src/views/home/Miku.vue
Normal 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>
|
||||
@@ -42,7 +42,8 @@ img {
|
||||
width: 89px;
|
||||
height: 59px;
|
||||
}
|
||||
|
||||
.tst {
|
||||
width: calc(89px * 4);
|
||||
min-width: calc(89px * 4);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,4 +13,11 @@ export default defineConfig({
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/gitea": "http://localhost:3000",
|
||||
"/radio": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
974
package-lock.json
generated
974
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.2.0"
|
||||
"@vueuse/core": "^14.2.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
116
readme.md
116
readme.md
@@ -1,22 +1,117 @@
|
||||
# TODO
|
||||
- ML for 4chan
|
||||
- Cache requests
|
||||
- Login to add / remove posts (auth)
|
||||
- Design webpage
|
||||
- Calendar to show radio times
|
||||
- Nice smooth function background
|
||||
- Shrines
|
||||
- Redis (not really)
|
||||
# My Web
|
||||
|
||||
# .env
|
||||
## Important TODO
|
||||
|
||||
- Get a new background
|
||||
|
||||

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