Compare commits
99 Commits
rowing_fro
...
a4514ad98d
| Author | SHA1 | Date | |
|---|---|---|---|
| a4514ad98d | |||
| b4ddb4d402 | |||
| 0360b1f7f1 | |||
| 36817277f9 | |||
| a03ce26824 | |||
| a10706506e | |||
| f29e937307 | |||
| 81cb2bc4b5 | |||
| 8b5ed9abec | |||
| 8cdab593ae | |||
| b63cc911a7 | |||
| e1fe281586 | |||
| 887d23af5b | |||
| 36aa7ed907 | |||
| d5065d19e0 | |||
| 15c721ea56 | |||
| b47d1a3df3 | |||
| 5b3cd267b6 | |||
| 6033a952af | |||
| 0ad7f4e009 | |||
| 6bf773487a | |||
| 2916afe206 | |||
| 17deec23ba | |||
| ad4d02228d | |||
| d5fbc0ee74 | |||
| 857f66cb37 | |||
| 5b041d7364 | |||
| 4be7e60394 | |||
| 27f74f6c2a | |||
| 5a19f09e17 | |||
| 469a225860 | |||
| cd1bcc7f39 | |||
| 14cacec1f5 | |||
| 7991c80176 | |||
| bad44a6ddd | |||
| 0b256863d6 | |||
| cb326ff8bf | |||
| 78d6c3d4f0 | |||
| c7dbf5b778 | |||
| a8d1b879be | |||
| f82389225c | |||
| 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 |
@@ -17,3 +17,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DOCKER_API_VERSION: "1.41"
|
DOCKER_API_VERSION: "1.41"
|
||||||
run: docker compose up -d --build --remove-orphans
|
run: docker compose up -d --build --remove-orphans
|
||||||
|
|
||||||
|
- name: Prune unused Docker resources
|
||||||
|
run: docker image prune -f
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,8 +1,11 @@
|
|||||||
|
icecast2/fallback_music/*
|
||||||
|
!icecast2/fallback_music/.gitkeep
|
||||||
certbot/conf
|
certbot/conf
|
||||||
certbot/www
|
certbot/www
|
||||||
backend/token/
|
backend/token/
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
gitea/config/app.ini
|
||||||
gitea/data/*
|
gitea/data/*
|
||||||
gitea-runner/data/*
|
gitea-runner/data/*
|
||||||
|
|
||||||
@@ -47,5 +50,6 @@ coverage
|
|||||||
# Vitest
|
# Vitest
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
|
|
||||||
.deploy
|
.deploy
|
||||||
*.xcf
|
*.xcf
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.24
|
FROM golang:1.25
|
||||||
|
|
||||||
WORKDIR /backend
|
WORKDIR /backend
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
module adam-french.co.uk/backend
|
module adam-french.co.uk/backend
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/99designs/gqlgen v0.17.88
|
||||||
|
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.32
|
||||||
github.com/zmb3/spotify/v2 v2.4.3
|
github.com/zmb3/spotify/v2 v2.4.3
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/oauth2 v0.30.0
|
golang.org/x/oauth2 v0.30.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
|
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
@@ -22,10 +27,11 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
@@ -41,7 +47,7 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
github.com/sosodev/duration v1.4.0 // indirect
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
@@ -50,12 +56,11 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/mod v0.29.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.38.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,10 +31,22 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
|
|||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
|
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
|
||||||
|
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||||
|
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||||
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||||
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
@@ -50,6 +62,10 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||||
|
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||||
|
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||||
|
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
@@ -71,10 +87,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
@@ -102,10 +120,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
|||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
|
||||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
@@ -129,12 +144,16 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf
|
|||||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
@@ -178,6 +197,10 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
|
|||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
|
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||||
|
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -203,6 +226,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -226,8 +251,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
|||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@@ -260,8 +285,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -294,14 +319,13 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 h1:Ati8dO7+U7mxpkPSxBZQEvzHVUYB/MqCklCN8ig5w/o=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
@@ -315,8 +339,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -351,8 +375,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@@ -368,8 +392,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
@@ -415,8 +439,8 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
|
|||||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -443,7 +467,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
|
|||||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
|
||||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
@@ -499,12 +522,14 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
|
|||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
59
backend/gqlgen.yml
Normal file
59
backend/gqlgen.yml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
schema:
|
||||||
|
- graph/schema/*.graphql
|
||||||
|
|
||||||
|
exec:
|
||||||
|
filename: graph/generated.go
|
||||||
|
package: graph
|
||||||
|
|
||||||
|
model:
|
||||||
|
filename: graph/model/models_gen.go
|
||||||
|
package: model
|
||||||
|
|
||||||
|
resolver:
|
||||||
|
layout: follow-schema
|
||||||
|
dir: graph
|
||||||
|
package: graph
|
||||||
|
filename_template: "{name}.resolvers.go"
|
||||||
|
|
||||||
|
models:
|
||||||
|
ID:
|
||||||
|
model:
|
||||||
|
- github.com/99designs/gqlgen/graphql.IntID
|
||||||
|
User:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.User
|
||||||
|
fields:
|
||||||
|
password:
|
||||||
|
resolver: false
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Post:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Post
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Activity:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Activity
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Favorite:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Favorite
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Rowing:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Rowing
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Message:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Message
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
22
backend/graph/activity.resolvers.go
Normal file
22
backend/graph/activity.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *activityResolver) ID(ctx context.Context, obj *models.Activity) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity returns ActivityResolver implementation.
|
||||||
|
func (r *Resolver) Activity() ActivityResolver { return &activityResolver{r} }
|
||||||
|
|
||||||
|
type activityResolver struct{ *Resolver }
|
||||||
52
backend/graph/context.go
Normal file
52
backend/graph/context.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
userClaimsKey contextKey = "userClaims"
|
||||||
|
ginContextKey contextKey = "ginContext"
|
||||||
|
)
|
||||||
|
|
||||||
|
func UserClaimsFromCtx(ctx context.Context) *jwt.MapClaims {
|
||||||
|
claims, ok := ctx.Value(userClaimsKey).(*jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserIDFromCtx(ctx context.Context) (uint, bool) {
|
||||||
|
claims := UserClaimsFromCtx(ctx)
|
||||||
|
if claims == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
idF, ok := (*claims)["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint(idF), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsAdminFromCtx(ctx context.Context) bool {
|
||||||
|
claims := UserClaimsFromCtx(ctx)
|
||||||
|
if claims == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
admin, ok := (*claims)["admin"].(bool)
|
||||||
|
return ok && admin
|
||||||
|
}
|
||||||
|
|
||||||
|
func GinContextFromCtx(ctx context.Context) *gin.Context {
|
||||||
|
gc, ok := ctx.Value(ginContextKey).(*gin.Context)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return gc
|
||||||
|
}
|
||||||
22
backend/graph/favorite.resolvers.go
Normal file
22
backend/graph/favorite.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *favoriteResolver) ID(ctx context.Context, obj *models.Favorite) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorite returns FavoriteResolver implementation.
|
||||||
|
func (r *Resolver) Favorite() FavoriteResolver { return &favoriteResolver{r} }
|
||||||
|
|
||||||
|
type favoriteResolver struct{ *Resolver }
|
||||||
7656
backend/graph/generated.go
Normal file
7656
backend/graph/generated.go
Normal file
File diff suppressed because it is too large
Load Diff
27
backend/graph/message.resolvers.go
Normal file
27
backend/graph/message.resolvers.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *messageResolver) ID(ctx context.Context, obj *models.Message) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorID is the resolver for the authorId field.
|
||||||
|
func (r *messageResolver) AuthorID(ctx context.Context, obj *models.Message) (int, error) {
|
||||||
|
return int(obj.AuthorID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message returns MessageResolver implementation.
|
||||||
|
func (r *Resolver) Message() MessageResolver { return &messageResolver{r} }
|
||||||
|
|
||||||
|
type messageResolver struct{ *Resolver }
|
||||||
25
backend/graph/middleware.go
Normal file
25
backend/graph/middleware.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/services"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AuthContextMiddleware(auth *services.Auth) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
ctx := context.WithValue(c.Request.Context(), ginContextKey, c)
|
||||||
|
|
||||||
|
accessToken, err := c.Cookie("access_token")
|
||||||
|
if err == nil {
|
||||||
|
claims, err := auth.VerifyJWT(accessToken)
|
||||||
|
if err == nil {
|
||||||
|
ctx = context.WithValue(ctx, userClaimsKey, claims)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Request = c.Request.WithContext(ctx)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
80
backend/graph/model/models_gen.go
Normal file
80
backend/graph/model/models_gen.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthPayload struct {
|
||||||
|
User *models.User `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateActivityInput struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Link *string `json:"link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateFavoriteInput struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Link *string `json:"link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePostInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserInput struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginInput struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyAlbum struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images []*SpotifyImage `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyArtist struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyImage struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyPlaying struct {
|
||||||
|
Playing bool `json:"playing"`
|
||||||
|
Track *SpotifyTrack `json:"track,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyRecentItem struct {
|
||||||
|
Track *SpotifyTrack `json:"track"`
|
||||||
|
PlayedAt time.Time `json:"playedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyTrack struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists []*SpotifyArtist `json:"artists"`
|
||||||
|
Album *SpotifyAlbum `json:"album"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePostInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
22
backend/graph/post.resolvers.go
Normal file
22
backend/graph/post.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *postResolver) ID(ctx context.Context, obj *models.Post) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post returns PostResolver implementation.
|
||||||
|
func (r *Resolver) Post() PostResolver { return &postResolver{r} }
|
||||||
|
|
||||||
|
type postResolver struct{ *Resolver }
|
||||||
12
backend/graph/resolver.go
Normal file
12
backend/graph/resolver.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import "adam-french.co.uk/backend/handlers"
|
||||||
|
|
||||||
|
// This file will not be regenerated automatically.
|
||||||
|
//
|
||||||
|
// It serves as dependency injection for your app, add any dependencies you require
|
||||||
|
// here.
|
||||||
|
|
||||||
|
type Resolver struct {
|
||||||
|
Store *handlers.Store
|
||||||
|
}
|
||||||
32
backend/graph/rowing.resolvers.go
Normal file
32
backend/graph/rowing.resolvers.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *rowingResolver) ID(ctx context.Context, obj *models.Rowing) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time is the resolver for the time field.
|
||||||
|
func (r *rowingResolver) Time(ctx context.Context, obj *models.Rowing) (int, error) {
|
||||||
|
return int(obj.Time), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance is the resolver for the distance field.
|
||||||
|
func (r *rowingResolver) Distance(ctx context.Context, obj *models.Rowing) (int, error) {
|
||||||
|
return int(obj.Distance), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rowing returns RowingResolver implementation.
|
||||||
|
func (r *Resolver) Rowing() RowingResolver { return &rowingResolver{r} }
|
||||||
|
|
||||||
|
type rowingResolver struct{ *Resolver }
|
||||||
456
backend/graph/schema.resolvers.go
Normal file
456
backend/graph/schema.resolvers.go
Normal file
@@ -0,0 +1,456 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
spotify "github.com/zmb3/spotify/v2"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login is the resolver for the login field.
|
||||||
|
func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) {
|
||||||
|
gc := GinContextFromCtx(ctx)
|
||||||
|
if gc == nil {
|
||||||
|
return nil, fmt.Errorf("could not get gin context")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := r.Store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := r.Store.Auth.GenerateJWT(&user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
gc.SetCookie(
|
||||||
|
"access_token",
|
||||||
|
tokens.AccessToken,
|
||||||
|
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||||
|
r.Store.Auth.Config.Endpoint,
|
||||||
|
r.Store.Auth.Config.Domain,
|
||||||
|
true, true,
|
||||||
|
)
|
||||||
|
gc.SetCookie(
|
||||||
|
"refresh_token",
|
||||||
|
tokens.RefreshToken,
|
||||||
|
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||||
|
r.Store.Auth.Config.Endpoint,
|
||||||
|
r.Store.Auth.Config.Domain,
|
||||||
|
true, true,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &model.AuthPayload{User: &user}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout is the resolver for the logout field.
|
||||||
|
func (r *mutationResolver) Logout(ctx context.Context) (bool, error) {
|
||||||
|
gc := GinContextFromCtx(ctx)
|
||||||
|
if gc == nil {
|
||||||
|
return false, fmt.Errorf("could not get gin context")
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
gc.SetCookie("access_token", "", -1, "", "", true, true)
|
||||||
|
gc.SetCookie("refresh_token", "", -1, "", "", true, true)
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken is the resolver for the refreshToken field.
|
||||||
|
func (r *mutationResolver) RefreshToken(ctx context.Context) (*model.AuthPayload, error) {
|
||||||
|
gc := GinContextFromCtx(ctx)
|
||||||
|
if gc == nil {
|
||||||
|
return nil, fmt.Errorf("could not get gin context")
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := gc.Cookie("refresh_token")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := r.Store.Auth.VerifyJWT(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
user.ID = uint(userIDF)
|
||||||
|
if err := r.Store.DB.First(&user).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := r.Store.Auth.GenerateJWT(&user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
gc.SetCookie(
|
||||||
|
"access_token",
|
||||||
|
tokens.AccessToken,
|
||||||
|
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||||
|
r.Store.Auth.Config.Endpoint,
|
||||||
|
r.Store.Auth.Config.Domain,
|
||||||
|
true, true,
|
||||||
|
)
|
||||||
|
gc.SetCookie(
|
||||||
|
"refresh_token",
|
||||||
|
tokens.RefreshToken,
|
||||||
|
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||||
|
r.Store.Auth.Config.Endpoint,
|
||||||
|
r.Store.Auth.Config.Domain,
|
||||||
|
true, true,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &model.AuthPayload{User: &user}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePost is the resolver for the createPost field.
|
||||||
|
func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*models.Post, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := UserIDFromCtx(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
|
||||||
|
if err := r.Store.DB.Create(&post).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePost is the resolver for the updatePost field.
|
||||||
|
func (r *mutationResolver) UpdatePost(ctx context.Context, id int, input model.UpdatePostInput) (*models.Post, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := UserIDFromCtx(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var post models.Post
|
||||||
|
if err := r.Store.DB.First(&post, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("post not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if post.AuthorID != userID {
|
||||||
|
return nil, fmt.Errorf("you can only update your own posts")
|
||||||
|
}
|
||||||
|
|
||||||
|
post.Title = input.Title
|
||||||
|
post.Content = input.Content
|
||||||
|
if err := r.Store.DB.Save(&post).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePost is the resolver for the deletePost field.
|
||||||
|
func (r *mutationResolver) DeletePost(ctx context.Context, id int) (*models.Post, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := UserIDFromCtx(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var post models.Post
|
||||||
|
if err := r.Store.DB.First(&post, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("post not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if post.AuthorID != userID {
|
||||||
|
return nil, fmt.Errorf("you can only delete your own posts")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Store.DB.Delete(&post)
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser is the resolver for the createUser field.
|
||||||
|
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*models.User, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := models.User{Username: input.Username, Password: hashedPassword}
|
||||||
|
if err := r.Store.DB.Create(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser is the resolver for the deleteUser field.
|
||||||
|
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.Store.DB.Delete(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserAdmin is the resolver for the setUserAdmin field.
|
||||||
|
func (r *mutationResolver) SetUserAdmin(ctx context.Context, id int, admin bool) (*models.User, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callerID, ok := UserIDFromCtx(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if uint(id) == callerID {
|
||||||
|
return nil, fmt.Errorf("cannot change your own admin status")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Admin = admin
|
||||||
|
if err := r.Store.DB.Save(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFavorite is the resolver for the createFavorite field.
|
||||||
|
func (r *mutationResolver) CreateFavorite(ctx context.Context, input model.CreateFavoriteInput) (*models.Favorite, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
|
||||||
|
if err := r.Store.DB.Create(&favorite).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &favorite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateActivity is the resolver for the createActivity field.
|
||||||
|
func (r *mutationResolver) CreateActivity(ctx context.Context, input model.CreateActivityInput) (*models.Activity, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
|
||||||
|
if err := r.Store.DB.Create(&activity).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &activity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users is the resolver for the users field.
|
||||||
|
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
|
||||||
|
var users []models.User
|
||||||
|
if err := r.Store.DB.Find(&users).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.User, len(users))
|
||||||
|
for i := range users {
|
||||||
|
result[i] = &users[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is the resolver for the user field.
|
||||||
|
func (r *queryResolver) User(ctx context.Context, id int) (*models.User, error) {
|
||||||
|
var user models.User
|
||||||
|
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posts is the resolver for the posts field.
|
||||||
|
func (r *queryResolver) Posts(ctx context.Context) ([]*models.Post, error) {
|
||||||
|
var posts []models.Post
|
||||||
|
if err := r.Store.DB.Preload("Author").Order("created_at DESC").Find(&posts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Post, len(posts))
|
||||||
|
for i := range posts {
|
||||||
|
result[i] = &posts[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post is the resolver for the post field.
|
||||||
|
func (r *queryResolver) Post(ctx context.Context, id int) (*models.Post, error) {
|
||||||
|
var post models.Post
|
||||||
|
if err := r.Store.DB.Preload("Author").First(&post, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("post not found")
|
||||||
|
}
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activities is the resolver for the activities field.
|
||||||
|
func (r *queryResolver) Activities(ctx context.Context) ([]*models.Activity, error) {
|
||||||
|
var activities []models.Activity
|
||||||
|
if err := r.Store.DB.Order("created_at DESC").Find(&activities).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Activity, len(activities))
|
||||||
|
for i := range activities {
|
||||||
|
result[i] = &activities[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites is the resolver for the favorites field.
|
||||||
|
func (r *queryResolver) Favorites(ctx context.Context) ([]*models.Favorite, error) {
|
||||||
|
var favorites []models.Favorite
|
||||||
|
if err := r.Store.DB.Order("created_at DESC").Find(&favorites).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Favorite, len(favorites))
|
||||||
|
for i := range favorites {
|
||||||
|
result[i] = &favorites[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RowingSessions is the resolver for the rowingSessions field.
|
||||||
|
func (r *queryResolver) RowingSessions(ctx context.Context) ([]*models.Rowing, error) {
|
||||||
|
var rows []models.Rowing
|
||||||
|
if err := r.Store.DB.Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Rowing, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
result[i] = &rows[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages is the resolver for the messages field.
|
||||||
|
func (r *queryResolver) Messages(ctx context.Context) ([]*models.Message, error) {
|
||||||
|
var messages []models.Message
|
||||||
|
if err := r.Store.DB.Order("created_at DESC").Find(&messages).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Message, len(messages))
|
||||||
|
for i := range messages {
|
||||||
|
result[i] = &messages[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpotifyListening is the resolver for the spotifyListening field.
|
||||||
|
func (r *queryResolver) SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error) {
|
||||||
|
if r.Store.SpotifyClient == nil {
|
||||||
|
return nil, fmt.Errorf("Spotify not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
playing, err := r.Store.SpotifyClient.PlayerCurrentlyPlaying(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &model.SpotifyPlaying{Playing: playing.Playing}
|
||||||
|
if playing.Item != nil {
|
||||||
|
result.Track = mapSpotifyTrack(playing.Item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpotifyRecent is the resolver for the spotifyRecent field.
|
||||||
|
func (r *queryResolver) SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error) {
|
||||||
|
if r.Store.SpotifyClient == nil {
|
||||||
|
return nil, fmt.Errorf("Spotify not authenticated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Store.RecentSongsFresh() {
|
||||||
|
return mapRecentItems(*r.Store.RecentSongs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := spotify.RecentlyPlayedOptions{Limit: 3}
|
||||||
|
played, err := r.Store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Store.RecentSongs = &played
|
||||||
|
r.Store.RecentSongsFetchedAt = time.Now()
|
||||||
|
|
||||||
|
return mapRecentItems(played), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me is the resolver for the me field.
|
||||||
|
func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
|
||||||
|
userID, ok := UserIDFromCtx(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
user.ID = userID
|
||||||
|
if err := r.Store.DB.First(&user).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutation returns MutationResolver implementation.
|
||||||
|
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
||||||
|
|
||||||
|
// Query returns QueryResolver implementation.
|
||||||
|
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
|
||||||
|
|
||||||
|
type mutationResolver struct{ *Resolver }
|
||||||
|
type queryResolver struct{ *Resolver }
|
||||||
|
|
||||||
14
backend/graph/schema/activity.graphql
Normal file
14
backend/graph/schema/activity.graphql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
type Activity {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
type: String!
|
||||||
|
name: String!
|
||||||
|
link: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateActivityInput {
|
||||||
|
type: String!
|
||||||
|
name: String!
|
||||||
|
link: String
|
||||||
|
}
|
||||||
8
backend/graph/schema/auth.graphql
Normal file
8
backend/graph/schema/auth.graphql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
input LoginInput {
|
||||||
|
username: String!
|
||||||
|
password: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthPayload {
|
||||||
|
user: User!
|
||||||
|
}
|
||||||
14
backend/graph/schema/favorite.graphql
Normal file
14
backend/graph/schema/favorite.graphql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
type Favorite {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
type: String!
|
||||||
|
name: String!
|
||||||
|
link: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateFavoriteInput {
|
||||||
|
type: String!
|
||||||
|
name: String!
|
||||||
|
link: String
|
||||||
|
}
|
||||||
7
backend/graph/schema/message.graphql
Normal file
7
backend/graph/schema/message.graphql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
type Message {
|
||||||
|
id: ID!
|
||||||
|
content: String!
|
||||||
|
authorId: Int!
|
||||||
|
fileUrl: String
|
||||||
|
createdAt: Time!
|
||||||
|
}
|
||||||
18
backend/graph/schema/post.graphql
Normal file
18
backend/graph/schema/post.graphql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
type Post {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
title: String!
|
||||||
|
author: User
|
||||||
|
content: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreatePostInput {
|
||||||
|
title: String!
|
||||||
|
content: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdatePostInput {
|
||||||
|
title: String!
|
||||||
|
content: String!
|
||||||
|
}
|
||||||
9
backend/graph/schema/rowing.graphql
Normal file
9
backend/graph/schema/rowing.graphql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
type Rowing {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
date: Time!
|
||||||
|
time: Int!
|
||||||
|
distance: Int!
|
||||||
|
timePer500m: Float!
|
||||||
|
calories: Float!
|
||||||
|
}
|
||||||
29
backend/graph/schema/schema.graphql
Normal file
29
backend/graph/schema/schema.graphql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
scalar Time
|
||||||
|
|
||||||
|
type Query {
|
||||||
|
users: [User!]!
|
||||||
|
user(id: ID!): User
|
||||||
|
posts: [Post!]!
|
||||||
|
post(id: ID!): Post
|
||||||
|
activities: [Activity!]!
|
||||||
|
favorites: [Favorite!]!
|
||||||
|
rowingSessions: [Rowing!]!
|
||||||
|
messages: [Message!]!
|
||||||
|
spotifyListening: SpotifyPlaying
|
||||||
|
spotifyRecent: [SpotifyRecentItem!]!
|
||||||
|
me: User
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation {
|
||||||
|
login(input: LoginInput!): AuthPayload!
|
||||||
|
logout: Boolean!
|
||||||
|
refreshToken: AuthPayload!
|
||||||
|
createPost(input: CreatePostInput!): Post!
|
||||||
|
updatePost(id: ID!, input: UpdatePostInput!): Post!
|
||||||
|
deletePost(id: ID!): Post!
|
||||||
|
createUser(input: CreateUserInput!): User!
|
||||||
|
deleteUser(id: ID!): User!
|
||||||
|
setUserAdmin(id: ID!, admin: Boolean!): User!
|
||||||
|
createFavorite(input: CreateFavoriteInput!): Favorite!
|
||||||
|
createActivity(input: CreateActivityInput!): Activity!
|
||||||
|
}
|
||||||
28
backend/graph/schema/spotify.graphql
Normal file
28
backend/graph/schema/spotify.graphql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
type SpotifyArtist {
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyImage {
|
||||||
|
url: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyAlbum {
|
||||||
|
name: String!
|
||||||
|
images: [SpotifyImage!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyTrack {
|
||||||
|
name: String!
|
||||||
|
artists: [SpotifyArtist!]!
|
||||||
|
album: SpotifyAlbum!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyPlaying {
|
||||||
|
playing: Boolean!
|
||||||
|
track: SpotifyTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyRecentItem {
|
||||||
|
track: SpotifyTrack!
|
||||||
|
playedAt: Time!
|
||||||
|
}
|
||||||
12
backend/graph/schema/user.graphql
Normal file
12
backend/graph/schema/user.graphql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
type User {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
username: String!
|
||||||
|
admin: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateUserInput {
|
||||||
|
username: String!
|
||||||
|
password: String!
|
||||||
|
}
|
||||||
51
backend/graph/spotify_helpers.go
Normal file
51
backend/graph/spotify_helpers.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
import (
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"github.com/zmb3/spotify/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mapSpotifyImages(images []spotify.Image) []*model.SpotifyImage {
|
||||||
|
result := make([]*model.SpotifyImage, len(images))
|
||||||
|
for i, img := range images {
|
||||||
|
result[i] = &model.SpotifyImage{URL: img.URL}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapSpotifyTrack(track *spotify.FullTrack) *model.SpotifyTrack {
|
||||||
|
artists := make([]*model.SpotifyArtist, len(track.Artists))
|
||||||
|
for i, a := range track.Artists {
|
||||||
|
artists[i] = &model.SpotifyArtist{Name: a.Name}
|
||||||
|
}
|
||||||
|
return &model.SpotifyTrack{
|
||||||
|
Name: track.Name,
|
||||||
|
Artists: artists,
|
||||||
|
Album: &model.SpotifyAlbum{
|
||||||
|
Name: track.Album.Name,
|
||||||
|
Images: mapSpotifyImages(track.Album.Images),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapRecentItems(items []spotify.RecentlyPlayedItem) []*model.SpotifyRecentItem {
|
||||||
|
result := make([]*model.SpotifyRecentItem, len(items))
|
||||||
|
for i, item := range items {
|
||||||
|
artists := make([]*model.SpotifyArtist, len(item.Track.Artists))
|
||||||
|
for j, a := range item.Track.Artists {
|
||||||
|
artists[j] = &model.SpotifyArtist{Name: a.Name}
|
||||||
|
}
|
||||||
|
result[i] = &model.SpotifyRecentItem{
|
||||||
|
PlayedAt: item.PlayedAt,
|
||||||
|
Track: &model.SpotifyTrack{
|
||||||
|
Name: item.Track.Name,
|
||||||
|
Artists: artists,
|
||||||
|
Album: &model.SpotifyAlbum{
|
||||||
|
Name: item.Track.Album.Name,
|
||||||
|
Images: mapSpotifyImages(item.Track.Album.Images),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
22
backend/graph/user.resolvers.go
Normal file
22
backend/graph/user.resolvers.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *userResolver) ID(ctx context.Context, obj *models.User) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// User returns UserResolver implementation.
|
||||||
|
func (r *Resolver) User() UserResolver { return &userResolver{r} }
|
||||||
|
|
||||||
|
type userResolver struct{ *Resolver }
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
"adam-french.co.uk/backend/models"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -27,6 +27,28 @@ func (store *Store) AuthMiddlewear(ctx *gin.Context) {
|
|||||||
ctx.Next()
|
ctx.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) AdminMiddleware(ctx *gin.Context) {
|
||||||
|
claims, exists := ctx.Get("userClaims")
|
||||||
|
if !exists {
|
||||||
|
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mapClaims, ok := claims.(*jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid claims"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, ok := (*mapClaims)["admin"].(bool)
|
||||||
|
if !ok || !admin {
|
||||||
|
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) CheckToken(ctx *gin.Context) {
|
func (store *Store) CheckToken(ctx *gin.Context) {
|
||||||
access_token, err := ctx.Cookie("access_token")
|
access_token, err := ctx.Cookie("access_token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -68,10 +90,9 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
|||||||
claims, err := store.Auth.VerifyJWT(refreshToken)
|
claims, err := store.Auth.VerifyJWT(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
ctx.JSON(http.StatusUnauthorized, err.Error())
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("claims: %v\n", claims)
|
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
|
||||||
@@ -93,6 +114,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
tokens.AccessToken,
|
tokens.AccessToken,
|
||||||
@@ -122,12 +144,12 @@ func (store *Store) Login(ctx *gin.Context) {
|
|||||||
|
|
||||||
user := models.User{}
|
user := models.User{}
|
||||||
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
|
||||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +159,7 @@ func (store *Store) Login(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
tokens.AccessToken,
|
tokens.AccessToken,
|
||||||
@@ -164,6 +187,7 @@ func (store *Store) Logout(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeCookies(ctx *gin.Context) {
|
func removeCookies(ctx *gin.Context) {
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
"",
|
"",
|
||||||
|
|||||||
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})
|
||||||
|
}
|
||||||
@@ -67,11 +67,6 @@ func (store *Store) CreatePost(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
userID := uint(userIDF)
|
userID := uint(userIDF)
|
||||||
|
|
||||||
if !(*claims)["admin"].(bool) {
|
|
||||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "you are not admin :("})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create post
|
// Create post
|
||||||
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
|
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
|
||||||
tx := store.DB.Create(&post)
|
tx := store.DB.Create(&post)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"strings"
|
||||||
|
|
||||||
"github.com/rwcarlsen/goexif/exif"
|
"github.com/rwcarlsen/goexif/exif"
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ExtractedRowingData struct {
|
type ExtractedRowingData struct {
|
||||||
TimeMinutes float64 `json:"timeMinutes"`
|
TimeMinutes uint64 `json:"timeMinutes"`
|
||||||
TimeSeconds float64 `json:"timeSeconds"`
|
TimeSeconds uint64 `json:"timeSeconds"`
|
||||||
Distance float64 `json:"distance"`
|
Distance uint64 `json:"distance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) GetRowing(ctx *gin.Context) {
|
func (store *Store) GetRowing(ctx *gin.Context) {
|
||||||
@@ -82,6 +82,13 @@ func (store *Store) CreateRowing(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
encoded := base64.StdEncoding.EncodeToString(data)
|
encoded := base64.StdEncoding.EncodeToString(data)
|
||||||
|
|
||||||
|
// Reject duplicates: same EXIF datetime already recorded
|
||||||
|
var existing models.Rowing
|
||||||
|
if err := store.DB.Where("date = ?", dateTaken).First(&existing).Error; err == nil {
|
||||||
|
ctx.JSON(http.StatusConflict, gin.H{"error": "duplicate entry for this date"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Build the message with an image + text prompt
|
// Build the message with an image + text prompt
|
||||||
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
|
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
|
||||||
Model: anthropic.ModelClaudeHaiku4_5,
|
Model: anthropic.ModelClaudeHaiku4_5,
|
||||||
@@ -110,21 +117,38 @@ No text, no markdown, no explanation. Just the JSON object.`),
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"raw": message.Content[0].Text,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(message.Content) == 0 {
|
if len(message.Content) == 0 {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"raw": message.Content[0].Text,
|
||||||
|
"error": "empty response from Claude",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
extractedData := ExtractedRowingData{}
|
extractedData := ExtractedRowingData{}
|
||||||
err = json.Unmarshal([]byte(message.Content[0].Text), &extractedData)
|
raw := message.Content[0].Text
|
||||||
|
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
raw = strings.TrimPrefix(raw, "```json")
|
||||||
|
raw = strings.TrimPrefix(raw, "```")
|
||||||
|
raw = strings.TrimSuffix(raw, "```")
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
|
||||||
|
err = json.Unmarshal([]byte(raw), &extractedData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"error": "failed to parse JSON response",
|
||||||
|
"detail": err.Error(),
|
||||||
|
"raw": raw,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,15 +158,39 @@ No text, no markdown, no explanation. Just the JSON object.`),
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
|
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
|
||||||
totalDuration := time.Duration(totalSeconds * float64(time.Second))
|
|
||||||
per500m := time.Duration(totalSeconds / extractedData.Distance * 500 * float64(time.Second))
|
// Validate for anomalous values
|
||||||
|
const (
|
||||||
|
minDistance = 100 // metres
|
||||||
|
maxDistance = 100000 // metres
|
||||||
|
minTotalSecs = 30 // 30 seconds
|
||||||
|
maxTotalSecs = 7200 // 2 hours
|
||||||
|
minPacePer500m = 80 // ~1:20 /500m (faster than any human)
|
||||||
|
maxPacePer500m = 150 // ~2:30 /500m (slow, not important)
|
||||||
|
)
|
||||||
|
if extractedData.Distance < minDistance || extractedData.Distance > maxDistance {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous distance value"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if totalSeconds < minTotalSecs || totalSeconds > maxTotalSecs {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous time value"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
per500m := float64(totalSeconds) / float64(extractedData.Distance) * 500.0
|
||||||
|
if per500m < minPacePer500m || per500m > maxPacePer500m {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous pace value"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
calories := float64(extractedData.Distance) / 7500.0 * 500.0
|
||||||
|
|
||||||
rowing := models.Rowing{
|
rowing := models.Rowing{
|
||||||
Date: dateTaken,
|
Date: dateTaken,
|
||||||
Time: totalDuration,
|
Time: totalSeconds,
|
||||||
TimePer500m: per500m,
|
TimePer500m: per500m,
|
||||||
Distance: extractedData.Distance,
|
Distance: extractedData.Distance,
|
||||||
Calories: extractedData.Distance / 7500.0 * 500.0,
|
Calories: calories,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.DB.Create(&rowing).Error; err != nil {
|
if err := store.DB.Create(&rowing).Error; err != nil {
|
||||||
|
|||||||
@@ -53,10 +53,16 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) RecentlyPlayed(ctx *gin.Context) {
|
func (store *Store) RecentlyPlayed(ctx *gin.Context) {
|
||||||
|
if store.SpotifyClient == nil {
|
||||||
|
ctx.JSON(500, gin.H{"error": "Spotify not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
opts := spotify.RecentlyPlayedOptions{Limit: 3}
|
opts := spotify.RecentlyPlayedOptions{Limit: 3}
|
||||||
|
|
||||||
if store.RecentSongsFresh() {
|
if store.RecentSongsFresh() {
|
||||||
ctx.JSON(200, *store.RecentSongs)
|
ctx.JSON(200, *store.RecentSongs)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ type UserCredentials struct {
|
|||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetAdminInput struct {
|
||||||
|
Admin *bool `json:"admin" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) CreateUser(ctx *gin.Context) {
|
func (store *Store) CreateUser(ctx *gin.Context) {
|
||||||
var input UserCredentials
|
var input UserCredentials
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||||
@@ -31,32 +35,9 @@ func (store *Store) CreateUser(ctx *gin.Context) {
|
|||||||
tx := store.DB.Create(&user)
|
tx := store.DB.Create(&user)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
tokens, err := store.Auth.GenerateJWT(&user)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.SetCookie(
|
|
||||||
"access_token",
|
|
||||||
tokens.AccessToken,
|
|
||||||
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
|
|
||||||
store.Auth.Config.Endpoint,
|
|
||||||
store.Auth.Config.Domain,
|
|
||||||
true, true,
|
|
||||||
)
|
|
||||||
ctx.SetCookie(
|
|
||||||
"refresh_token",
|
|
||||||
tokens.RefreshToken,
|
|
||||||
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
|
||||||
store.Auth.Config.Endpoint,
|
|
||||||
store.Auth.Config.Domain,
|
|
||||||
true, true,
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, user)
|
ctx.JSON(http.StatusOK, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +90,52 @@ func (store *Store) UpdateUser(ctx *gin.Context) {
|
|||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "will be implemented"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "will be implemented"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) SetUserAdmin(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
|
||||||
|
}
|
||||||
|
callerIDF, ok := (*claims)["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callerID := uint(callerIDF)
|
||||||
|
|
||||||
|
targetID := ctx.Param("id")
|
||||||
|
|
||||||
|
var input SetAdminInput
|
||||||
|
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := store.DB.First(&user, targetID).Error; err != nil {
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.ID == callerID {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot change your own admin status"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Admin = *input.Admin
|
||||||
|
if err := store.DB.Save(&user).Error; err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) DeleteUser(ctx *gin.Context) {
|
func (store *Store) DeleteUser(ctx *gin.Context) {
|
||||||
claimsVal, ok := ctx.Get("userClaims")
|
claimsVal, ok := ctx.Get("userClaims")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -141,6 +168,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -7,15 +7,18 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/99designs/gqlgen/graphql/handler"
|
||||||
|
"github.com/99designs/gqlgen/graphql/playground"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph"
|
||||||
"adam-french.co.uk/backend/handlers"
|
"adam-french.co.uk/backend/handlers"
|
||||||
"adam-french.co.uk/backend/services"
|
"adam-french.co.uk/backend/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logsDir := "/backend/logs"
|
logsDir := "/backend/logs"
|
||||||
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -38,6 +41,11 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if os.Getenv("SEED_DB") == "true" {
|
||||||
|
services.SeedDatabase(db)
|
||||||
|
}
|
||||||
|
domainName := os.Getenv("DOMAIN")
|
||||||
|
services.InitWebSocket(db, domainName)
|
||||||
|
|
||||||
// SPOTIFY
|
// SPOTIFY
|
||||||
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
||||||
@@ -53,7 +61,6 @@ func main() {
|
|||||||
claudeClient := services.InitClaude(&claudeConfig)
|
claudeClient := services.InitClaude(&claudeConfig)
|
||||||
|
|
||||||
authSecret := os.Getenv("BACKEND_SECRET")
|
authSecret := os.Getenv("BACKEND_SECRET")
|
||||||
domainName := os.Getenv("DOMAIN")
|
|
||||||
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
|
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
|
||||||
accessTokenLifetime := 24 * time.Hour
|
accessTokenLifetime := 24 * time.Hour
|
||||||
refreshTokenLifetime := 365 * 24 * time.Hour
|
refreshTokenLifetime := 365 * 24 * time.Hour
|
||||||
@@ -67,32 +74,34 @@ func main() {
|
|||||||
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes}
|
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes}
|
||||||
|
|
||||||
protected := r.Group("/", store.AuthMiddlewear)
|
protected := r.Group("/", store.AuthMiddlewear)
|
||||||
|
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)
|
||||||
|
|
||||||
// FAVORITES
|
// FAVORITES
|
||||||
r.GET("/favorites", store.GetFavorites)
|
r.GET("/favorites", store.GetFavorites)
|
||||||
protected.POST("/favorites", store.CreateFavorite)
|
admin.POST("/favorites", store.CreateFavorite)
|
||||||
|
|
||||||
// ROWING
|
// ROWING
|
||||||
r.GET("/rowing", store.GetRowing)
|
r.GET("/rowing", store.GetRowing)
|
||||||
protected.POST("/rowing", store.CreateRowing)
|
admin.POST("/rowing", store.CreateRowing)
|
||||||
|
|
||||||
// ACTIVITIES
|
// ACTIVITIES
|
||||||
r.GET("/activity", store.GetActivity)
|
r.GET("/activity", store.GetActivity)
|
||||||
protected.POST("/activity", store.CreateActivity)
|
admin.POST("/activity", store.CreateActivity)
|
||||||
|
|
||||||
// POSTS
|
// POSTS
|
||||||
r.GET("/posts", store.GetPosts)
|
r.GET("/posts", store.GetPosts)
|
||||||
protected.POST("/posts", store.CreatePost)
|
admin.POST("/posts", store.CreatePost)
|
||||||
r.GET("/posts/:id", store.GetPost)
|
r.GET("/posts/:id", store.GetPost)
|
||||||
protected.PUT("/posts/:id", store.UpdatePost)
|
admin.PUT("/posts/:id", store.UpdatePost)
|
||||||
protected.DELETE("/posts/:id", store.DeletePost)
|
admin.DELETE("/posts/:id", store.DeletePost)
|
||||||
|
|
||||||
// USERS
|
// USERS
|
||||||
r.GET("/user/:id", store.GetUser)
|
r.GET("/user/:id", store.GetUser)
|
||||||
protected.PUT("/user/:id", store.UpdateUser)
|
admin.PUT("/user/:id", store.UpdateUser)
|
||||||
protected.DELETE("/user/:id", store.DeleteUser)
|
admin.DELETE("/user/:id", store.DeleteUser)
|
||||||
r.GET("/user", store.GetUsers)
|
r.GET("/user", store.GetUsers)
|
||||||
r.POST("/user", store.CreateUser)
|
admin.POST("/user", store.CreateUser)
|
||||||
|
admin.PATCH("/user/:id/admin", store.SetUserAdmin)
|
||||||
|
|
||||||
// AUTH
|
// AUTH
|
||||||
r.POST("/auth/login", store.Login)
|
r.POST("/auth/login", store.Login)
|
||||||
@@ -108,10 +117,22 @@ func main() {
|
|||||||
|
|
||||||
// MESSAGES
|
// MESSAGES
|
||||||
r.GET("/ws", store.ConnectWebSocket)
|
r.GET("/ws", store.ConnectWebSocket)
|
||||||
|
protected.POST("/messages/upload", store.UploadMessageFile)
|
||||||
|
|
||||||
// NOTES
|
// NOTES
|
||||||
r.GET("/notes/*path", store.GetNoteFile)
|
r.GET("/notes/*path", store.GetNoteFile)
|
||||||
|
|
||||||
|
// GRAPHQL
|
||||||
|
gqlSrv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
|
||||||
|
Resolvers: &graph.Resolver{Store: &store},
|
||||||
|
}))
|
||||||
|
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
|
||||||
|
gqlSrv.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
r.GET("/graphql", func(c *gin.Context) {
|
||||||
|
playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
|
||||||
// HELLO WORLD
|
// HELLO WORLD
|
||||||
r.GET("/", func(c *gin.Context) {
|
r.GET("/", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"message": "Hello World"})
|
c.JSON(200, gin.H{"message": "Hello World"})
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ type Post struct {
|
|||||||
type Message struct {
|
type Message struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Content string `json:"text"`
|
Content string `json:"text"`
|
||||||
AuthorID uint `json:"-"`
|
AuthorID uint `json:"authorId"`
|
||||||
Author *User `gorm:"foreignKey:AuthorID" json:"author"`
|
FileURL string `json:"fileUrl,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
}
|
}
|
||||||
@@ -61,8 +61,8 @@ type Rowing struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
Time time.Duration `json:"time"`
|
Time uint64 `json:"time"`
|
||||||
TimePer500m time.Duration `json:"timePer500m"`
|
Distance uint64 `json:"distance"`
|
||||||
Distance float64 `json:"distance"`
|
TimePer500m float64 `json:"timePer500m"`
|
||||||
Calories float64 `json:"calories"`
|
Calories float64 `json:"calories"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func migrateDatabase(db *gorm.DB) error {
|
|||||||
&models.Activity{},
|
&models.Activity{},
|
||||||
&models.Favorite{},
|
&models.Favorite{},
|
||||||
&models.Rowing{},
|
&models.Rowing{},
|
||||||
|
&models.Message{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
65
backend/services/seed.go
Normal file
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
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
"adam-french.co.uk/backend/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxMessages = 50
|
||||||
|
|
||||||
|
var allowedDomain string
|
||||||
|
|
||||||
var Upgrader = websocket.Upgrader{
|
var Upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
origin = strings.TrimPrefix(origin, "https://")
|
||||||
|
origin = strings.TrimPrefix(origin, "http://")
|
||||||
|
return origin == allowedDomain || origin == "www."+allowedDomain
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
clients = make(map[*websocket.Conn]bool)
|
clients = make(map[*websocket.Conn]bool)
|
||||||
messages = make([]models.Message, 0)
|
mu sync.Mutex
|
||||||
mu sync.Mutex
|
wsDB *gorm.DB
|
||||||
|
nextAuthorID uint
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
rateLimitWindow = time.Second
|
||||||
|
rateLimitMaxMsgs = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitWebSocket(database *gorm.DB, domain string) {
|
||||||
|
wsDB = database
|
||||||
|
allowedDomain = domain
|
||||||
|
}
|
||||||
|
|
||||||
func HandleWebSocket(conn *websocket.Conn) {
|
func HandleWebSocket(conn *websocket.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
clients[conn] = true
|
clients[conn] = true
|
||||||
|
nextAuthorID++
|
||||||
|
authorID := nextAuthorID
|
||||||
|
|
||||||
// Send existing message history to new client
|
var history []models.Message
|
||||||
for _, msg := range messages {
|
wsDB.Order("created_at ASC").Limit(maxMessages).Find(&history)
|
||||||
|
|
||||||
|
for _, msg := range history {
|
||||||
if err := conn.WriteJSON(msg); err != nil {
|
if err := conn.WriteJSON(msg); err != nil {
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
return
|
return
|
||||||
@@ -35,17 +66,32 @@ func HandleWebSocket(conn *websocket.Conn) {
|
|||||||
}
|
}
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
|
msgCount := 0
|
||||||
|
windowStart := time.Now()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var incoming models.Message
|
var incoming models.Message
|
||||||
if err := conn.ReadJSON(&incoming); err != nil {
|
if err := conn.ReadJSON(&incoming); err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
incoming.CreatedAt = time.Now()
|
now := time.Now()
|
||||||
|
if now.Sub(windowStart) > rateLimitWindow {
|
||||||
|
msgCount = 0
|
||||||
|
windowStart = now
|
||||||
|
}
|
||||||
|
msgCount++
|
||||||
|
if msgCount > rateLimitMaxMsgs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming.AuthorID = authorID
|
||||||
|
|
||||||
// Store and broadcast
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
messages = append(messages, incoming)
|
wsDB.Create(&incoming)
|
||||||
|
wsDB.Where("id NOT IN (?)",
|
||||||
|
wsDB.Model(&models.Message{}).Select("id").Order("created_at DESC").Limit(maxMessages),
|
||||||
|
).Delete(&models.Message{})
|
||||||
|
|
||||||
for client := range clients {
|
for client := range clients {
|
||||||
if err := client.WriteJSON(incoming); err != nil {
|
if err := client.WriteJSON(incoming); err != nil {
|
||||||
@@ -56,7 +102,6 @@ func HandleWebSocket(conn *websocket.Conn) {
|
|||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup on disconnect
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
delete(clients, conn)
|
delete(clients, conn)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|||||||
10
docker-compose.dev.yml
Normal file
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:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
|
uploads:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
nginx:
|
nginx:
|
||||||
@@ -25,6 +26,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./certbot/conf:/etc/letsencrypt
|
- ./certbot/conf:/etc/letsencrypt
|
||||||
- ./certbot/www:/var/www/certbot
|
- ./certbot/www:/var/www/certbot
|
||||||
|
- uploads:/uploads
|
||||||
|
|
||||||
certbot:
|
certbot:
|
||||||
image: certbot/certbot
|
image: certbot/certbot
|
||||||
@@ -55,6 +57,7 @@ services:
|
|||||||
- ./backend/token/:/backend/token
|
- ./backend/token/:/backend/token
|
||||||
- ${OBSIDIAN_DIR}:/backend/notes
|
- ${OBSIDIAN_DIR}:/backend/notes
|
||||||
- ./logs:/backend/logs
|
- ./logs:/backend/logs
|
||||||
|
- uploads:/backend/uploads
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
@@ -66,8 +69,6 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/postgresql/data
|
- dbdata:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
|
|
||||||
icecast2:
|
icecast2:
|
||||||
build:
|
build:
|
||||||
@@ -79,12 +80,16 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
|
volumes:
|
||||||
|
- ./icecast2/fallback_music:/music:ro
|
||||||
ports:
|
ports:
|
||||||
- "${ICECAST_PORT}:${ICECAST_PORT}"
|
- "${LIQUIDSOAP_HARBOR_PORT:-8001}:${LIQUIDSOAP_HARBOR_PORT:-8001}"
|
||||||
|
|
||||||
gitea-runner:
|
gitea-runner:
|
||||||
image: gitea/act_runner:latest
|
image: gitea/act_runner:latest
|
||||||
container_name: "${GITEA_RUNNER_HOST}"
|
container_name: "${GITEA_RUNNER_HOST}"
|
||||||
|
profiles:
|
||||||
|
- disabled
|
||||||
environment:
|
environment:
|
||||||
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
|
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
|
||||||
CONFIG_FILE: /config.yaml
|
CONFIG_FILE: /config.yaml
|
||||||
@@ -94,7 +99,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./gitea-runner/config.yaml:/config.yaml
|
- ./gitea-runner/config.yaml:/config.yaml
|
||||||
- ./gitea-runner/data:/data
|
- ./gitea-runner/data:/data
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock # WARNING: Docker socket mount gives container host-level access. Runner is in 'disabled' profile to mitigate risk.
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
@@ -110,6 +115,11 @@ services:
|
|||||||
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
|
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
|
||||||
- GITEA__database__USER=${POSTGRES_USER}
|
- GITEA__database__USER=${POSTGRES_USER}
|
||||||
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
|
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
|
||||||
|
- GITEA__server__LFS_JWT_SECRET=${GITEA_LFS_JWT_SECRET}
|
||||||
|
- GITEA__security__INTERNAL_TOKEN=${GITEA_INTERNAL_TOKEN}
|
||||||
|
- GITEA__oauth2__JWT_SECRET=${GITEA_OAUTH2_JWT_SECRET}
|
||||||
|
- USER_UID=1000
|
||||||
|
- USER_GID=1000
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./gitea/data:/var/lib/gitea
|
- ./gitea/data:/var/lib/gitea
|
||||||
@@ -117,7 +127,7 @@ services:
|
|||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
|
||||||
- "2222:2222"
|
- "2222:2222"
|
||||||
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ SSH_LISTEN_PORT = 2222
|
|||||||
BUILTIN_SSH_SERVER_USER = git
|
BUILTIN_SSH_SERVER_USER = git
|
||||||
LFS_START_SERVER = true
|
LFS_START_SERVER = true
|
||||||
DOMAIN = stppi.local
|
DOMAIN = stppi.local
|
||||||
LFS_JWT_SECRET = XHIJprS_aMv0tizioZpUD38GGqTtNMFXMz1R6LuPvjU
|
LFS_JWT_SECRET =
|
||||||
OFFLINE_MODE = true
|
OFFLINE_MODE = true
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
@@ -34,7 +34,7 @@ DB_TYPE = postgres
|
|||||||
HOST = db
|
HOST = db
|
||||||
NAME = gitea
|
NAME = gitea
|
||||||
USER = postgres
|
USER = postgres
|
||||||
PASSWD = password
|
PASSWD =
|
||||||
SCHEMA =
|
SCHEMA =
|
||||||
SSL_MODE = disable
|
SSL_MODE = disable
|
||||||
LOG_SQL = false
|
LOG_SQL = false
|
||||||
@@ -60,7 +60,7 @@ INSTALL_LOCK = true
|
|||||||
SECRET_KEY =
|
SECRET_KEY =
|
||||||
REVERSE_PROXY_LIMIT = 1
|
REVERSE_PROXY_LIMIT = 1
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||||
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzEyNDMyMTd9.yHsgFcEwDNWmZebftpe8tpWRFa5aR5tkpQuVYybeVaY
|
INTERNAL_TOKEN =
|
||||||
PASSWORD_HASH_ALGO = pbkdf2
|
PASSWORD_HASH_ALGO = pbkdf2
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
@@ -95,4 +95,4 @@ DEFAULT_MERGE_STYLE = merge
|
|||||||
DEFAULT_TRUST_MODEL = committer
|
DEFAULT_TRUST_MODEL = committer
|
||||||
|
|
||||||
[oauth2]
|
[oauth2]
|
||||||
JWT_SECRET = pYiwW8xxGi23gysl2pa-02Cf567Z5ERvR6DDFGIn2iQ
|
JWT_SECRET =
|
||||||
@@ -1,19 +1,13 @@
|
|||||||
FROM debian:latest as builder
|
FROM savonet/liquidsoap:v2.3.2
|
||||||
|
USER root
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install --yes icecast2 gettext media-types
|
&& apt-get install --yes icecast2 gettext media-types \
|
||||||
# RUN apt-get install --yes liquidsoap
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN useradd radio
|
RUN useradd radio
|
||||||
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2
|
RUN mkdir -p /music /etc/liquidsoap
|
||||||
# RUN chown -R radio:radio /etc/liquidsoap /var/log/liquidsoap
|
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2 /music /etc/liquidsoap
|
||||||
USER radio
|
USER radio
|
||||||
|
|
||||||
COPY icecast.xml.template /etc/icecast2/icecast.xml.template
|
COPY icecast.xml.template /etc/icecast2/icecast.xml.template
|
||||||
# COPY stream.liq.template /etc/liquidsoap/stream.liq.template
|
COPY stream.liq.template /etc/liquidsoap/stream.liq.template
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Substitute environment variables into template
|
|
||||||
envsubst < /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml
|
envsubst < /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml
|
||||||
# envsubst < /etc/liquidsoap/stream.liq.template > /etc/liquidsoap/stream.liq
|
envsubst < /etc/liquidsoap/stream.liq.template > /etc/liquidsoap/stream.liq
|
||||||
|
|
||||||
# Run icecast with the generated config
|
icecast2 -c /etc/icecast2/icecast.xml &
|
||||||
exec icecast2 -c /etc/icecast2/icecast.xml
|
sleep 2
|
||||||
# exec liquidsoap /etc/liquidsoap/stream.liq
|
liquidsoap /etc/liquidsoap/stream.liq &
|
||||||
# wait -n
|
wait -n
|
||||||
|
kill $(jobs -p) 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
|||||||
0
icecast2/fallback_music/.gitkeep
Normal file
0
icecast2/fallback_music/.gitkeep
Normal file
@@ -1,24 +1,17 @@
|
|||||||
[general]
|
settings.server.telnet := false
|
||||||
duration = 0 # 0 = run forever
|
|
||||||
bufferSecs = 5 # buffer size in seconds
|
|
||||||
reconnect = yes # reconnect on failure
|
|
||||||
reconnectDelay = 5
|
|
||||||
|
|
||||||
[input]
|
music = playlist("/music", mode="randomize", reload_mode="watch")
|
||||||
device = pulse # PulseAudio input
|
|
||||||
sampleRate = 44100 # in Hz
|
|
||||||
bitsPerSample = 16
|
|
||||||
channel = 2
|
|
||||||
|
|
||||||
[icecast2-0]
|
live = input.harbor("${LIQUIDSOAP_HARBOR_MOUNT}", port=${LIQUIDSOAP_HARBOR_PORT}, password="${ICECAST_SOURCE_PASSWORD}")
|
||||||
bitrateMode = cbr
|
|
||||||
bitrate = 128 # kbps
|
radio = amplify(0.7, fallback(track_sensitive=false, [live, music, blank()]))
|
||||||
format = mp3
|
|
||||||
server = ${ICECAST_HOST}
|
output.icecast(
|
||||||
port = ${ICECAST_PORT}
|
%mp3,
|
||||||
password = ${ICECAST_SOURCE_PASSWORD}
|
host="localhost",
|
||||||
mountPoint = ${ICECAST_MOUNT}
|
port=${ICECAST_PORT},
|
||||||
name = "Live DJ stream"
|
password="${ICECAST_SOURCE_PASSWORD}",
|
||||||
description = "Live microphone stream"
|
mount="${ICECAST_MOUNT}",
|
||||||
genre = "Various"
|
fallible=true,
|
||||||
public = yes
|
radio
|
||||||
|
)
|
||||||
|
|||||||
5
nginx/.dockerignore
Normal file
5
nginx/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
vue/node_modules
|
||||||
|
vue/.vite
|
||||||
|
vue/dist
|
||||||
|
**/.git
|
||||||
|
**/.DS_Store
|
||||||
@@ -1,32 +1,21 @@
|
|||||||
FROM nginx:latest
|
# Stage 1: Build Vue app
|
||||||
RUN rm -rf /etc/nginx/html/*
|
FROM node:22-slim AS build
|
||||||
|
RUN apt-get update && apt-get install -y make git && rm -rf /var/lib/apt/lists/*
|
||||||
# Install dependencies needed to add NodeSource repo
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
curl \
|
|
||||||
build-essential \
|
|
||||||
git \
|
|
||||||
gettext-base \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Node.js LTS + npm
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
|
||||||
&& apt-get install -y nodejs \
|
|
||||||
&& npm install -g npm@latest
|
|
||||||
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY vue/package.json vue/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
COPY vue/ ./
|
COPY vue/ ./
|
||||||
RUN npm install
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve with nginx
|
||||||
RUN mkdir -p /etc/nginx/html \
|
FROM nginx:latest
|
||||||
&& cp -r ./dist/* /etc/nginx/html/
|
RUN rm -rf /etc/nginx/html/* && \
|
||||||
|
apt-get update && apt-get install -y gettext-base && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=build /app/dist /etc/nginx/html/
|
||||||
COPY nginx.conf.template /etc/nginx/nginx.conf.template
|
COPY nginx.conf.template /etc/nginx/nginx.conf.template
|
||||||
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.conf.template
|
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.conf.template
|
||||||
|
COPY nginx_dev.conf.template /etc/nginx/nginx_dev.conf.template
|
||||||
COPY robots.txt /etc/nginx/html/robots.txt
|
COPY robots.txt /etc/nginx/html/robots.txt
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Check if certificate exists
|
# Check if dev mode, certificate exists, or setup mode
|
||||||
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
|
if [ "$DEV_MODE" = "true" ]; then
|
||||||
echo "Certificates found. Using production nginx config."
|
echo "Dev mode. Using HTTP-only nginx config."
|
||||||
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
|
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
|
||||||
< /etc/nginx/nginx.conf.template \
|
</etc/nginx/nginx_dev.conf.template \
|
||||||
> /etc/nginx/nginx.conf
|
>/etc/nginx/nginx.conf
|
||||||
|
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
|
||||||
|
echo "Certificates found. Using production nginx config."
|
||||||
|
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
|
||||||
|
</etc/nginx/nginx.conf.template \
|
||||||
|
>/etc/nginx/nginx.conf
|
||||||
else
|
else
|
||||||
echo "Certificates NOT found. Using setup nginx config."
|
echo "Certificates NOT found. Using setup nginx config."
|
||||||
envsubst '${DOMAIN}' < /etc/nginx/nginx_setup.conf.template > /etc/nginx/nginx.conf
|
envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Ensure upload directory is traversable by nginx worker
|
||||||
|
chmod 755 /uploads 2>/dev/null || true
|
||||||
|
|
||||||
# Start nginx
|
# Start nginx
|
||||||
nginx -g 'daemon off;'
|
nginx -g 'daemon off;'
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ http {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||||
|
|
||||||
log_format compact
|
log_format compact
|
||||||
'$remote_addr "$request" $status rt=$request_time';
|
'$remote_addr "$request" $status rt=$request_time';
|
||||||
|
|
||||||
@@ -55,6 +61,13 @@ http {
|
|||||||
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
alias /uploads/;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Content-Disposition "inline" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
@@ -77,7 +90,40 @@ http {
|
|||||||
return 301 $BACKEND_ENDPOINT/;
|
return 301 $BACKEND_ENDPOINT/;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/ws {
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/auth/login {
|
||||||
|
limit_req zone=login burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/messages/upload {
|
||||||
|
limit_req zone=upload burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location $BACKEND_ENDPOINT/ {
|
location $BACKEND_ENDPOINT/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
212
nginx/vue/package-lock.json
generated
212
nginx/vue/package-lock.json
generated
@@ -1024,9 +1024,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||||
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
|
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1037,9 +1037,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
|
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1050,9 +1050,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
|
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1063,9 +1063,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
|
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1076,9 +1076,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
|
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1089,9 +1089,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
|
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1102,9 +1102,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
|
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1115,9 +1115,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||||
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
|
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1128,9 +1128,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
|
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1141,9 +1141,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
|
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1154,9 +1154,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
|
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -1167,9 +1167,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
|
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -1180,9 +1180,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
|
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1193,9 +1193,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
|
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1206,9 +1206,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
|
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1219,9 +1219,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
|
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1232,9 +1232,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
|
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1245,9 +1245,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
|
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1258,9 +1258,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||||
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
|
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1271,9 +1271,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||||
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
|
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1284,9 +1284,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||||
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
|
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1297,9 +1297,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
|
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1310,9 +1310,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
|
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1323,9 +1323,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||||
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
|
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1336,9 +1336,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||||
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
|
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2982,9 +2982,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/markdown-it": {
|
"node_modules/markdown-it": {
|
||||||
"version": "14.1.0",
|
"version": "14.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1",
|
"argparse": "^2.0.1",
|
||||||
@@ -3245,9 +3245,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.55.1",
|
"version": "4.59.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||||
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
@@ -3260,31 +3260,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.55.1",
|
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||||
"@rollup/rollup-android-arm64": "4.55.1",
|
"@rollup/rollup-android-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-arm64": "4.55.1",
|
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||||
"@rollup/rollup-darwin-x64": "4.55.1",
|
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.55.1",
|
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||||
"@rollup/rollup-freebsd-x64": "4.55.1",
|
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.55.1",
|
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.55.1",
|
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.55.1",
|
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.55.1",
|
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||||
"@rollup/rollup-openbsd-x64": "4.55.1",
|
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.55.1",
|
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.55.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.55.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.55.1",
|
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.55.1",
|
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
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/pdf/transcript.pdf
Normal file
BIN
nginx/vue/public/pdf/transcript.pdf
Normal file
Binary file not shown.
@@ -5,7 +5,7 @@ import Footer from "@/components/Footer.vue";
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Navbar class="no-print" />
|
<Navbar class="no-print sticky" />
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|
||||||
<!-- <Footer style="height: 10vh" /> -->
|
<!-- <Footer style="height: 10vh" /> -->
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
/* PRINTING */
|
/* PRINTING */
|
||||||
@media print {
|
@media print {
|
||||||
|
|
||||||
.no-print,
|
.no-print,
|
||||||
.no-print * {
|
.no-print * {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
height: 0px;
|
height: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* END OF PRINTING */
|
/* END OF PRINTING */
|
||||||
|
|
||||||
/* FONTS */
|
/* FONTS */
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* END OF FONTS */
|
/* END OF FONTS */
|
||||||
|
|
||||||
/* VARIABLES */
|
/* VARIABLES */
|
||||||
@@ -75,6 +78,7 @@
|
|||||||
--font-heading: var(--font_heading);
|
--font-heading: var(--font_heading);
|
||||||
--default-font-family: var(--font_default);
|
--default-font-family: var(--font_default);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* END OF VARIABLES */
|
/* END OF VARIABLES */
|
||||||
/* ELEMENTS */
|
/* ELEMENTS */
|
||||||
body {
|
body {
|
||||||
@@ -118,9 +122,11 @@ h3,
|
|||||||
h4 {
|
h4 {
|
||||||
@apply text-lg;
|
@apply text-lg;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@apply text-2xl;
|
@apply text-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
@apply text-xl;
|
@apply text-xl;
|
||||||
}
|
}
|
||||||
@@ -130,12 +136,17 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@apply text-primary bg-link text-center font-heading text-xl tracking-wide;
|
@apply text-primary bg-link text-center font-heading tracking-wide;
|
||||||
}
|
}
|
||||||
|
|
||||||
input,
|
input,
|
||||||
textarea {
|
textarea {
|
||||||
@apply text-primary border;
|
@apply text-primary border p-2 w-full;
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
@apply text-secondary opacity-50;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@@ -219,18 +230,24 @@ td {
|
|||||||
/* PHONE */
|
/* PHONE */
|
||||||
@media (max-width: 850px) {
|
@media (max-width: 850px) {
|
||||||
.a4page-portrait {
|
.a4page-portrait {
|
||||||
width: 100%; /* fill mobile width */
|
width: 100%;
|
||||||
|
/* fill mobile width */
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
margin: 0 auto; /* center horizontally */
|
margin: 0 auto;
|
||||||
|
/* center horizontally */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.a4page-landscape {
|
.a4page-landscape {
|
||||||
width: 100%; /* fill mobile width */
|
width: 100%;
|
||||||
|
/* fill mobile width */
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
margin: 0 auto; /* center horizontally */
|
margin: 0 auto;
|
||||||
|
/* center horizontally */
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.a5page-portrait {
|
.a5page-portrait {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -238,6 +255,7 @@ td {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.a5page-landscape {
|
.a5page-landscape {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
@@ -249,12 +267,15 @@ td {
|
|||||||
.tl {
|
.tl {
|
||||||
@apply absolute top-0 left-0;
|
@apply absolute top-0 left-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tr {
|
.tr {
|
||||||
@apply absolute top-0 right-0;
|
@apply absolute top-0 right-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bl {
|
.bl {
|
||||||
@apply absolute bottom-0 left-0;
|
@apply absolute bottom-0 left-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.br {
|
.br {
|
||||||
@apply absolute bottom-0 right-0;
|
@apply absolute bottom-0 right-0;
|
||||||
}
|
}
|
||||||
@@ -270,17 +291,9 @@ td {
|
|||||||
--blur: 0%;
|
--blur: 0%;
|
||||||
|
|
||||||
background-color: var(--bg_secondary);
|
background-color: var(--bg_secondary);
|
||||||
background-image: radial-gradient(
|
background-image: radial-gradient(circle at center,
|
||||||
circle at center,
|
var(--bg_primary) var(--dot_size),
|
||||||
var(--bg_primary) var(--dot_size),
|
transparent var(--blur));
|
||||||
transparent var(--blur)
|
|
||||||
);
|
|
||||||
background-size: var(--bg_size) var(--bg_size);
|
background-size: var(--bg_size) var(--bg_size);
|
||||||
background-position: 0 0;
|
background-position: 0 0;
|
||||||
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
-180deg,
|
|
||||||
rgba(1, 1, 1, 1) 0%,
|
|
||||||
rgba(1, 1, 1, 0.92) 100%
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,31 @@ const container = useTemplateRef("container");
|
|||||||
const item1 = useTemplateRef("item1");
|
const item1 = useTemplateRef("item1");
|
||||||
|
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
let cachedWidth = 0;
|
||||||
|
|
||||||
let rafId;
|
let rafId;
|
||||||
|
|
||||||
const speed = 0.5; // pixels per frame
|
const speed = 0.5; // pixels per frame
|
||||||
|
|
||||||
function animate() {
|
function measureWidth() {
|
||||||
const ctnr = container.value;
|
const ctnr = container.value;
|
||||||
const it1 = item1.value;
|
const it1 = item1.value;
|
||||||
|
if (ctnr && it1) {
|
||||||
|
cachedWidth = Math.max(ctnr.offsetWidth, it1.scrollWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const width = Math.max(ctnr.offsetWidth, it1.scrollWidth);
|
function animate() {
|
||||||
|
const ctnr = container.value;
|
||||||
|
if (!ctnr || cachedWidth === 0) {
|
||||||
|
rafId = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
offset -= speed;
|
offset -= speed;
|
||||||
|
|
||||||
if (offset <= -width) {
|
if (offset <= -cachedWidth) {
|
||||||
offset += width;
|
offset += cachedWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctnr.style.transform = `translateX(${offset}px)`;
|
ctnr.style.transform = `translateX(${offset}px)`;
|
||||||
@@ -27,12 +37,19 @@ function animate() {
|
|||||||
rafId = requestAnimationFrame(animate);
|
rafId = requestAnimationFrame(animate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resizeObserver;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
measureWidth();
|
||||||
rafId = requestAnimationFrame(animate);
|
rafId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(measureWidth);
|
||||||
|
resizeObserver.observe(container.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ let pos = 0;
|
|||||||
let direction = 1; // 1 = down, -1 = up
|
let direction = 1; // 1 = down, -1 = up
|
||||||
let timeoutId;
|
let timeoutId;
|
||||||
let timeoutId2;
|
let timeoutId2;
|
||||||
|
let cachedScrollHeight = 0;
|
||||||
|
|
||||||
|
function measureScrollHeight() {
|
||||||
|
const el = container.value;
|
||||||
|
if (el) cachedScrollHeight = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
function handleHover() {
|
function handleHover() {
|
||||||
cancelAnimationFrame(timeoutId);
|
cancelAnimationFrame(timeoutId);
|
||||||
@@ -28,6 +34,10 @@ function handleHover() {
|
|||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
const el = container.value;
|
const el = container.value;
|
||||||
|
if (!el || cachedScrollHeight === 0) {
|
||||||
|
timeoutId = requestAnimationFrame(tick);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const reachedBottom = pos <= 0;
|
const reachedBottom = pos <= 0;
|
||||||
const reachedTop = pos >= 1;
|
const reachedTop = pos >= 1;
|
||||||
@@ -46,16 +56,23 @@ function tick() {
|
|||||||
|
|
||||||
pos += direction * SPEED;
|
pos += direction * SPEED;
|
||||||
|
|
||||||
el.scrollTop = pos * el.scrollHeight;
|
el.scrollTop = pos * cachedScrollHeight;
|
||||||
|
|
||||||
timeoutId = requestAnimationFrame(tick);
|
timeoutId = requestAnimationFrame(tick);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resizeObserver;
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
measureScrollHeight();
|
||||||
timeoutId = requestAnimationFrame(tick);
|
timeoutId = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(measureScrollHeight);
|
||||||
|
resizeObserver.observe(container.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
cancelAnimationFrame(timeoutId);
|
cancelAnimationFrame(timeoutId);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,9 +1,75 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from "vue";
|
||||||
import Button from "@/components/input/Button.vue";
|
import Button from "@/components/input/Button.vue";
|
||||||
import { useMessagesStore } from "@/stores/messages";
|
import { useMessagesStore } from "@/stores/messages";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import Header from "@/components/text/Header.vue";
|
||||||
|
|
||||||
const messagesStore = useMessagesStore();
|
const messagesStore = useMessagesStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
const messages = computed(() => messagesStore.messages);
|
const messages = computed(() => messagesStore.messages);
|
||||||
|
const messageInput = ref("");
|
||||||
|
const messagesContainer = ref(null);
|
||||||
|
const fileInput = ref(null);
|
||||||
|
|
||||||
|
function scrollToBottom() {
|
||||||
|
nextTick(() => {
|
||||||
|
if (messagesContainer.value) {
|
||||||
|
messagesContainer.value.scrollTop =
|
||||||
|
messagesContainer.value.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(messages, scrollToBottom, { deep: true });
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
const text = messageInput.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
messagesStore.sendMessage(text);
|
||||||
|
messageInput.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileSelected(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
await messagesStore.uploadAndSendFile(file);
|
||||||
|
fileInput.value.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageUrl(url) {
|
||||||
|
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVideoUrl(url) {
|
||||||
|
return /\.(mp4|webm|ogg|mov)$/i.test(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeFileUrl(url) {
|
||||||
|
return typeof url === "string" && url.startsWith("/uploads/");
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlRegex = /(https?:\/\/[^\s<]+)/g;
|
||||||
|
|
||||||
|
function parseMessageParts(text) {
|
||||||
|
const parts = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
while ((match = urlRegex.exec(text)) !== null) {
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push({
|
||||||
|
type: "text",
|
||||||
|
value: text.slice(lastIndex, match.index),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parts.push({ type: "link", value: match[1] });
|
||||||
|
lastIndex = urlRegex.lastIndex;
|
||||||
|
}
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
parts.push({ type: "text", value: text.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
messagesStore.connect();
|
messagesStore.connect();
|
||||||
@@ -14,15 +80,69 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="flex-col flex">
|
||||||
<div class="flex flex-col">
|
<Header>Chat</Header>
|
||||||
|
<div ref="messagesContainer" class="flex flex-col overflow-y-auto p-2">
|
||||||
<p v-for="message in messages" :key="message.id">
|
<p v-for="message in messages" :key="message.id">
|
||||||
{{ message.content }}
|
<span class="text-tertiary">{{ message.authorId }}:</span>
|
||||||
|
<template
|
||||||
|
v-for="(part, i) in parseMessageParts(message.text || '')"
|
||||||
|
:key="i"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-if="part.type === 'link'"
|
||||||
|
:href="part.value"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-primary underline"
|
||||||
|
>{{ part.value }}</a
|
||||||
|
>
|
||||||
|
<span v-else>{{ part.value }}</span>
|
||||||
|
</template>
|
||||||
|
<template
|
||||||
|
v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="isImageUrl(message.fileUrl)"
|
||||||
|
:src="message.fileUrl"
|
||||||
|
class="max-w-xs max-h-48 rounded"
|
||||||
|
@load="scrollToBottom"
|
||||||
|
/>
|
||||||
|
<video
|
||||||
|
v-else-if="isVideoUrl(message.fileUrl)"
|
||||||
|
:src="message.fileUrl"
|
||||||
|
controls
|
||||||
|
class="max-w-xs max-h-48 rounded"
|
||||||
|
@loadedmetadata="scrollToBottom"
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
:href="message.fileUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="underline"
|
||||||
|
>{{ message.fileUrl.split("/").pop() }}</a
|
||||||
|
>
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row">
|
<div>
|
||||||
<input v-model="messageInput" @keyup.enter="sendMessage" />
|
<input v-model="messageInput" @keyup.enter="sendMessage" />
|
||||||
<Button @click="sendMessage">Send</Button>
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
class="hidden"
|
||||||
|
@change="onFileSelected"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button class="flex-1" @click="sendMessage">Send</Button>
|
||||||
|
<Button
|
||||||
|
v-if="authStore.user.admin"
|
||||||
|
class="flex-1"
|
||||||
|
@click="fileInput.click()"
|
||||||
|
>Attach</Button
|
||||||
|
>
|
||||||
|
<Button class="flex-1" @click="scrollToBottom">Bottom</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
|
import Header from "@/components/text/Header.vue";
|
||||||
|
|
||||||
const url =
|
const url = "/gitea/api/v1/users/adamf/activities/feeds?limit=1";
|
||||||
"https://www.adam-french.co.uk/gitea/api/v1/users/adamf/activities/feeds?limit=1";
|
|
||||||
|
|
||||||
const feed = ref(null);
|
const feed = ref(null);
|
||||||
const isLoading = ref(true);
|
const isLoading = ref(true);
|
||||||
@@ -27,16 +27,21 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="justify-center text-center">
|
<div class="flex flex-col text-center h-full">
|
||||||
<div v-if="isLoading">
|
<Header class="text-left">Commits</Header>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="flex-1 overflow-y-auto">
|
||||||
<p>Loading latest activity...</p>
|
<p>Loading latest activity...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="hasError">
|
<div v-else-if="hasError" class="flex-1 overflow-y-auto">
|
||||||
<p>Could not fetch feed. Please try again later.</p>
|
<p>Could not fetch feed. Please try again later.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="feed" class="flex-col justify-center flex">
|
<div
|
||||||
|
v-else-if="feed"
|
||||||
|
class="flex-1 flex flex-col justify-center overflow-y-auto"
|
||||||
|
>
|
||||||
<h3>Last git activity</h3>
|
<h3>Last git activity</h3>
|
||||||
<img
|
<img
|
||||||
:src="feed.act_user.avatar_url"
|
:src="feed.act_user.avatar_url"
|
||||||
@@ -51,7 +56,7 @@ onMounted(() => {
|
|||||||
<small> {{ new Date(feed.created).toLocaleString() }}</small>
|
<small> {{ new Date(feed.created).toLocaleString() }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else class="flex-1 overflow-y-auto">
|
||||||
<p>No activity found.</p>
|
<p>No activity found.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="streamLive">
|
<div v-if="streamLive" class="overflow-hidden">
|
||||||
|
<Header>Radio</Header>
|
||||||
<img src="/img/tmpen31z3pe.PNG" />
|
<img src="/img/tmpen31z3pe.PNG" />
|
||||||
<audio controls :src="streamUrl" ref="audio"></audio>
|
<audio controls :src="streamUrl" ref="audio"></audio>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
|
<Header>Radio</Header>
|
||||||
<img src="/img/tmpen31z3pe.PNG" />
|
<img src="/img/tmpen31z3pe.PNG" />
|
||||||
<div class="m-1">
|
<div class="m-1 text-center">
|
||||||
<p>Radio is offline. Message for info!</p>
|
<p>Radio is offline. Message for info!</p>
|
||||||
<Button class="w-full" @click="checkStream()">Check Stream</Button>
|
<Button class="w-full" @click="checkStream()">Check Stream</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -14,25 +16,25 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import Button from "@/components/input/Button.vue";
|
import Button from "@/components/input/Button.vue";
|
||||||
import { ref, onMounted } from "vue";
|
import Header from "@/components/text/Header.vue";
|
||||||
|
import { ref, useTemplateRef, onMounted, nextTick } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const streamMount = ref("");
|
|
||||||
const streamUrl = ref("");
|
const streamUrl = ref("");
|
||||||
const streamLive = ref(false);
|
const streamLive = ref(false);
|
||||||
const audio = ref(null);
|
const audio = useTemplateRef("audio");
|
||||||
|
|
||||||
async function checkStream() {
|
async function checkStream() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get("/radio/status-json.xsl");
|
await axios.head("/radio/stream");
|
||||||
const data = res.data;
|
if (!streamLive.value) {
|
||||||
|
|
||||||
streamMount.value = data.icestats.source.listenurl.split("/").pop();
|
|
||||||
if (streamMount.value) {
|
|
||||||
streamLive.value = true;
|
streamLive.value = true;
|
||||||
streamUrl.value = "/radio/" + streamMount.value;
|
streamUrl.value = "/radio/stream";
|
||||||
|
await nextTick();
|
||||||
if (audio.value) audio.value.load();
|
if (audio.value) {
|
||||||
|
audio.value.load();
|
||||||
|
audio.value.volume = 0.2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
streamLive.value = false;
|
streamLive.value = false;
|
||||||
|
|||||||
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="items-center flex flex-col">
|
<div class="flex flex-col">
|
||||||
|
<Header>{{ weekday }} {{ day }}, {{ month }}</Header>
|
||||||
<h1>{{ time }}</h1>
|
<h1>{{ time }}</h1>
|
||||||
<h1>{{ weekday }} {{ day }}, {{ month }}</h1>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Button from "@/components/input/Button.vue";
|
import Button from "@/components/input/Button.vue";
|
||||||
|
import Header from "@/components/text/Header.vue";
|
||||||
|
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
|
|
||||||
const timer = ref(null);
|
const timer = ref(null);
|
||||||
@@ -64,7 +66,7 @@ function playFinishedSound() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-1 p-1 items-center">
|
<div class="flex flex-col gap-1 p-1 items-center">
|
||||||
<h2 class="items-center">Timer</h2>
|
<Header>Timer</Header>
|
||||||
<div v-if="finished && paused" class="flex flex-col">
|
<div v-if="finished && paused" class="flex flex-col">
|
||||||
<div class="flex flex-row p-2 place-content-around">
|
<div class="flex flex-row p-2 place-content-around">
|
||||||
<input
|
<input
|
||||||
|
|||||||
7
nginx/vue/src/graphql.js
Normal file
7
nginx/vue/src/graphql.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
export async function gql(query, variables = {}) {
|
||||||
|
const res = await axios.post("/api/graphql", { query, variables });
|
||||||
|
if (res.data.errors && !res.data.data) throw new Error(res.data.errors[0].message);
|
||||||
|
return res.data.data;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import axios from "axios";
|
import { useHomeDataStore } from "@/stores/homeData";
|
||||||
|
|
||||||
const activity_template = {
|
const activity_template = {
|
||||||
type: "activity",
|
type: "activity",
|
||||||
@@ -13,19 +13,21 @@ export const useActivityStore = defineStore("activity", () => {
|
|||||||
|
|
||||||
const activityCount = computed(() => activity.value.length);
|
const activityCount = computed(() => activity.value.length);
|
||||||
|
|
||||||
async function fetchActivity() {
|
const homeData = useHomeDataStore();
|
||||||
try {
|
watch(
|
||||||
const res = await axios.get("/api/activity");
|
() => homeData.activities,
|
||||||
if (!Array.isArray(res.data)) {
|
(newActivities) => {
|
||||||
throw new Error("Invalid response from posts API");
|
if (newActivities.length > 0) {
|
||||||
|
activity.value = newActivities;
|
||||||
}
|
}
|
||||||
activity.value = res.data;
|
},
|
||||||
} catch (err) {
|
{ immediate: true },
|
||||||
console.error("Cannot connect to activity API", err);
|
);
|
||||||
}
|
|
||||||
|
async function fetchActivity() {
|
||||||
|
await homeData.fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchActivity();
|
|
||||||
return {
|
return {
|
||||||
activity,
|
activity,
|
||||||
activityCount,
|
activityCount,
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import axios from "axios";
|
import { gql } from "@/graphql";
|
||||||
|
import { useHomeDataStore } from "@/stores/homeData";
|
||||||
|
|
||||||
export const useAuthStore = defineStore("auth", () => {
|
export const useAuthStore = defineStore("auth", () => {
|
||||||
const user = ref({});
|
const user = ref({});
|
||||||
const loggedIn = computed(() => !!user.value.username);
|
const loggedIn = computed(() => !!user.value.username);
|
||||||
|
|
||||||
checkToken();
|
const homeData = useHomeDataStore();
|
||||||
|
watch(
|
||||||
|
() => homeData.me,
|
||||||
|
(me) => {
|
||||||
|
if (me) {
|
||||||
|
user.value = me;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
async function logOut() {
|
async function logOut() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.post("/api/auth/logout");
|
await gql(`mutation { logout }`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
@@ -19,11 +29,11 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
|
|
||||||
async function logIn(username, password) {
|
async function logIn(username, password) {
|
||||||
try {
|
try {
|
||||||
const res = await axios.post("/api/auth/login", {
|
const data = await gql(
|
||||||
username,
|
`mutation Login($input: LoginInput!) { login(input: $input) { user { id username admin } } }`,
|
||||||
password,
|
{ input: { username, password } },
|
||||||
});
|
);
|
||||||
user.value = res.data;
|
user.value = data.login.user;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
@@ -31,31 +41,38 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
|
|
||||||
async function createUser(username, password) {
|
async function createUser(username, password) {
|
||||||
try {
|
try {
|
||||||
const res = await axios.post("/api/user", {
|
const data = await gql(
|
||||||
username,
|
`mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id username admin } }`,
|
||||||
password,
|
{ input: { username, password } },
|
||||||
});
|
);
|
||||||
user.value = res.data;
|
return data.createUser;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshToken() {
|
async function refreshToken() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.post("/api/auth/refresh");
|
const data = await gql(
|
||||||
user.value = res.data;
|
`mutation { refreshToken { user { id username admin } } }`,
|
||||||
|
);
|
||||||
|
user.value = data.refreshToken.user;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkToken() {
|
async function setUserAdmin(userId, admin) {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get("/api/auth/check");
|
const data = await gql(
|
||||||
user.value = res.data;
|
`mutation SetUserAdmin($id: ID!, $admin: Boolean!) { setUserAdmin(id: $id, admin: $admin) { id username admin } }`,
|
||||||
|
{ id: userId, admin },
|
||||||
|
);
|
||||||
|
return data.setUserAdmin;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
user.value = {};
|
console.error(err);
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +82,9 @@ export const useAuthStore = defineStore("auth", () => {
|
|||||||
loggedIn,
|
loggedIn,
|
||||||
|
|
||||||
logIn,
|
logIn,
|
||||||
checkToken,
|
|
||||||
refreshToken,
|
refreshToken,
|
||||||
logOut,
|
logOut,
|
||||||
createUser,
|
createUser,
|
||||||
|
setUserAdmin,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import axios from "axios";
|
import { useHomeDataStore } from "@/stores/homeData";
|
||||||
|
|
||||||
const favorite_template = {
|
const favorite_template = {
|
||||||
type: "favorite",
|
type: "favorite",
|
||||||
@@ -13,19 +13,20 @@ export const useFavoritesStore = defineStore("favorites", () => {
|
|||||||
|
|
||||||
const favoritesCount = computed(() => favorites.value.length);
|
const favoritesCount = computed(() => favorites.value.length);
|
||||||
|
|
||||||
async function fetchFavorites() {
|
const homeData = useHomeDataStore();
|
||||||
try {
|
watch(
|
||||||
const res = await axios.get("/api/favorites");
|
() => homeData.favorites,
|
||||||
if (!Array.isArray(res.data)) {
|
(newFavorites) => {
|
||||||
throw new Error("Invalid response from favorites API");
|
if (newFavorites.length > 0) {
|
||||||
|
favorites.value = newFavorites;
|
||||||
}
|
}
|
||||||
favorites.value = res.data;
|
},
|
||||||
} catch (err) {
|
{ immediate: true },
|
||||||
console.error("Cannot connect to favorites API", err);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchFavorites();
|
async function fetchFavorites() {
|
||||||
|
await homeData.fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
favorites,
|
favorites,
|
||||||
|
|||||||
50
nginx/vue/src/stores/homeData.js
Normal file
50
nginx/vue/src/stores/homeData.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { gql } from "@/graphql";
|
||||||
|
|
||||||
|
export const useHomeDataStore = defineStore("homeData", () => {
|
||||||
|
const loaded = ref(false);
|
||||||
|
const error = ref(null);
|
||||||
|
|
||||||
|
const me = ref(null);
|
||||||
|
const posts = ref([]);
|
||||||
|
const favorites = ref([]);
|
||||||
|
const activities = ref([]);
|
||||||
|
const spotifyRecent = ref([]);
|
||||||
|
|
||||||
|
async function fetchAll() {
|
||||||
|
try {
|
||||||
|
const data = await gql(`
|
||||||
|
query HomeData {
|
||||||
|
posts { id title content createdAt updatedAt author { id username } }
|
||||||
|
favorites { id type name link createdAt }
|
||||||
|
activities { id type name link createdAt }
|
||||||
|
spotifyRecent { track { name album { name images { url } } artists { name } } playedAt }
|
||||||
|
me { id username admin }
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
posts.value = data.posts;
|
||||||
|
favorites.value = data.favorites;
|
||||||
|
activities.value = data.activities;
|
||||||
|
spotifyRecent.value = data.spotifyRecent;
|
||||||
|
me.value = data.me || null;
|
||||||
|
loaded.value = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("HomeData fetch failed:", err);
|
||||||
|
error.value = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAll();
|
||||||
|
|
||||||
|
return {
|
||||||
|
loaded,
|
||||||
|
error,
|
||||||
|
me,
|
||||||
|
posts,
|
||||||
|
favorites,
|
||||||
|
activities,
|
||||||
|
spotifyRecent,
|
||||||
|
fetchAll,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,29 +1,34 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
const URL = "/api/ws";
|
function getWebSocketURL() {
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
const message_template = {
|
return `${protocol}//${window.location.host}/api/ws`;
|
||||||
id: 0,
|
}
|
||||||
content: "Yo",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMessagesStore = defineStore("messages", () => {
|
export const useMessagesStore = defineStore("messages", () => {
|
||||||
const socket = ref(null);
|
const socket = ref(null);
|
||||||
const messages = ref([message_template]);
|
const messages = ref([]);
|
||||||
const isConnected = ref(false);
|
const isConnected = ref(false);
|
||||||
const lastError = ref(null);
|
const lastError = ref(null);
|
||||||
|
let intentionalClose = false;
|
||||||
|
let reconnectDelay = 1000;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
|
||||||
const messagesCount = computed(() => messages.value.length);
|
const messagesCount = computed(() => messages.value.length);
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
if (socket.value && isConnected.value) return;
|
if (socket.value && isConnected.value) return;
|
||||||
|
intentionalClose = false;
|
||||||
|
|
||||||
socket.value = new WebSocket(URL);
|
socket.value = new WebSocket(getWebSocketURL());
|
||||||
|
|
||||||
socket.value.onopen = () => {
|
socket.value.onopen = () => {
|
||||||
isConnected.value = true;
|
isConnected.value = true;
|
||||||
lastError.value = null;
|
lastError.value = null;
|
||||||
|
reconnectDelay = 1000;
|
||||||
|
messages.value = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
socket.value.onmessage = (event) => {
|
socket.value.onmessage = (event) => {
|
||||||
@@ -31,8 +36,7 @@ export const useMessagesStore = defineStore("messages", () => {
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
messages.value.push(data);
|
messages.value.push(data);
|
||||||
} catch {
|
} catch {
|
||||||
// fallback if server sends plain text
|
messages.value.push({ text: event.data });
|
||||||
messages.value.push(event.data);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -43,25 +47,46 @@ export const useMessagesStore = defineStore("messages", () => {
|
|||||||
socket.value.onclose = () => {
|
socket.value.onclose = () => {
|
||||||
isConnected.value = false;
|
isConnected.value = false;
|
||||||
socket.value = null;
|
socket.value = null;
|
||||||
|
if (!intentionalClose) {
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, reconnectDelay);
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, 30000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
|
intentionalClose = true;
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
if (!socket.value) return;
|
if (!socket.value) return;
|
||||||
socket.value.close();
|
socket.value.close();
|
||||||
socket.value = null;
|
socket.value = null;
|
||||||
isConnected.value = false;
|
isConnected.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendMessage(payload) {
|
function sendMessage(text) {
|
||||||
if (!socket.value || !isConnected.value) return;
|
if (!socket.value || !isConnected.value) return;
|
||||||
socket.value.send(JSON.stringify(payload));
|
socket.value.send(JSON.stringify({ text }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearMessages() {
|
function clearMessages() {
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function uploadAndSendFile(file) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
const res = await axios.post("/api/messages/upload", formData);
|
||||||
|
const { url } = res.data;
|
||||||
|
if (!socket.value || !isConnected.value) return;
|
||||||
|
socket.value.send(JSON.stringify({ text: "", fileUrl: url }));
|
||||||
|
} catch (err) {
|
||||||
|
lastError.value = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
isConnected,
|
isConnected,
|
||||||
@@ -73,5 +98,6 @@ export const useMessagesStore = defineStore("messages", () => {
|
|||||||
disconnect,
|
disconnect,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
clearMessages,
|
clearMessages,
|
||||||
|
uploadAndSendFile,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { computed, ref } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import axios from "axios";
|
import { gql } from "@/graphql";
|
||||||
|
import { useHomeDataStore } from "@/stores/homeData";
|
||||||
|
|
||||||
const post_template = {
|
const post_template = {
|
||||||
title: "Can't fetch from the db yo",
|
title: "Can't fetch from the db yo",
|
||||||
@@ -17,32 +18,34 @@ export const usePostsStore = defineStore("posts", () => {
|
|||||||
|
|
||||||
const postsCount = computed(() => posts.value.length);
|
const postsCount = computed(() => posts.value.length);
|
||||||
|
|
||||||
async function fetchPosts() {
|
const homeData = useHomeDataStore();
|
||||||
try {
|
watch(
|
||||||
const res = await axios.get("/api/posts");
|
() => homeData.posts,
|
||||||
if (!Array.isArray(res.data)) {
|
(newPosts) => {
|
||||||
throw new Error("Invalid response from posts API");
|
if (newPosts.length > 0) {
|
||||||
|
posts.value = newPosts;
|
||||||
}
|
}
|
||||||
posts.value = res.data;
|
},
|
||||||
} catch (err) {
|
{ immediate: true },
|
||||||
console.error("Cannot connect to Post API", err);
|
);
|
||||||
}
|
|
||||||
|
async function fetchPosts() {
|
||||||
|
await homeData.fetchAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePost(post) {
|
async function deletePost(post) {
|
||||||
try {
|
try {
|
||||||
const res = await axios.delete(
|
await gql(
|
||||||
`/api/posts/${encodeURIComponent(post.id)}`,
|
`mutation DeletePost($id: ID!) { deletePost(id: $id) { id } }`,
|
||||||
|
{ id: post.id },
|
||||||
);
|
);
|
||||||
console.log("Deleted:", res.data);
|
console.log("Deleted:", post.id);
|
||||||
fetchPosts();
|
await homeData.fetchAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Delete failed:", err);
|
console.error("Delete failed:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchPosts();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
posts,
|
posts,
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed, watch } from "vue";
|
||||||
import axios from "axios";
|
import { gql } from "@/graphql";
|
||||||
|
import { useHomeDataStore } from "@/stores/homeData";
|
||||||
|
|
||||||
const song_template = {
|
const song_template = {
|
||||||
id: 1,
|
|
||||||
track: {
|
track: {
|
||||||
id: 1,
|
|
||||||
name: "^_^",
|
name: "^_^",
|
||||||
album: { images: [{ url: "/img/Untitled.png" }] },
|
album: { name: "", images: [{ url: "/img/Untitled.png" }] },
|
||||||
artists: [{ name: ">_<" }],
|
artists: [{ name: ">_<" }],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -17,13 +16,34 @@ export const useSongsStore = defineStore("songs", () => {
|
|||||||
|
|
||||||
const songsCount = computed(() => songs.value.length);
|
const songsCount = computed(() => songs.value.length);
|
||||||
|
|
||||||
|
const homeData = useHomeDataStore();
|
||||||
|
watch(
|
||||||
|
() => homeData.spotifyRecent,
|
||||||
|
(newSongs) => {
|
||||||
|
if (newSongs.length > 0) {
|
||||||
|
songs.value = newSongs;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
async function fetchSongs() {
|
async function fetchSongs() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.get("/api/spotify/recent");
|
const data = await gql(`
|
||||||
if (!Array.isArray(res.data)) {
|
query {
|
||||||
throw new Error("Invalid response from Spotify API");
|
spotifyRecent {
|
||||||
|
track {
|
||||||
|
name
|
||||||
|
album { name images { url } }
|
||||||
|
artists { name }
|
||||||
|
}
|
||||||
|
playedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
if (Array.isArray(data.spotifyRecent) && data.spotifyRecent.length > 0) {
|
||||||
|
songs.value = data.spotifyRecent;
|
||||||
}
|
}
|
||||||
songs.value = res.data;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Cannot connect to Spotify API", err);
|
console.error("Cannot connect to Spotify API", err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,187 +4,205 @@ import Project from "./Project.vue";
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
|
<div class="no-print w-full h-20">
|
||||||
|
|
||||||
|
</div>
|
||||||
<div class="a4page">
|
<div class="a4page">
|
||||||
<div class="flex flex-row justify-between">
|
<div class="flex flex-row justify-between">
|
||||||
<h1 class="name">Adam French</h1>
|
<h1 class="name">Adam French</h1>
|
||||||
<!-- <a href="covers.html"><img width=25 height=50 src="img/rune.png"></a> -->
|
|
||||||
<div class="contact-details text-right">
|
<div class="contact-details text-right">
|
||||||
<p>+447563266931</p>
|
<p>+447563266931</p>
|
||||||
<p>adam.a.french@outlook.com</p>
|
<p>adam.a.french@outlook.com</p>
|
||||||
|
<h4>
|
||||||
<a href="https://www.adam-french.co.uk">
|
<a href="https://www.adam-french.co.uk">
|
||||||
www.adam-french.co.uk
|
www.adam-french.co.uk
|
||||||
</a>
|
</a>
|
||||||
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Profile</h2>
|
<h2>Profile</h2>
|
||||||
<p>
|
<p>
|
||||||
Recent Computer Science with Mathematics (International)
|
First Class Honours graduate in Computer Science with Mathematics
|
||||||
graduate from the University of Leeds, awarded First Class
|
from the University of Leeds (81.1%), with a year abroad at the
|
||||||
Honours (81.1%). Strong foundation in full-stack software
|
University of Waterloo. Proficient in full-stack development,
|
||||||
development, CI/CD workflows, and modern programming languages.
|
systems programming, and CI/CD automation. Eager to contribute to
|
||||||
Experienced in creating scalable, maintainable systems and
|
a collaborative engineering team, apply strong academic
|
||||||
motivated by solving complex technical problems. Enthusiastic
|
foundations to real-world problems, and grow through hands-on
|
||||||
about working within organisations that promote innovation,
|
experience.
|
||||||
collaboration, and positive social impact.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h2>Skills</h2>
|
||||||
|
<div class="skills-grid">
|
||||||
|
<div><strong>Languages</strong><br /><small>Go, Rust, Python, JavaScript / TypeScript, SQL</small></div>
|
||||||
|
<div><strong>Frontend</strong><br /><small>Vue, React / Redux, Svelte, Tailwind CSS, WebAssembly</small></div>
|
||||||
|
<div><strong>Backend / Infra</strong><br /><small>Nginx, Docker, PostgreSQL, SQLite, JWT Auth, Git Actions</small></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>Projects</h2>
|
<h2>Projects</h2>
|
||||||
|
|
||||||
<Project class="border-b border-dotted">
|
<Project class="border-b border-dotted">
|
||||||
<template v-slot:left>
|
<template v-slot:left>
|
||||||
|
<h4>
|
||||||
<a
|
<a
|
||||||
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
|
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
|
||||||
>
|
>
|
||||||
web_server.git
|
web_server.git
|
||||||
</a>
|
</a>
|
||||||
|
</h4>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:top>
|
<template v-slot:top>
|
||||||
<small>
|
<small>
|
||||||
Nginx, Vue, Postgres, Docker, Go, Python, Rust -> Wasm,
|
Nginx, Vue, Postgres, Docker, Go, Python, Rust → Wasm,
|
||||||
Git Actions, JWT Auth
|
Git Actions, JWT Auth
|
||||||
</small>
|
</small>
|
||||||
<small>2025</small>
|
<small>2025</small>
|
||||||
</template>
|
</template>
|
||||||
<p>
|
<p>
|
||||||
Developed and self-hosted a personal website with a fully
|
Self-hosted personal website with a fully automated CI/CD
|
||||||
automated maintenance CI/CD pipeline. Experimented with
|
pipeline. Iterated across diverse tech stacks including
|
||||||
diverse tech stacks including Svelte, React/Redux, SQLite,
|
Svelte, React/Redux, SQLite, Rust Actix, and Deno.
|
||||||
Rust Actix, and Deno.
|
|
||||||
</p>
|
</p>
|
||||||
</Project>
|
</Project>
|
||||||
<Project class="border-b border-dotted">
|
<Project class="border-b border-dotted">
|
||||||
<template v-slot:left>
|
<template v-slot:left>
|
||||||
|
<h4>
|
||||||
<a
|
<a
|
||||||
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
|
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
|
||||||
>
|
>
|
||||||
tour.git
|
tour.git
|
||||||
</a>
|
</a>
|
||||||
|
</h4>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:top>
|
<template v-slot:top>
|
||||||
<small>Rust</small>
|
<small>Rust</small>
|
||||||
<small>2026</small>
|
<small>2026</small>
|
||||||
</template>
|
</template>
|
||||||
<p>
|
<p>
|
||||||
Created a command-line tool for building and viewing
|
CLI tool for building and navigating interactive code
|
||||||
interactive code tutorials. Designed functionality analogous
|
tutorials, with version-traversal semantics inspired by Git.
|
||||||
to Git for intuitive version traversal and educational use.
|
|
||||||
</p>
|
</p>
|
||||||
</Project>
|
</Project>
|
||||||
<Project class="border-b border-dotted">
|
<Project class="border-b border-dotted">
|
||||||
<template v-slot:left>
|
<template v-slot:left>
|
||||||
|
<h4>
|
||||||
<a
|
<a
|
||||||
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
|
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
|
||||||
>
|
>
|
||||||
rust-raytracer.git
|
rust-raytracer.git
|
||||||
</a>
|
</a>
|
||||||
|
</h4>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:top>
|
<template v-slot:top>
|
||||||
<small>Rust, Linear Algebra, Multithreading</small>
|
<small>Rust, Linear Algebra, Multithreading</small>
|
||||||
<small>2023</small>
|
<small>2023</small>
|
||||||
</template>
|
</template>
|
||||||
<p>
|
<p>
|
||||||
Built a parallelised, recursive ray tracer for realistic 3D
|
Parallelised recursive ray tracer for realistic 3D rendering.
|
||||||
rendering as part of a university module. Focused on
|
Emphasised algorithmic efficiency and low-level memory
|
||||||
algorithmic efficiency and low-level memory management in
|
management in Rust.
|
||||||
Rust.
|
|
||||||
</p>
|
</p>
|
||||||
</Project>
|
</Project>
|
||||||
<Project>
|
<Project>
|
||||||
<template #left>
|
<template #left>
|
||||||
<p>
|
<h4>
|
||||||
<a
|
<a
|
||||||
class="text-center w-full"
|
class="text-center w-full"
|
||||||
href="https://community.wolfram.com/groups/-/m/t/3210947"
|
href="https://community.wolfram.com/groups/-/m/t/3210947"
|
||||||
>
|
>
|
||||||
Wolfram Summer School
|
Wolfram Summer School
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</h4>
|
||||||
</template>
|
</template>
|
||||||
<template #top>
|
<template #top>
|
||||||
<small>Wolfram Mathematica</small>
|
<small>Wolfram Mathematica</small>
|
||||||
<small>2024</small>
|
<small>2024</small>
|
||||||
</template>
|
</template>
|
||||||
<p>
|
<p>
|
||||||
Designed and implemented a research project on Mobile
|
Research project on Mobile Automata with data visualisation
|
||||||
Automata, including data visualisation and presentation of
|
and academic presentation. Delivered within a tight deadline
|
||||||
findings. Completed the project within a short deadline and
|
in collaboration with academic mentors.
|
||||||
collaborated with academic mentors to refine outcomes.
|
|
||||||
</p>
|
</p>
|
||||||
</Project>
|
</Project>
|
||||||
<h2>University & Modules</h2>
|
|
||||||
|
<h2>Education</h2>
|
||||||
<div class="w-full h-fit flex-row flex gap-5">
|
<div class="w-full h-fit flex-row flex gap-5">
|
||||||
<div class="flex-1 border-r border-dotted pr-3">
|
<div class="flex-1 border-r border-dotted pr-3">
|
||||||
<h3>University of Leeds</h3>
|
<h3>
|
||||||
|
<a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
|
||||||
|
University of Leeds
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
<div
|
<div
|
||||||
class="flex-row flex place-content-between m-auto place-items-center"
|
class="flex-row flex place-content-between m-auto place-items-center"
|
||||||
>
|
>
|
||||||
<small> 81.1% (First Class Honours)</small>
|
<small>81.1% — First Class Honours</small>
|
||||||
<small> 2021-2025 </small>
|
<small>2021–2025</small>
|
||||||
</div>
|
</div>
|
||||||
<small>BSc Computer Science with Mathematics </small>
|
<small>BSc Computer Science with Mathematics (International)</small>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Procedural & Object Oriented Programming,</li>
|
<li>Algorithms & Data Structures I & II</li>
|
||||||
<li></li>
|
|
||||||
<li>Algorithms and Data Structures I & II</li>
|
|
||||||
|
|
||||||
<li>Databases</li>
|
|
||||||
<li>Computer Processors</li>
|
|
||||||
<li>Compiler Design and Construction</li>
|
<li>Compiler Design and Construction</li>
|
||||||
|
<li>Formal Languages & Finite Automata</li>
|
||||||
<li>Formal Languages and Finite Automata</li>
|
|
||||||
<li>Probability and Statistics I</li>
|
|
||||||
<li>Machine Learning</li>
|
|
||||||
<li>Graph Algorithms & Complexity Theory</li>
|
<li>Graph Algorithms & Complexity Theory</li>
|
||||||
|
<li>Machine Learning · Databases · Computer Processors</li>
|
||||||
|
<li>Probability and Statistics I</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 pl-3">
|
<div class="flex-1 pl-3">
|
||||||
<h3>The University of Waterloo</h3>
|
<h3>University of Waterloo</h3>
|
||||||
<div
|
<div
|
||||||
class="flex-row flex place-content-between m-auto place-items-center"
|
class="flex-row flex place-content-between m-auto place-items-center"
|
||||||
>
|
>
|
||||||
<small>---</small>
|
<small>Year abroad</small>
|
||||||
<small> 2023-2024 </small>
|
<small>2023–2024</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-row flex place-content-between"></div>
|
|
||||||
<ul>
|
<ul>
|
||||||
<li>Applied Cryptography</li>
|
<li>Applied Cryptography</li>
|
||||||
<li>Introduction to Computer Graphics</li>
|
<li>Introduction to Computer Graphics</li>
|
||||||
<li>
|
<li>Introduction to Rings and Fields with Applications</li>
|
||||||
Introduction to Rings and Fields with Applications
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="a4page"> -->
|
|
||||||
<!-- <h2>Experience</h2> -->
|
<div class="no-print w-full h-20">
|
||||||
<!-- <Project> -->
|
|
||||||
<!-- <template #left> -->
|
</div>
|
||||||
<!-- <p>Hospitality</p> -->
|
|
||||||
<!-- </template> -->
|
<div class="a4page">
|
||||||
<!-- <template #top> -->
|
<div class="flex-1 pl-3">
|
||||||
<!-- <small>Cashier, Bartender, Waiter</small> -->
|
<h2>Experience</h2>
|
||||||
<!-- <small>2018-2023</small> -->
|
<Project>
|
||||||
<!-- </template> -->
|
<template #left>
|
||||||
<!-- <p> -->
|
<p>Hospitality</p>
|
||||||
<!-- Worked at venues including: -->
|
</template>
|
||||||
<!-- <em>Belgrave Music Hall</em>, -->
|
<template #top>
|
||||||
<!-- <em>The Crown and Anchor Eastbourne</em>, -->
|
<small>Cashier, Bartender, Waiter</small>
|
||||||
<!-- <em>To The Rise Bakery</em>, -->
|
<small>2018–2023</small>
|
||||||
<!-- <em>BFI Riverfront Kitchen</em>. -->
|
</template>
|
||||||
<!-- </p> -->
|
<p>
|
||||||
<!-- </Project> -->
|
Worked at <em>Belgrave Music Hall</em>,
|
||||||
<!-- <h2>Commitments</h2> -->
|
<em>The Crown and Anchor</em>, and
|
||||||
<!-- <ul> -->
|
<em>BFI Riverfront Kitchen</em>. Developed
|
||||||
<!-- <li>Gym</li> -->
|
communication, composure under pressure, and
|
||||||
<!-- <li>Climbing</li> -->
|
reliability in customer-facing roles.
|
||||||
<!-- <li>Meetup.com</li> -->
|
</p>
|
||||||
<!-- <li>Boardgames</li> -->
|
</Project>
|
||||||
<!-- <li>Leetcode</li> -->
|
<h2>Interests</h2>
|
||||||
<!-- <li>Learning Mandarin</li> -->
|
<ul>
|
||||||
<!-- </ul> -->
|
<li>Leetcode — daily competitive problem solving</li>
|
||||||
<!-- </div> -->
|
<li>Learning Mandarin</li>
|
||||||
|
<li>Rhythm Games</li>
|
||||||
|
<li>Climbing · Gym</li>
|
||||||
|
<li>Board games · Meetup.com</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="no-print w-full h-20">
|
||||||
|
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -206,7 +224,6 @@ import Project from "./Project.vue";
|
|||||||
|
|
||||||
/* Variables */
|
/* Variables */
|
||||||
* {
|
* {
|
||||||
/* Black - White */
|
|
||||||
--primary: black;
|
--primary: black;
|
||||||
--secondary: #0000ff;
|
--secondary: #0000ff;
|
||||||
--tertiary: #ff0000;
|
--tertiary: #ff0000;
|
||||||
@@ -228,18 +245,14 @@ import Project from "./Project.vue";
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-family: var(--font-text);
|
font-family: var(--font-text);
|
||||||
width: 210mm;
|
width: 210mm;
|
||||||
/* Standard A4 width */
|
|
||||||
height: 297mm;
|
height: 297mm;
|
||||||
/* Standard A4 height */
|
|
||||||
padding: 5mm;
|
padding: 5mm;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: var(--background);
|
background-color: var(--background);
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||||
border: 1px solid var(--primary);
|
border: 1px solid var(--primary);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
/* Enables scrolling when content exceeds height */
|
|
||||||
margin: auto auto;
|
margin: auto auto;
|
||||||
/* Centers the page horizontally */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Component Styling */
|
/* Component Styling */
|
||||||
@@ -298,7 +311,6 @@ table {
|
|||||||
}
|
}
|
||||||
|
|
||||||
td {
|
td {
|
||||||
/* border: 2px solid var(--tertiary); */
|
|
||||||
color: var(--secondary);
|
color: var(--secondary);
|
||||||
border-top: 1px solid var(--tertiary);
|
border-top: 1px solid var(--tertiary);
|
||||||
padding: 1px 10px 1px 10px;
|
padding: 1px 10px 1px 10px;
|
||||||
@@ -321,16 +333,27 @@ th {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
small {
|
small {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
font-size: var(--font-size-small);
|
font-size: var(--font-size-small);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.skills-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
gap: 0.3em 1em;
|
||||||
|
margin-bottom: 0.2em;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import CreatePost from "./CreatePost.vue";
|
|||||||
import CreateFavorite from "./CreateFavorite.vue";
|
import CreateFavorite from "./CreateFavorite.vue";
|
||||||
import CreateActivity from "./CreateActivity.vue";
|
import CreateActivity from "./CreateActivity.vue";
|
||||||
import CreateRowing from "./CreateRowing.vue";
|
import CreateRowing from "./CreateRowing.vue";
|
||||||
|
import ManageUsers from "./ManageUsers.vue";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
</script>
|
</script>
|
||||||
@@ -16,13 +17,12 @@ const auth = useAuthStore();
|
|||||||
<main class="halftone justify-center flex flex-row w-full h-full">
|
<main class="halftone justify-center flex flex-row w-full h-full">
|
||||||
<div class="a5page-portrait bdr-1 flex flex-col">
|
<div class="a5page-portrait bdr-1 flex flex-col">
|
||||||
<Login class="bdr-2 bg-bg_primary" />
|
<Login class="bdr-2 bg-bg_primary" />
|
||||||
<!--
|
<CreateUser class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||||
<CreateUser class="bdr-2 bg-bg_primary" />
|
|
||||||
-->
|
|
||||||
<CreatePost class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
<CreatePost class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||||
<CreateFavorite 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" />
|
<CreateActivity class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||||
<CreateRowing class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
<CreateRowing class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||||
|
<ManageUsers class="bdr-2 bg-bg_primary" v-if="auth.loggedIn" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Button from "@/components/input/Button.vue";
|
import Button from "@/components/input/Button.vue";
|
||||||
|
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import axios from "axios";
|
import { gql } from "@/graphql";
|
||||||
|
|
||||||
const type = ref("");
|
const type = ref("");
|
||||||
const name = ref("");
|
const name = ref("");
|
||||||
@@ -10,15 +10,14 @@ const link = ref("");
|
|||||||
|
|
||||||
async function post() {
|
async function post() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.post("/api/activity", {
|
const data = await gql(
|
||||||
type: type.value,
|
`mutation CreateActivity($input: CreateActivityInput!) { createActivity(input: $input) { id } }`,
|
||||||
name: name.value,
|
{ input: { type: type.value, name: name.value, link: link.value || undefined } },
|
||||||
link: link.value || undefined,
|
);
|
||||||
});
|
|
||||||
type.value = "";
|
type.value = "";
|
||||||
name.value = "";
|
name.value = "";
|
||||||
link.value = "";
|
link.value = "";
|
||||||
console.log(res.data);
|
console.log(data.createActivity);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
@@ -28,9 +27,9 @@ async function post() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<h1>Create Activity</h1>
|
<h1>Create Activity</h1>
|
||||||
<input type="text" v-model="type" placeholder="Type" />
|
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" />
|
||||||
<input type="text" v-model="name" placeholder="Name" />
|
<input type="text" v-model="name" placeholder="Name" @keyup.enter="post" />
|
||||||
<input type="text" v-model="link" placeholder="Link" />
|
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
|
||||||
<Button @click="post">Upload</Button>
|
<Button @click="post">Upload</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Button from "@/components/input/Button.vue";
|
import Button from "@/components/input/Button.vue";
|
||||||
|
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import axios from "axios";
|
import { gql } from "@/graphql";
|
||||||
|
|
||||||
const type = ref("");
|
const type = ref("");
|
||||||
const name = ref("");
|
const name = ref("");
|
||||||
@@ -10,15 +10,14 @@ const link = ref("");
|
|||||||
|
|
||||||
async function post() {
|
async function post() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.post("/api/favorites", {
|
const data = await gql(
|
||||||
type: type.value,
|
`mutation CreateFavorite($input: CreateFavoriteInput!) { createFavorite(input: $input) { id } }`,
|
||||||
name: name.value,
|
{ input: { type: type.value, name: name.value, link: link.value || undefined } },
|
||||||
link: link.value || undefined,
|
);
|
||||||
});
|
|
||||||
type.value = "";
|
type.value = "";
|
||||||
name.value = "";
|
name.value = "";
|
||||||
link.value = "";
|
link.value = "";
|
||||||
console.log(res.data);
|
console.log(data.createFavorite);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
@@ -28,9 +27,9 @@ async function post() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<h1>Create Favorite</h1>
|
<h1>Create Favorite</h1>
|
||||||
<input type="text" v-model="type" placeholder="Type" />
|
<input type="text" v-model="type" placeholder="Type" @keyup.enter="post" />
|
||||||
<input type="text" v-model="name" placeholder="Name" />
|
<input type="text" v-model="name" placeholder="Name" @keyup.enter="post" />
|
||||||
<input type="text" v-model="link" placeholder="Link" />
|
<input type="text" v-model="link" placeholder="Link" @keyup.enter="post" />
|
||||||
<Button @click="post">Upload</Button>
|
<Button @click="post">Upload</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Button from "@/components/input/Button.vue";
|
import Button from "@/components/input/Button.vue";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import axios from "axios";
|
import { gql } from "@/graphql";
|
||||||
|
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const content = ref("");
|
const content = ref("");
|
||||||
|
|
||||||
async function post() {
|
async function post() {
|
||||||
try {
|
try {
|
||||||
const res = await axios.post("/api/posts", {
|
const data = await gql(
|
||||||
title: title.value,
|
`mutation CreatePost($input: CreatePostInput!) { createPost(input: $input) { id } }`,
|
||||||
content: content.value,
|
{ input: { title: title.value, content: content.value } },
|
||||||
});
|
);
|
||||||
title.value = "";
|
title.value = "";
|
||||||
content.value = "";
|
content.value = "";
|
||||||
console.log(res.data);
|
console.log(data.createPost);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
@@ -24,7 +24,7 @@ async function post() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<h1>Create Post</h1>
|
<h1>Create Post</h1>
|
||||||
<input type="text" v-model="title" placeholder="Title" />
|
<input type="text" v-model="title" placeholder="Title" @keyup.enter="post" />
|
||||||
<textarea
|
<textarea
|
||||||
class="h-50"
|
class="h-50"
|
||||||
v-model="content"
|
v-model="content"
|
||||||
|
|||||||
@@ -3,44 +3,49 @@ import Button from "@/components/input/Button.vue";
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
const image = ref(null);
|
const images = ref([]);
|
||||||
const status = ref("");
|
const results = ref([]);
|
||||||
const error = ref("");
|
|
||||||
|
|
||||||
function onFileChange(e) {
|
function onFileChange(e) {
|
||||||
image.value = e.target.files[0] || null;
|
images.value = Array.from(e.target.files);
|
||||||
|
results.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!image.value) {
|
if (!images.value.length) return;
|
||||||
error.value = "Please select an image";
|
results.value = images.value.map((f) => ({ name: f.name, status: "Uploading..." }));
|
||||||
return;
|
|
||||||
}
|
|
||||||
error.value = "";
|
|
||||||
status.value = "Uploading...";
|
|
||||||
|
|
||||||
const formData = new FormData();
|
await Promise.all(
|
||||||
formData.append("image", image.value);
|
images.value.map(async (file, i) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("image", file);
|
||||||
|
try {
|
||||||
|
const res = await axios.post("/api/rowing", formData, {
|
||||||
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
|
});
|
||||||
|
const mins = Math.floor(res.data.Time / 1e9 / 60);
|
||||||
|
const secs = String(Math.floor((res.data.Time / 1e9) % 60)).padStart(2, "0");
|
||||||
|
results.value[i].status = `${res.data.Distance}m in ${mins}:${secs}`;
|
||||||
|
results.value[i].ok = true;
|
||||||
|
} catch (err) {
|
||||||
|
results.value[i].status = err.response?.data?.error || "Upload failed";
|
||||||
|
results.value[i].ok = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
images.value = [];
|
||||||
const res = await axios.post("/api/rowing", formData, {
|
|
||||||
headers: { "Content-Type": "multipart/form-data" },
|
|
||||||
});
|
|
||||||
status.value = `Saved: ${res.data.Distance}m in ${Math.floor(res.data.Time / 1e9 / 60)}:${String(Math.floor((res.data.Time / 1e9) % 60)).padStart(2, "0")}`;
|
|
||||||
image.value = null;
|
|
||||||
} catch (err) {
|
|
||||||
status.value = "";
|
|
||||||
error.value = err.response?.data?.error || "Upload failed";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h1>Create Rowing</h1>
|
<h1>Create Rowing</h1>
|
||||||
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" @change="onFileChange" />
|
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple @change="onFileChange" />
|
||||||
<Button @click="submit">Upload</Button>
|
<Button @click="submit">Upload</Button>
|
||||||
<p v-if="status">{{ status }}</p>
|
<div v-for="r in results" :key="r.name">
|
||||||
<p v-if="error" class="text-red-500">{{ error }}</p>
|
<span class="text-primary">{{ r.name }}: </span>
|
||||||
|
<span :class="r.ok ? 'text-secondary' : 'text-red-500'">{{ r.status }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,28 +1,45 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Button from "@/components/input/Button.vue";
|
import Button from "@/components/input/Button.vue";
|
||||||
import { ref, onMounted, computed } from "vue";
|
import { ref } from "vue";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { gql } from "@/graphql";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const username = ref("");
|
const username = ref("");
|
||||||
const password = ref("");
|
const password = ref("");
|
||||||
|
const message = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
function handleLogin() {
|
async function handleCreate() {
|
||||||
auth.createUser(username.value, password.value);
|
message.value = "";
|
||||||
|
error.value = "";
|
||||||
|
try {
|
||||||
|
const data = await gql(
|
||||||
|
`mutation CreateUser($input: CreateUserInput!) { createUser(input: $input) { id username } }`,
|
||||||
|
{ input: { username: username.value, password: password.value } },
|
||||||
|
);
|
||||||
|
message.value = `User "${data.createUser.username}" created successfully.`;
|
||||||
|
username.value = "";
|
||||||
|
password.value = "";
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message || "Failed to create user.";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="auth.loggedIn" class="flex flex-col">
|
<div v-if="auth.loggedIn && auth.user.admin" class="flex flex-col">
|
||||||
<h1>Logged in</h1>
|
<h1>Create User</h1>
|
||||||
<p>{{ auth.user.id }}</p>
|
<p v-if="message" class="text-green-500">{{ message }}</p>
|
||||||
<p>{{ auth.user.username }}</p>
|
<p v-if="error" class="text-red-500">{{ error }}</p>
|
||||||
<p>{{ auth.user.admin }}</p>
|
<input type="text" v-model="username" placeholder="Username" @keyup.enter="handleCreate" />
|
||||||
|
<input type="password" v-model="password" placeholder="Password" @keyup.enter="handleCreate" />
|
||||||
|
<Button @click="handleCreate">Create Account</Button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="auth.loggedIn" class="flex flex-col">
|
||||||
|
<p>You do not have permission to create users.</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col">
|
<div v-else class="flex flex-col">
|
||||||
<h1>Create User</h1>
|
<p>You must be logged in as an admin to create users.</p>
|
||||||
<input type="text" v-model="username" placeholder="Username" />
|
|
||||||
<input type="password" v-model="password" placeholder="Password" />
|
|
||||||
<Button @click="handleLogin">Create Account</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ function handleLogout() {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col">
|
<div v-else class="flex flex-col">
|
||||||
<h1>Login</h1>
|
<h1>Login</h1>
|
||||||
<input type="text" v-model="username" placeholder="Username" />
|
<input type="text" v-model="username" placeholder="Username" @keyup.enter="handleLogin" />
|
||||||
<input type="password" v-model="password" placeholder="Password" />
|
<input type="password" v-model="password" placeholder="Password" @keyup.enter="handleLogin" />
|
||||||
<Button @click="handleLogin">Log In</Button>
|
<Button @click="handleLogin">Log In</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
45
nginx/vue/src/views/admin/ManageUsers.vue
Normal file
45
nginx/vue/src/views/admin/ManageUsers.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from "@/components/input/Button.vue";
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
import { gql } from "@/graphql";
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const users = ref([]);
|
||||||
|
|
||||||
|
async function fetchUsers() {
|
||||||
|
try {
|
||||||
|
const data = await gql(`query { users { id username admin } }`);
|
||||||
|
users.value = data.users;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleAdmin(user) {
|
||||||
|
try {
|
||||||
|
const data = await auth.setUserAdmin(user.id, !user.admin);
|
||||||
|
user.admin = data.admin;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchUsers);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<h1>Manage Users</h1>
|
||||||
|
<div v-for="user in users" :key="user.id" class="flex flex-row items-center gap-2">
|
||||||
|
<span>{{ user.username }}</span>
|
||||||
|
<span v-if="user.admin">(admin)</span>
|
||||||
|
<Button
|
||||||
|
v-if="user.id !== auth.user.id"
|
||||||
|
@click="toggleAdmin(user)"
|
||||||
|
>
|
||||||
|
{{ user.admin ? "Demote" : "Promote" }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,76 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
import Slideshow from "@/components/util/Slideshow.vue";
|
||||||
import { Transition } from "vue";
|
|
||||||
import Header from "@/components/text/Header.vue";
|
|
||||||
|
|
||||||
const images = [
|
const images = [
|
||||||
{ url: "/img/memes/pidgeon.gif", comment: "鸟" },
|
{ url: "/img/memes/pidgeon.gif", comment: "鸟" },
|
||||||
// { url: "/img/memes/no_slip.png" },
|
// { url: "/img/memes/no_slip.png" },
|
||||||
//{ url: "/img/memes/epic.jpeg" },
|
// { url: "/img/memes/epic.jpeg" },
|
||||||
// { url: "/img/bedroom/img2.png", comment: "办公桌" },
|
// { url: "/img/bedroom/img2.png", comment: "办公桌" },
|
||||||
// { url: "/img/bedroom/img1.png", comment: "床" },
|
// { url: "/img/bedroom/img1.png", comment: "床" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const currentIndex = ref(0);
|
|
||||||
const currentComment = computed(() => images[currentIndex.value].comment);
|
|
||||||
const currentUrl = computed(() => images[currentIndex.value].url);
|
|
||||||
|
|
||||||
let nextId;
|
|
||||||
|
|
||||||
function nextImage() {
|
|
||||||
clearTimeout(nextId);
|
|
||||||
currentIndex.value = (currentIndex.value + 1) % images.length;
|
|
||||||
nextId = setTimeout(nextImage, 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextRandomImage() {
|
|
||||||
clearTimeout(nextId);
|
|
||||||
let newIndex;
|
|
||||||
do {
|
|
||||||
newIndex = Math.floor(Math.random() * images.length);
|
|
||||||
} while (newIndex === currentIndex.value);
|
|
||||||
currentIndex.value = newIndex;
|
|
||||||
nextId = setTimeout(nextImage, 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
nextId = setTimeout(nextImage, 10000);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
clearTimeout(nextId);
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition name="fade" mode="out-in">
|
<Slideshow :images="images" />
|
||||||
<div class="image-viewer" @click="nextImage" :key="currentIndex">
|
|
||||||
<Header v-if="currentComment">
|
|
||||||
{{ currentComment }}
|
|
||||||
</Header>
|
|
||||||
<img :src="currentUrl" alt="Image Viewer" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.image-viewer {
|
|
||||||
width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-active,
|
|
||||||
.fade-leave-active {
|
|
||||||
transition: opacity 0.5s ease;
|
|
||||||
}
|
|
||||||
.fade-enter-from,
|
|
||||||
.fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -38,30 +38,40 @@ function deletePost() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="flex flex-col flex-1 min-h-0">
|
||||||
class="flex flex-col p-1 overflow-scroll text-left items-start justify-start"
|
|
||||||
>
|
|
||||||
<Header>{{ post.title }}</Header>
|
<Header>{{ post.title }}</Header>
|
||||||
<small
|
<div
|
||||||
>Created at: {{ new Date(post.createdAt).toLocaleString() }}</small
|
class="flex flex-col flex-1 min-h-0 p-1 overflow-auto text-left items-start justify-start"
|
||||||
>
|
>
|
||||||
<small>By: {{ post.author.username }}</small>
|
<small
|
||||||
<Markdown class="flex-1 border-box text-wrap" :source="post.content" />
|
>Created at:
|
||||||
<div class="flex flex-row w-full">
|
{{ new Date(post.createdAt).toLocaleString() }}</small
|
||||||
<Button class="flex-1 border-box" v-if="!leftCap" @click="prevPost">
|
>
|
||||||
Prev
|
<small>By: {{ post.author.username }}</small>
|
||||||
</Button>
|
<Markdown
|
||||||
<Button
|
class="flex-1 border-box text-wrap"
|
||||||
class="flex-1 border-box"
|
:source="post.content"
|
||||||
v-if="!rightCap"
|
/>
|
||||||
@click="nextPost"
|
<div class="flex flex-row w-full">
|
||||||
|
<Button
|
||||||
|
class="flex-1 border-box"
|
||||||
|
v-if="!leftCap"
|
||||||
|
@click="prevPost"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
class="flex-1 border-box"
|
||||||
|
v-if="!rightCap"
|
||||||
|
@click="nextPost"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button class="w-full" v-if="userOwnsPost" @click="deletePost"
|
||||||
|
>Delete</Button
|
||||||
>
|
>
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Button class="w-full" v-if="userOwnsPost" @click="deletePost"
|
|
||||||
>Delete</Button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
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>
|
||||||
@@ -10,20 +10,36 @@ import CommitHistory from "@/components/util/CommitHistory.vue";
|
|||||||
import Intro from "./Intro.vue";
|
import Intro from "./Intro.vue";
|
||||||
import Intro2 from "./Intro2.vue";
|
import Intro2 from "./Intro2.vue";
|
||||||
import BadApple from "./BadApple.vue";
|
import BadApple from "./BadApple.vue";
|
||||||
|
import Miku from "./Miku.vue";
|
||||||
import Stamps from "./Stamps.vue";
|
import Stamps from "./Stamps.vue";
|
||||||
import Listening from "./Listening.vue";
|
import Listening from "./Listening.vue";
|
||||||
import Links from "./Links.vue";
|
import Links from "./Links.vue";
|
||||||
import Feed from "./Feed.vue";
|
import Feed from "./Feed.vue";
|
||||||
import Collage from "./Collage.vue";
|
import Collage from "./Collage.vue";
|
||||||
import Favorites from "./Favorites.vue";
|
import Favorites from "./Favorites.vue";
|
||||||
import Gym from "./Gym.vue";
|
// import Gym from "./Gym.vue";
|
||||||
|
import Gym2 from "./Gym2.vue";
|
||||||
import Consumption from "./Consumption.vue";
|
import Consumption from "./Consumption.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="halftone justify-center flex flex-row w-full h-full">
|
<main class="halftone justify-center flex flex-row w-full h-full">
|
||||||
<div class="h-fit flex flex-row">
|
<div class="outerWrap h-fit flex flex-row">
|
||||||
<div class="a4page-portrait homeGrid relative bdr-1">
|
<div
|
||||||
|
class="sidebar place-content-between flex-1 flex flex-col m-10 w-60 gap-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex flex-col background-children border-children gap-2"
|
||||||
|
>
|
||||||
|
<Chat class="h-306" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Miku class="border-tertiary border bg-bg_secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="a4page-portrait homeGrid relative background-children border-children bdr-1"
|
||||||
|
>
|
||||||
<!-- <Intro class="intro" /> -->
|
<!-- <Intro class="intro" /> -->
|
||||||
<Intro2 class="intro" />
|
<Intro2 class="intro" />
|
||||||
<!-- <BadApple class="intro" /> -->
|
<!-- <BadApple class="intro" /> -->
|
||||||
@@ -34,31 +50,28 @@ import Consumption from "./Consumption.vue";
|
|||||||
<Collage class="collage" />
|
<Collage class="collage" />
|
||||||
<Consumption class="consumption" />
|
<Consumption class="consumption" />
|
||||||
<Favorites class="favorites" />
|
<Favorites class="favorites" />
|
||||||
<Gym class="gym" />
|
<!-- <Gym class="gym" /> -->
|
||||||
|
<Gym2 class="gym" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="sidebar border-quaternary place-content-between flex-1 flex flex-col m-10 w-60"
|
class="sidebar place-content-between flex-1 flex flex-col m-10 w-60 gap-2"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col flex-1 gap-2">
|
<div
|
||||||
<Time
|
class="flex flex-col background-children border-children gap-2"
|
||||||
class="bg-bg_primary border-primary border text-center"
|
>
|
||||||
/>
|
<Time />
|
||||||
<Timer class="border-primary border bg-bg_primary" />
|
<Timer />
|
||||||
<Radio
|
<Radio />
|
||||||
class="border-primary border bg-bg_primary text-center"
|
<CommitHistory class="h-120" />
|
||||||
/>
|
|
||||||
<CommitHistory
|
|
||||||
class="border-primary border bg-bg_primary text-center"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- <Elle class="flex-1" /> -->
|
<!-- <Elle class="flex-1" /> -->
|
||||||
<!-- <Chat class="bdr-2 bg-bg_primary" /> -->
|
|
||||||
<!-- <MusicPlayer /> -->
|
<!-- <MusicPlayer /> -->
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<img
|
<img
|
||||||
src="/img/memes/fire-woman.gif"
|
src="/img/memes/fire-woman.gif"
|
||||||
class="border-tertiary border"
|
class="border-tertiary border"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,32 +80,74 @@ import Consumption from "./Consumption.vue";
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.homeGrid > * {
|
.border-children > * {
|
||||||
border: 2px solid var(--quaternary);
|
border: 2px solid var(--quaternary);
|
||||||
border-color: var(--quaternary);
|
}
|
||||||
|
|
||||||
|
.background-children > * {
|
||||||
background-color: var(--bg_primary);
|
background-color: var(--bg_primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.homeGrid {
|
.homeGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-gap: 5px;
|
gap: 5px;
|
||||||
grid-template-columns: repeat(10, 1fr);
|
grid-template-columns: repeat(10, 1fr);
|
||||||
grid-template-rows: repeat(10, 1fr);
|
grid-template-rows: repeat(10, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 850px) {
|
@media (max-width: 1200px) {
|
||||||
.homeGrid {
|
.outerWrap {
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.homeGrid {
|
||||||
|
order: -1;
|
||||||
|
width: 100%;
|
||||||
|
height: 350mm;
|
||||||
|
margin-inline: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
margin: 5px 10px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 850px) {
|
||||||
.tr,
|
.homeGrid {
|
||||||
.br,
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: none;
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar > * {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
main {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outerWrap {
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
margin: 5px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,4 +195,10 @@ import Consumption from "./Consumption.vue";
|
|||||||
grid-column: span 3;
|
grid-column: span 3;
|
||||||
grid-row: span 2;
|
grid-row: span 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-random {
|
||||||
|
background-color: var(--bg_primary);
|
||||||
|
background-image: url("/img/miku/miku2.gif");
|
||||||
|
background-size: 10px 10px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,49 +23,91 @@ const phrases = [
|
|||||||
"I like anime, all kinds of music and sci fic",
|
"I like anime, all kinds of music and sci fic",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Non-reactive animation state to avoid triggering Vue re-renders every frame
|
||||||
|
const animState = phrases.map((text, i) => ({
|
||||||
|
x: i * 20,
|
||||||
|
y: i * 20,
|
||||||
|
dx: rand(0, 60) / 100,
|
||||||
|
dy: 1.0,
|
||||||
|
content: text,
|
||||||
|
cachedW: 0,
|
||||||
|
cachedH: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reactive items only for initial render
|
||||||
const items = ref<Item[]>(
|
const items = ref<Item[]>(
|
||||||
phrases.map((text, i) => ({
|
animState.map((s) => ({
|
||||||
x: i * 20,
|
x: s.x,
|
||||||
y: i * 20,
|
y: s.y,
|
||||||
dx: rand(0, 30) / 100,
|
dx: s.dx,
|
||||||
dy: 0.5,
|
dy: s.dy,
|
||||||
content: text,
|
content: s.content,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
let rafId = 0;
|
let rafId = 0;
|
||||||
|
let cachedCW = 0;
|
||||||
|
let cachedCH = 0;
|
||||||
|
let lastFrameTime = 0;
|
||||||
|
const FRAME_INTERVAL = 1000 / 30;
|
||||||
|
|
||||||
function animate() {
|
function measureSizes() {
|
||||||
const c = container.value;
|
const c = container.value;
|
||||||
if (!c) return;
|
if (c) {
|
||||||
|
cachedCW = c.clientWidth;
|
||||||
const cw = c.clientWidth;
|
cachedCH = c.clientHeight;
|
||||||
const ch = c.clientHeight;
|
}
|
||||||
|
itemEls.value.forEach((el, i) => {
|
||||||
items.value.forEach((item, i) => {
|
if (el && animState[i]) {
|
||||||
const el = itemEls.value[i];
|
animState[i].cachedW = el.offsetWidth;
|
||||||
if (!el) return;
|
animState[i].cachedH = el.offsetHeight;
|
||||||
|
}
|
||||||
const ew = el.offsetWidth;
|
|
||||||
const eh = el.offsetHeight;
|
|
||||||
|
|
||||||
item.x += item.dx;
|
|
||||||
item.y += item.dy;
|
|
||||||
|
|
||||||
if (item.x < 0 || item.x > cw - ew) item.dx *= -1;
|
|
||||||
if (item.y < 0 || item.y > ch - eh) item.dy *= -1;
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate(timestamp: number) {
|
||||||
|
if (!cachedCW || !cachedCH) {
|
||||||
|
rafId = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestamp - lastFrameTime < FRAME_INTERVAL) {
|
||||||
|
rafId = requestAnimationFrame(animate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastFrameTime = timestamp;
|
||||||
|
|
||||||
|
for (let i = 0; i < animState.length; i++) {
|
||||||
|
const s = animState[i];
|
||||||
|
const el = itemEls.value[i];
|
||||||
|
if (!el) continue;
|
||||||
|
|
||||||
|
s.x += s.dx;
|
||||||
|
s.y += s.dy;
|
||||||
|
|
||||||
|
if (s.x < 0 || s.x > cachedCW - s.cachedW) s.dx *= -1;
|
||||||
|
if (s.y < 0 || s.y > cachedCH - s.cachedH) s.dy *= -1;
|
||||||
|
|
||||||
|
el.style.transform = `translate(${s.x}px, ${s.y}px)`;
|
||||||
|
}
|
||||||
|
|
||||||
rafId = requestAnimationFrame(animate);
|
rafId = requestAnimationFrame(animate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resizeObserver: ResizeObserver;
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
measureSizes();
|
||||||
rafId = requestAnimationFrame(animate);
|
rafId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(measureSizes);
|
||||||
|
resizeObserver.observe(container.value!);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cancelAnimationFrame(rafId);
|
cancelAnimationFrame(rafId);
|
||||||
|
resizeObserver?.disconnect();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -79,9 +121,6 @@ onUnmounted(() => {
|
|||||||
:key="i"
|
:key="i"
|
||||||
ref="itemEls"
|
ref="itemEls"
|
||||||
class="absolute w-fit h-fit"
|
class="absolute w-fit h-fit"
|
||||||
:style="{
|
|
||||||
transform: `translate(${item.x}px, ${item.y}px)`,
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<h1>
|
<h1>
|
||||||
{{ item.content }}
|
{{ item.content }}
|
||||||
|
|||||||
@@ -29,10 +29,11 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition name="fade" mode="out-in">
|
<div class="listening-wrapper">
|
||||||
|
<Transition name="fade">
|
||||||
<div
|
<div
|
||||||
@click="nextSong"
|
@click="nextSong"
|
||||||
:key="song.track.id"
|
:key="song.track.name"
|
||||||
class="flex flex-col items-center"
|
class="flex flex-col items-center"
|
||||||
>
|
>
|
||||||
<Header>Listening To</Header>
|
<Header>Listening To</Header>
|
||||||
@@ -45,9 +46,16 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.listening-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 70%;
|
width: 70%;
|
||||||
}
|
}
|
||||||
@@ -56,15 +64,17 @@ p {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fade-enter-active {
|
.fade-enter-active,
|
||||||
transition: opacity 0.5s ease;
|
|
||||||
}
|
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 0.5s ease;
|
transition: opacity 0.5s ease;
|
||||||
}
|
}
|
||||||
.fade-enter-from {
|
.fade-leave-active {
|
||||||
opacity: 0;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
}
|
}
|
||||||
|
.fade-enter-from,
|
||||||
.fade-leave-to {
|
.fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
@@ -3,11 +3,9 @@ import Wip from "@/components/util/Wip.vue";
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="items-center flex flex-col">
|
<main class="items-center flex flex-col">
|
||||||
<div
|
<div class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll">
|
||||||
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
|
<Wip />
|
||||||
>
|
</div>
|
||||||
<Wip />
|
</main>
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -7,10 +7,21 @@ import vueDevTools from "vite-plugin-vue-devtools";
|
|||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue(), vueDevTools(), tailwindcss()],
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
...(process.env.NODE_ENV !== "production" ? [vueDevTools()] : []),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://localhost:8080",
|
||||||
|
"/gitea": "http://localhost:3000",
|
||||||
|
"/radio": "http://localhost:8000",
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
83
readme.md
83
readme.md
@@ -1,14 +1,79 @@
|
|||||||
# Introduction
|
# My Web
|
||||||
|
|
||||||
|
## 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.
|
Welcome to the source code for my website! Please contact me if you would like to collaborate and thank you for visiting.
|
||||||
|
|
||||||
This website is currently self hosted on my Rasberry PI. Any interference and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
|
This website is currently self hosted on my Raspberry Pi. Any interference and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
|
||||||
|
|
||||||
# Future ideas
|
## Architecture
|
||||||
|
|
||||||
- Rust to wasm
|
All services run in Docker containers orchestrated by Docker Compose:
|
||||||
|
|
||||||
|
```
|
||||||
|
nginx (80, 443) ── Frontend SPA + Reverse Proxy
|
||||||
|
backend (8080) ── Go API
|
||||||
|
db (5432) ── PostgreSQL 16
|
||||||
|
icecast2 (8000) ── Audio Streaming
|
||||||
|
gitea (3000) ── Self-Hosted Git
|
||||||
|
gitea-runner ── CI/CD Runner
|
||||||
|
certbot ── SSL Certificate Management
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
**Frontend** - Vue 3, Vite, Tailwind CSS, Pinia, Vue Router, markdown-it, Rust/WASM
|
||||||
|
|
||||||
|
**Backend** - Go (Gin), GORM, PostgreSQL, JWT auth, WebSockets
|
||||||
|
|
||||||
|
**Integrations** - Spotify API, Anthropic Claude API, Icecast2
|
||||||
|
|
||||||
|
**Infrastructure** - Docker Compose, Nginx, Let's Encrypt (Certbot), Gitea + Act Runner
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Spotify integration (currently playing, recently played)
|
||||||
|
- Obsidian note viewer with wikilink and LaTeX support
|
||||||
|
- Live radio streaming via Icecast2
|
||||||
|
- Real-time chat over WebSockets
|
||||||
|
- Blog with admin panel (CRUD)
|
||||||
|
- Activity and rowing session tracking
|
||||||
|
- Fan shrines (GTO, Evangelion, Demoman, Skip Skip Benben)
|
||||||
|
- Self-hosted Git (Gitea) with CI/CD
|
||||||
|
- Claude AI integration
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
| -------------- | ------------------------------------- |
|
||||||
|
| `/` | Home dashboard with grid layout |
|
||||||
|
| `/admin` | Admin panel (authenticated) |
|
||||||
|
| `/cv` | Curriculum Vitae |
|
||||||
|
| `/bookmarks` | Bookmarks |
|
||||||
|
| `/notes/:path` | Obsidian note viewer |
|
||||||
|
| `/shrines` | Fan shrine index + individual shrines |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Public endpoints for posts, users, favorites, activities, rowing, Spotify, notes, and WebSocket messaging. Protected endpoints for creating/updating/deleting content require JWT authentication via `/auth/login`.
|
||||||
|
|
||||||
|
## Local Testing (Dev Mode)
|
||||||
|
|
||||||
|
Run the full stack over plain HTTP without SSL certificates:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses an HTTP-only nginx config with all routing (SPA, backend proxy, radio, gitea) and disables certbot. Visit `http://localhost` to test.
|
||||||
|
|
||||||
|
## Future Ideas
|
||||||
|
|
||||||
|
- More Rust to WASM
|
||||||
- ML for chatboards
|
- ML for chatboards
|
||||||
- Cache requests
|
- Cache requests
|
||||||
- Design more webpages
|
- Design more webpages
|
||||||
@@ -17,12 +82,11 @@ This website is currently self hosted on my Rasberry PI. Any interference and th
|
|||||||
- Design shrines
|
- Design shrines
|
||||||
- Redis (not really but practical experience)
|
- Redis (not really but practical experience)
|
||||||
|
|
||||||
# .env
|
## .env
|
||||||
|
|
||||||
These environment variables are found in the `.env` file. The use of environment variables can be found by reading the code so the security of the variable names are not significant.
|
These environment variables are found in the `.env` file. The use of environment variables can be found by reading the code so the security of the variable names are not significant.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
POSTGRES_USER=
|
POSTGRES_USER=
|
||||||
POSTGRES_PASSWORD=
|
POSTGRES_PASSWORD=
|
||||||
POSTGRES_DB=
|
POSTGRES_DB=
|
||||||
@@ -42,6 +106,10 @@ BACKEND_HOST=
|
|||||||
BACKEND_SECRET=
|
BACKEND_SECRET=
|
||||||
BACKEND_ENDPOINT=
|
BACKEND_ENDPOINT=
|
||||||
|
|
||||||
|
CLAUDE_API_KEY=
|
||||||
|
|
||||||
|
SEED_DB=
|
||||||
|
|
||||||
OBSIDIAN_DIR=
|
OBSIDIAN_DIR=
|
||||||
|
|
||||||
SPOTIFY_CLIENT_ID=
|
SPOTIFY_CLIENT_ID=
|
||||||
@@ -60,4 +128,7 @@ ICECAST_MOUNT=
|
|||||||
DOMAIN=
|
DOMAIN=
|
||||||
EMAIL=
|
EMAIL=
|
||||||
|
|
||||||
|
GITEA_LFS_JWT_SECRET=
|
||||||
|
GITEA_INTERNAL_TOKEN=
|
||||||
|
GITEA_OAUTH2_JWT_SECRET=
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user