Move Gitea feed from frontend to backend with cached GraphQL proxy
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m39s

Replaces direct browser-to-Gitea API calls with a backend service that
proxies and caches the feed (1-min TTL), served via the existing GraphQL
HomeData query. Commit message parsing now happens server-side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 00:14:59 +00:00
parent 5999eccc21
commit 7381cda7b8
11 changed files with 520 additions and 47 deletions

View File

@@ -66,6 +66,15 @@ type ComplexityRoot struct {
UpdatedAt func(childComplexity int) int UpdatedAt func(childComplexity int) int
} }
GiteaFeedItem struct {
AvatarURL func(childComplexity int) int
CommitMessage func(childComplexity int) int
CreatedAt func(childComplexity int) int
OpType func(childComplexity int) int
RepoName func(childComplexity int) int
RepoURL func(childComplexity int) int
}
Message struct { Message struct {
AuthorID func(childComplexity int) int AuthorID func(childComplexity int) int
Content func(childComplexity int) int Content func(childComplexity int) int
@@ -100,6 +109,7 @@ type ComplexityRoot struct {
Query struct { Query struct {
Activities func(childComplexity int) int Activities func(childComplexity int) int
Favorites func(childComplexity int) int Favorites func(childComplexity int) int
GiteaFeed func(childComplexity int) int
Me func(childComplexity int) int Me func(childComplexity int) int
Messages func(childComplexity int) int Messages func(childComplexity int) int
Post func(childComplexity int, id int) int Post func(childComplexity int, id int) int
@@ -197,6 +207,7 @@ type QueryResolver interface {
Messages(ctx context.Context) ([]*models.Message, error) Messages(ctx context.Context) ([]*models.Message, error)
SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error) SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error)
SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error) SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error)
GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error)
Me(ctx context.Context) (*models.User, error) Me(ctx context.Context) (*models.User, error)
} }
type RowingResolver interface { type RowingResolver interface {
@@ -304,6 +315,43 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.ComplexityRoot.Favorite.UpdatedAt(childComplexity), true return e.ComplexityRoot.Favorite.UpdatedAt(childComplexity), true
case "GiteaFeedItem.avatarUrl":
if e.ComplexityRoot.GiteaFeedItem.AvatarURL == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.AvatarURL(childComplexity), true
case "GiteaFeedItem.commitMessage":
if e.ComplexityRoot.GiteaFeedItem.CommitMessage == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.CommitMessage(childComplexity), true
case "GiteaFeedItem.createdAt":
if e.ComplexityRoot.GiteaFeedItem.CreatedAt == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.CreatedAt(childComplexity), true
case "GiteaFeedItem.opType":
if e.ComplexityRoot.GiteaFeedItem.OpType == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.OpType(childComplexity), true
case "GiteaFeedItem.repoName":
if e.ComplexityRoot.GiteaFeedItem.RepoName == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.RepoName(childComplexity), true
case "GiteaFeedItem.repoUrl":
if e.ComplexityRoot.GiteaFeedItem.RepoURL == nil {
break
}
return e.ComplexityRoot.GiteaFeedItem.RepoURL(childComplexity), true
case "Message.authorId": case "Message.authorId":
if e.ComplexityRoot.Message.AuthorID == nil { if e.ComplexityRoot.Message.AuthorID == nil {
break break
@@ -496,6 +544,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
} }
return e.ComplexityRoot.Query.Favorites(childComplexity), true return e.ComplexityRoot.Query.Favorites(childComplexity), true
case "Query.giteaFeed":
if e.ComplexityRoot.Query.GiteaFeed == nil {
break
}
return e.ComplexityRoot.Query.GiteaFeed(childComplexity), true
case "Query.me": case "Query.me":
if e.ComplexityRoot.Query.Me == nil { if e.ComplexityRoot.Query.Me == nil {
@@ -796,7 +850,7 @@ func newExecutionContext(
} }
} }
//go:embed "schema/activity.graphql" "schema/auth.graphql" "schema/favorite.graphql" "schema/message.graphql" "schema/post.graphql" "schema/rowing.graphql" "schema/schema.graphql" "schema/spotify.graphql" "schema/user.graphql" //go:embed "schema/activity.graphql" "schema/auth.graphql" "schema/favorite.graphql" "schema/gitea.graphql" "schema/message.graphql" "schema/post.graphql" "schema/rowing.graphql" "schema/schema.graphql" "schema/spotify.graphql" "schema/user.graphql"
var sourcesFS embed.FS var sourcesFS embed.FS
func sourceData(filename string) string { func sourceData(filename string) string {
@@ -811,6 +865,7 @@ var sources = []*ast.Source{
{Name: "schema/activity.graphql", Input: sourceData("schema/activity.graphql"), BuiltIn: false}, {Name: "schema/activity.graphql", Input: sourceData("schema/activity.graphql"), BuiltIn: false},
{Name: "schema/auth.graphql", Input: sourceData("schema/auth.graphql"), BuiltIn: false}, {Name: "schema/auth.graphql", Input: sourceData("schema/auth.graphql"), BuiltIn: false},
{Name: "schema/favorite.graphql", Input: sourceData("schema/favorite.graphql"), BuiltIn: false}, {Name: "schema/favorite.graphql", Input: sourceData("schema/favorite.graphql"), BuiltIn: false},
{Name: "schema/gitea.graphql", Input: sourceData("schema/gitea.graphql"), BuiltIn: false},
{Name: "schema/message.graphql", Input: sourceData("schema/message.graphql"), BuiltIn: false}, {Name: "schema/message.graphql", Input: sourceData("schema/message.graphql"), BuiltIn: false},
{Name: "schema/post.graphql", Input: sourceData("schema/post.graphql"), BuiltIn: false}, {Name: "schema/post.graphql", Input: sourceData("schema/post.graphql"), BuiltIn: false},
{Name: "schema/rowing.graphql", Input: sourceData("schema/rowing.graphql"), BuiltIn: false}, {Name: "schema/rowing.graphql", Input: sourceData("schema/rowing.graphql"), BuiltIn: false},
@@ -1407,6 +1462,180 @@ func (ec *executionContext) fieldContext_Favorite_link(_ context.Context, field
return fc, nil return fc, nil
} }
func (ec *executionContext) _GiteaFeedItem_avatarUrl(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_avatarUrl,
func(ctx context.Context) (any, error) {
return obj.AvatarURL, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_avatarUrl(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_repoUrl(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_repoUrl,
func(ctx context.Context) (any, error) {
return obj.RepoURL, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_repoUrl(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_repoName(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_repoName,
func(ctx context.Context) (any, error) {
return obj.RepoName, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_repoName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_opType(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_opType,
func(ctx context.Context) (any, error) {
return obj.OpType, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_opType(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_commitMessage(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_commitMessage,
func(ctx context.Context) (any, error) {
return obj.CommitMessage, nil
},
nil,
ec.marshalNString2string,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_commitMessage(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _GiteaFeedItem_createdAt(ctx context.Context, field graphql.CollectedField, obj *model.GiteaFeedItem) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_GiteaFeedItem_createdAt,
func(ctx context.Context) (any, error) {
return obj.CreatedAt, nil
},
nil,
ec.marshalNTime2timeᚐTime,
true,
true,
)
}
func (ec *executionContext) fieldContext_GiteaFeedItem_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "GiteaFeedItem",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Time does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Message_id(ctx context.Context, field graphql.CollectedField, obj *models.Message) (ret graphql.Marshaler) { func (ec *executionContext) _Message_id(ctx context.Context, field graphql.CollectedField, obj *models.Message) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -2713,6 +2942,49 @@ func (ec *executionContext) fieldContext_Query_spotifyRecent(_ context.Context,
return fc, nil return fc, nil
} }
func (ec *executionContext) _Query_giteaFeed(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField(
ctx,
ec.OperationContext,
field,
ec.fieldContext_Query_giteaFeed,
func(ctx context.Context) (any, error) {
return ec.Resolvers.Query().GiteaFeed(ctx)
},
nil,
ec.marshalOGiteaFeedItem2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐGiteaFeedItem,
true,
false,
)
}
func (ec *executionContext) fieldContext_Query_giteaFeed(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Query",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "avatarUrl":
return ec.fieldContext_GiteaFeedItem_avatarUrl(ctx, field)
case "repoUrl":
return ec.fieldContext_GiteaFeedItem_repoUrl(ctx, field)
case "repoName":
return ec.fieldContext_GiteaFeedItem_repoName(ctx, field)
case "opType":
return ec.fieldContext_GiteaFeedItem_opType(ctx, field)
case "commitMessage":
return ec.fieldContext_GiteaFeedItem_commitMessage(ctx, field)
case "createdAt":
return ec.fieldContext_GiteaFeedItem_createdAt(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type GiteaFeedItem", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) _Query_me(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { func (ec *executionContext) _Query_me(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -5472,6 +5744,70 @@ func (ec *executionContext) _Favorite(ctx context.Context, sel ast.SelectionSet,
return out return out
} }
var giteaFeedItemImplementors = []string{"GiteaFeedItem"}
func (ec *executionContext) _GiteaFeedItem(ctx context.Context, sel ast.SelectionSet, obj *model.GiteaFeedItem) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, giteaFeedItemImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("GiteaFeedItem")
case "avatarUrl":
out.Values[i] = ec._GiteaFeedItem_avatarUrl(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "repoUrl":
out.Values[i] = ec._GiteaFeedItem_repoUrl(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "repoName":
out.Values[i] = ec._GiteaFeedItem_repoName(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "opType":
out.Values[i] = ec._GiteaFeedItem_opType(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "commitMessage":
out.Values[i] = ec._GiteaFeedItem_commitMessage(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "createdAt":
out.Values[i] = ec._GiteaFeedItem_createdAt(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.Deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.ProcessDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var messageImplementors = []string{"Message"} var messageImplementors = []string{"Message"}
func (ec *executionContext) _Message(ctx context.Context, sel ast.SelectionSet, obj *models.Message) graphql.Marshaler { func (ec *executionContext) _Message(ctx context.Context, sel ast.SelectionSet, obj *models.Message) graphql.Marshaler {
@@ -6030,6 +6366,25 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
} }
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "giteaFeed":
field := field
innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) {
defer func() {
if r := recover(); r != nil {
ec.Error(ctx, ec.Recover(ctx, r))
}
}()
res = ec._Query_giteaFeed(ctx, field)
return res
}
rrm := func(ctx context.Context) graphql.Marshaler {
return ec.OperationContext.RootResolverMiddleware(ctx,
func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "me": case "me":
field := field field := field
@@ -7505,6 +7860,13 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast
return res return res
} }
func (ec *executionContext) marshalOGiteaFeedItem2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐGiteaFeedItem(ctx context.Context, sel ast.SelectionSet, v *model.GiteaFeedItem) graphql.Marshaler {
if v == nil {
return graphql.Null
}
return ec._GiteaFeedItem(ctx, sel, v)
}
func (ec *executionContext) marshalOPost2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋmodelsᚐPost(ctx context.Context, sel ast.SelectionSet, v *models.Post) graphql.Marshaler { func (ec *executionContext) marshalOPost2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋmodelsᚐPost(ctx context.Context, sel ast.SelectionSet, v *models.Post) graphql.Marshaler {
if v == nil { if v == nil {
return graphql.Null return graphql.Null

View File

@@ -0,0 +1,17 @@
package graph
import (
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/services"
)
func mapGiteaFeed(feed *services.GiteaFeedResponse) *model.GiteaFeedItem {
return &model.GiteaFeedItem{
AvatarURL: feed.ActUser.AvatarURL,
RepoURL: feed.Repo.HTMLURL,
RepoName: feed.Repo.FullName,
OpType: feed.OpType,
CommitMessage: services.ParseCommitMessage(feed.Content),
CreatedAt: feed.Created,
}
}

View File

@@ -34,6 +34,15 @@ type CreateUserInput struct {
Password string `json:"password"` Password string `json:"password"`
} }
type GiteaFeedItem struct {
AvatarURL string `json:"avatarUrl"`
RepoURL string `json:"repoUrl"`
RepoName string `json:"repoName"`
OpType string `json:"opType"`
CommitMessage string `json:"commitMessage"`
CreatedAt time.Time `json:"createdAt"`
}
type LoginInput struct { type LoginInput struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`

View File

@@ -13,6 +13,7 @@ import (
"adam-french.co.uk/backend/graph/model" "adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
"adam-french.co.uk/backend/services"
spotify "github.com/zmb3/spotify/v2" spotify "github.com/zmb3/spotify/v2"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -429,6 +430,26 @@ func (r *queryResolver) SpotifyRecent(ctx context.Context) ([]*model.SpotifyRece
return mapRecentItems(played), nil return mapRecentItems(played), nil
} }
// GiteaFeed is the resolver for the giteaFeed field.
func (r *queryResolver) GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error) {
if r.Store.GiteaFeedFresh() {
return mapGiteaFeed(r.Store.GiteaFeed), nil
}
feed, err := services.FetchLatestFeed(r.Store.GiteaHost, r.Store.GiteaPort)
if err != nil {
return nil, err
}
if feed == nil {
return nil, nil
}
r.Store.GiteaFeed = feed
r.Store.GiteaFeedFetchedAt = time.Now()
return mapGiteaFeed(feed), nil
}
// Me is the resolver for the me field. // Me is the resolver for the me field.
func (r *queryResolver) Me(ctx context.Context) (*models.User, error) { func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
userID, ok := UserIDFromCtx(ctx) userID, ok := UserIDFromCtx(ctx)
@@ -453,4 +474,3 @@ func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
type mutationResolver struct{ *Resolver } type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver } type queryResolver struct{ *Resolver }

View File

@@ -0,0 +1,8 @@
type GiteaFeedItem {
avatarUrl: String!
repoUrl: String!
repoName: String!
opType: String!
commitMessage: String!
createdAt: Time!
}

View File

@@ -11,6 +11,7 @@ type Query {
messages: [Message!]! messages: [Message!]!
spotifyListening: SpotifyPlaying spotifyListening: SpotifyPlaying
spotifyRecent: [SpotifyRecentItem!]! spotifyRecent: [SpotifyRecentItem!]!
giteaFeed: GiteaFeedItem
me: User me: User
} }

View File

@@ -20,4 +20,16 @@ type Store struct {
RecentSongs *[]spotify.RecentlyPlayedItem RecentSongs *[]spotify.RecentlyPlayedItem
RecentSongsFetchedAt time.Time RecentSongsFetchedAt time.Time
GiteaHost string
GiteaPort string
GiteaFeed *services.GiteaFeedResponse
GiteaFeedFetchedAt time.Time
}
func (s *Store) GiteaFeedFresh() bool {
if s.GiteaFeed == nil {
return false
}
return time.Since(s.GiteaFeedFetchedAt) < time.Minute
} }

View File

@@ -71,7 +71,10 @@ func main() {
notesConfig := services.NotesConfig{Dir: notesDir} notesConfig := services.NotesConfig{Dir: notesDir}
notes := services.InitNotes(&notesConfig) notes := services.InitNotes(&notesConfig)
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes} giteaHost := os.Getenv("GITEA_HOST")
giteaPort := os.Getenv("GITEA_PORT")
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes, GiteaHost: giteaHost, GiteaPort: giteaPort}
protected := r.Group("/", store.AuthMiddlewear) protected := r.Group("/", store.AuthMiddlewear)
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware) admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)

72
backend/services/gitea.go Normal file
View File

@@ -0,0 +1,72 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type GiteaFeedCommit struct {
Message string `json:"Message"`
}
type GiteaFeedContent struct {
Commits []GiteaFeedCommit `json:"Commits"`
}
type GiteaFeedUser struct {
AvatarURL string `json:"avatar_url"`
}
type GiteaFeedRepo struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
}
type GiteaFeedResponse struct {
ActUser GiteaFeedUser `json:"act_user"`
Repo GiteaFeedRepo `json:"repo"`
OpType string `json:"op_type"`
Content string `json:"content"`
Created time.Time `json:"created"`
}
func FetchLatestFeed(host, port string) (*GiteaFeedResponse, error) {
url := fmt.Sprintf("http://%s:%s/api/v1/users/adamf/activities/feeds?limit=1", host, port)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var items []GiteaFeedResponse
if err := json.Unmarshal(body, &items); err != nil {
return nil, err
}
if len(items) == 0 {
return nil, nil
}
return &items[0], nil
}
func ParseCommitMessage(content string) string {
var c GiteaFeedContent
if err := json.Unmarshal([]byte(content), &c); err != nil {
return ""
}
if len(c.Commits) == 0 {
return ""
}
return c.Commits[0].Message
}

View File

@@ -1,59 +1,36 @@
<script setup> <script setup>
import axios from "axios"; import { useHomeDataStore } from "@/stores/homeData";
import { ref, onMounted } from "vue"; import { storeToRefs } from "pinia";
import Header from "@/components/text/Header.vue"; import Header from "@/components/text/Header.vue";
const url = "/gitea/api/v1/users/adamf/activities/feeds?limit=1"; const homeData = useHomeDataStore();
const { gitFeed: feed, loaded } = storeToRefs(homeData);
const feed = ref(null);
const isLoading = ref(true);
const hasError = ref(false);
async function checkFeed() {
try {
const res = await axios.get(url);
feed.value = res.data[0] || null;
hasError.value = false;
} catch (err) {
hasError.value = true;
} finally {
isLoading.value = false;
}
}
onMounted(() => {
checkFeed();
});
</script> </script>
<template> <template>
<div class="flex flex-col text-center h-full"> <div class="flex flex-col text-center h-full">
<Header class="text-left">Commits</Header> <Header class="text-left">Commits</Header>
<div v-if="isLoading" class="flex-1 overflow-y-auto"> <div v-if="!loaded" class="flex-1 overflow-y-auto">
<p>Loading latest activity...</p> <p>Loading latest activity...</p>
</div> </div>
<div v-else-if="hasError" class="flex-1 overflow-y-auto">
<p>Could not fetch feed. Please try again later.</p>
</div>
<div <div
v-else-if="feed" v-else-if="feed"
class="flex-1 flex flex-col justify-center overflow-y-auto" 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.avatarUrl"
alt="User avatar" alt="User avatar"
class="avatar" class="avatar"
/> />
<a :href="feed.repo.html_url"> <a :href="feed.repoUrl">
<h3>repo: {{ feed.repo.full_name }}</h3> <h3>repo: {{ feed.repoName }}</h3>
</a> </a>
<p>Action: {{ feed.op_type }}</p> <p>Action: {{ feed.opType }}</p>
<p>Message: {{ JSON.parse(feed.content).Commits[0].Message }}</p> <p>Message: {{ feed.commitMessage }}</p>
<small> {{ new Date(feed.created).toLocaleString() }}</small> <small> {{ new Date(feed.createdAt).toLocaleString() }}</small>
</div> </div>
<div v-else class="flex-1 overflow-y-auto"> <div v-else class="flex-1 overflow-y-auto">

View File

@@ -26,10 +26,10 @@ export const useHomeDataStore = defineStore("homeData", () => {
activities { id type name link createdAt } activities { id type name link createdAt }
spotifyRecent { track { name album { name images { url } } artists { name } } playedAt } spotifyRecent { track { name album { name images { url } } artists { name } } playedAt }
rowingSessions { id date time distance timePer500m calories } rowingSessions { id date time distance timePer500m calories }
giteaFeed { avatarUrl repoUrl repoName opType commitMessage createdAt }
me { id username admin } me { id username admin }
} }
`), `),
fetchGitFeed(),
fetchRadioStatus(), fetchRadioStatus(),
]); ]);
posts.value = data.posts; posts.value = data.posts;
@@ -37,6 +37,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
activities.value = data.activities; activities.value = data.activities;
spotifyRecent.value = data.spotifyRecent; spotifyRecent.value = data.spotifyRecent;
rowingSessions.value = data.rowingSessions; rowingSessions.value = data.rowingSessions;
gitFeed.value = data.giteaFeed || null;
me.value = data.me || null; me.value = data.me || null;
loaded.value = true; loaded.value = true;
} catch (err) { } catch (err) {
@@ -45,15 +46,6 @@ export const useHomeDataStore = defineStore("homeData", () => {
} }
} }
async function fetchGitFeed() {
try {
const res = await axios.get("/gitea/api/v1/users/adamf/activities/feeds?limit=1");
gitFeed.value = res.data[0] || null;
} catch {
gitFeed.value = null;
}
}
async function fetchRadioStatus() { async function fetchRadioStatus() {
try { try {
await axios.head("/radio/stream"); await axios.head("/radio/stream");