Compare commits
5 Commits
f2ba3494b1
...
rowing_fro
| Author | SHA1 | Date | |
|---|---|---|---|
| e25fc5f1d1 | |||
| 5bcc65668e | |||
| 2c1ecce99a | |||
| 095cd72946 | |||
| 1d4beca336 |
@@ -7,12 +7,13 @@ require (
|
|||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
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.43.0
|
||||||
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5
|
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/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
|
||||||
@@ -23,7 +24,7 @@ require (
|
|||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.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.18.0 // indirect
|
||||||
github.com/golang/protobuf v1.5.2 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // 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
|
||||||
@@ -40,6 +41,11 @@ 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/tidwall/gjson v1.18.0 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
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
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9
|
|||||||
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/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/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/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=
|
||||||
@@ -102,6 +104,8 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
|
|||||||
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 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=
|
||||||
@@ -172,6 +176,8 @@ github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1
|
|||||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||||
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/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
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=
|
||||||
@@ -183,6 +189,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
|||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
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=
|
||||||
@@ -287,6 +303,8 @@ golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4Iltr
|
|||||||
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 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/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
|||||||
154
backend/handlers/handle_rowing.go
Normal file
154
backend/handlers/handle_rowing.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rwcarlsen/goexif/exif"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExtractedRowingData struct {
|
||||||
|
TimeMinutes float64 `json:"timeMinutes"`
|
||||||
|
TimeSeconds float64 `json:"timeSeconds"`
|
||||||
|
Distance float64 `json:"distance"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) GetRowing(ctx *gin.Context) {
|
||||||
|
var rowing []models.Rowing
|
||||||
|
if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.JSON(http.StatusOK, rowing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CreateRowing(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("image")
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "image is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
// Get the date taken from the EXIF data
|
||||||
|
x, err := exif.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "no EXIF data found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dateTaken, err := x.DateTime()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "no date found in EXIF data"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seek back to start since exif.Decode advanced the cursor
|
||||||
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to seek image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := io.ReadAll(f)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedMediaTypes := map[string]bool{
|
||||||
|
"image/jpeg": true,
|
||||||
|
"image/png": true,
|
||||||
|
"image/gif": true,
|
||||||
|
"image/webp": true,
|
||||||
|
}
|
||||||
|
mediaType := file.Header.Get("Content-Type")
|
||||||
|
if !allowedMediaTypes[mediaType] {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "unsupported image type"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(data)
|
||||||
|
|
||||||
|
// Build the message with an image + text prompt
|
||||||
|
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
|
||||||
|
Model: anthropic.ModelClaudeHaiku4_5,
|
||||||
|
MaxTokens: 256,
|
||||||
|
Messages: []anthropic.MessageParam{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: []anthropic.ContentBlockParamUnion{
|
||||||
|
// Image block
|
||||||
|
anthropic.NewImageBlock(anthropic.Base64ImageSourceParam{
|
||||||
|
Type: "base64",
|
||||||
|
MediaType: anthropic.Base64ImageSourceMediaType(mediaType),
|
||||||
|
Data: encoded,
|
||||||
|
}),
|
||||||
|
// Text prompt requesting exactly 2 variables
|
||||||
|
anthropic.NewTextBlock(
|
||||||
|
`Look at this rowing machine display. Extract the total elapsed time and total distance.
|
||||||
|
|
||||||
|
Return ONLY a JSON object with these exact keys and numeric values:
|
||||||
|
- "timeMinutes": total minutes (e.g. 2:30 = 2)
|
||||||
|
- "timeSeconds": total seconds (e.g. 2:30 = 30)
|
||||||
|
- "distance": distance in meters as a number (e.g. 5000)
|
||||||
|
|
||||||
|
No text, no markdown, no explanation. Just the JSON object.`),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(message.Content) == 0 {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
extractedData := ExtractedRowingData{}
|
||||||
|
err = json.Unmarshal([]byte(message.Content[0].Text), &extractedData)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if extractedData.Distance == 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid distance in image"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
|
||||||
|
totalDuration := time.Duration(totalSeconds * float64(time.Second))
|
||||||
|
per500m := time.Duration(totalSeconds / extractedData.Distance * 500 * float64(time.Second))
|
||||||
|
|
||||||
|
rowing := models.Rowing{
|
||||||
|
Date: dateTaken,
|
||||||
|
Time: totalDuration,
|
||||||
|
TimePer500m: per500m,
|
||||||
|
Distance: extractedData.Distance,
|
||||||
|
Calories: extractedData.Distance / 7500.0 * 500.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.DB.Create(&rowing).Error; err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save rowing"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusCreated, rowing)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/services"
|
"adam-french.co.uk/backend/services"
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
"github.com/zmb3/spotify/v2"
|
"github.com/zmb3/spotify/v2"
|
||||||
spotifyauth "github.com/zmb3/spotify/v2/auth"
|
spotifyauth "github.com/zmb3/spotify/v2/auth"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -13,6 +14,7 @@ type Store struct {
|
|||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
SpotifyAuth *spotifyauth.Authenticator
|
SpotifyAuth *spotifyauth.Authenticator
|
||||||
SpotifyClient *spotify.Client
|
SpotifyClient *spotify.Client
|
||||||
|
ClaudeClient *anthropic.Client
|
||||||
Auth *services.Auth
|
Auth *services.Auth
|
||||||
Notes *services.Notes
|
Notes *services.Notes
|
||||||
|
|
||||||
|
|||||||
@@ -39,12 +39,18 @@ func main() {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SPOTIFY
|
||||||
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
||||||
spotifyRedirectURL := os.Getenv("SPOTIFY_REDIRECT_URI")
|
spotifyRedirectURL := os.Getenv("SPOTIFY_REDIRECT_URI")
|
||||||
spotifyClientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
spotifyClientID := os.Getenv("SPOTIFY_CLIENT_ID")
|
||||||
spotifyClientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
spotifyClientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
spotifyConfig := services.SpotifyConfig{AuthState: spotifyAuthState, RedirectURL: spotifyRedirectURL, ClientID: spotifyClientID, ClientSecret: spotifyClientSecret}
|
spotifyConfig := services.SpotifyConfig{AuthState: spotifyAuthState, RedirectURL: spotifyRedirectURL, ClientID: spotifyClientID, ClientSecret: spotifyClientSecret}
|
||||||
spotifyAuth, client := services.InitSpotifyAuth(&spotifyConfig)
|
spotifyAuth, spotifyClient := services.InitSpotifyAuth(&spotifyConfig)
|
||||||
|
|
||||||
|
// CLAUDE
|
||||||
|
claudeAPIKey := os.Getenv("CLAUDE_API_KEY")
|
||||||
|
claudeConfig := services.ClaudeConfig{APIKey: claudeAPIKey}
|
||||||
|
claudeClient := services.InitClaude(&claudeConfig)
|
||||||
|
|
||||||
authSecret := os.Getenv("BACKEND_SECRET")
|
authSecret := os.Getenv("BACKEND_SECRET")
|
||||||
domainName := os.Getenv("DOMAIN")
|
domainName := os.Getenv("DOMAIN")
|
||||||
@@ -58,7 +64,7 @@ func main() {
|
|||||||
notesConfig := services.NotesConfig{Dir: notesDir}
|
notesConfig := services.NotesConfig{Dir: notesDir}
|
||||||
notes := services.InitNotes(¬esConfig)
|
notes := services.InitNotes(¬esConfig)
|
||||||
|
|
||||||
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: client, 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)
|
||||||
|
|
||||||
@@ -66,6 +72,10 @@ func main() {
|
|||||||
r.GET("/favorites", store.GetFavorites)
|
r.GET("/favorites", store.GetFavorites)
|
||||||
protected.POST("/favorites", store.CreateFavorite)
|
protected.POST("/favorites", store.CreateFavorite)
|
||||||
|
|
||||||
|
// ROWING
|
||||||
|
r.GET("/rowing", store.GetRowing)
|
||||||
|
protected.POST("/rowing", store.CreateRowing)
|
||||||
|
|
||||||
// ACTIVITIES
|
// ACTIVITIES
|
||||||
r.GET("/activity", store.GetActivity)
|
r.GET("/activity", store.GetActivity)
|
||||||
protected.POST("/activity", store.CreateActivity)
|
protected.POST("/activity", store.CreateActivity)
|
||||||
|
|||||||
@@ -55,3 +55,14 @@ type Favorite struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Link *string `json:"link"`
|
Link *string `json:"link"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Rowing struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Time time.Duration `json:"time"`
|
||||||
|
TimePer500m time.Duration `json:"timePer500m"`
|
||||||
|
Distance float64 `json:"distance"`
|
||||||
|
Calories float64 `json:"calories"`
|
||||||
|
}
|
||||||
|
|||||||
15
backend/services/claude.go
Normal file
15
backend/services/claude.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
"github.com/anthropics/anthropic-sdk-go/option"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClaudeConfig struct {
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitClaude(config *ClaudeConfig) *anthropic.Client {
|
||||||
|
client := anthropic.NewClient(option.WithAPIKey(config.APIKey))
|
||||||
|
return &client
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ func migrateDatabase(db *gorm.DB) error {
|
|||||||
&models.Post{},
|
&models.Post{},
|
||||||
&models.Activity{},
|
&models.Activity{},
|
||||||
&models.Favorite{},
|
&models.Favorite{},
|
||||||
|
&models.Rowing{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: "/admin",
|
path: "/admin",
|
||||||
name: "admin",
|
name: "admin",
|
||||||
component: () => import("../views/Admin.vue"),
|
component: () => import("../views/admin/Admin.vue"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/bookmarks",
|
path: "/bookmarks",
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
import Login from "@/components/admin/Login.vue";
|
import Login from "./Login.vue";
|
||||||
import CreateUser from "@/components/admin/CreateUser.vue";
|
import CreateUser from "./CreateUser.vue";
|
||||||
import CreatePost from "@/components/admin/CreatePost.vue";
|
import CreatePost from "./CreatePost.vue";
|
||||||
import CreateFavorite from "@/components/admin/CreateFavorite.vue";
|
import CreateFavorite from "./CreateFavorite.vue";
|
||||||
import CreateActivity from "@/components/admin/CreateActivity.vue";
|
import CreateActivity from "./CreateActivity.vue";
|
||||||
|
import CreateRowing from "./CreateRowing.vue";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
</script>
|
</script>
|
||||||
@@ -21,6 +22,7 @@ const auth = useAuthStore();
|
|||||||
<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" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
46
nginx/vue/src/views/admin/CreateRowing.vue
Normal file
46
nginx/vue/src/views/admin/CreateRowing.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup>
|
||||||
|
import Button from "@/components/input/Button.vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
const image = ref(null);
|
||||||
|
const status = ref("");
|
||||||
|
const error = ref("");
|
||||||
|
|
||||||
|
function onFileChange(e) {
|
||||||
|
image.value = e.target.files[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!image.value) {
|
||||||
|
error.value = "Please select an image";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = "";
|
||||||
|
status.value = "Uploading...";
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("image", image.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h1>Create Rowing</h1>
|
||||||
|
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" @change="onFileChange" />
|
||||||
|
<Button @click="submit">Upload</Button>
|
||||||
|
<p v-if="status">{{ status }}</p>
|
||||||
|
<p v-if="error" class="text-red-500">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user