From 66f32cdbd2f6ae3281ef7a13d043041fcfdec053 Mon Sep 17 00:00:00 2001 From: Adam French Date: Mon, 13 Apr 2026 12:04:13 +0100 Subject: [PATCH] Add database-backed bookmarks via GraphQL Replace hardcoded bookmarks in the frontend with a GORM-backed Bookmark model exposed through GraphQL query and admin-only create/delete mutations. Frontend groups bookmarks by category dynamically from the store. Co-Authored-By: Claude Sonnet 4.6 --- backend/gqlgen.yml | 6 + backend/graph/bookmark.resolvers.go | 22 + backend/graph/generated.go | 647 ++++++++++++++++++++- backend/graph/model/models_gen.go | 6 + backend/graph/schema.resolvers.go | 40 ++ backend/graph/schema/bookmark.graphql | 14 + backend/graph/schema/schema.graphql | 3 + backend/models/models.go | 10 + backend/services/database.go | 1 + vue/src/stores/homeData.js | 4 + vue/src/views/home/bookmarks/Bookmarks.vue | 252 +------- 11 files changed, 767 insertions(+), 238 deletions(-) create mode 100644 backend/graph/bookmark.resolvers.go create mode 100644 backend/graph/schema/bookmark.graphql diff --git a/backend/gqlgen.yml b/backend/gqlgen.yml index 1f25230..4307b67 100644 --- a/backend/gqlgen.yml +++ b/backend/gqlgen.yml @@ -57,6 +57,12 @@ models: fields: deletedAt: resolver: false + Bookmark: + model: + - adam-french.co.uk/backend/models.Bookmark + fields: + deletedAt: + resolver: false JobApplication: model: - adam-french.co.uk/backend/models.JobApplication diff --git a/backend/graph/bookmark.resolvers.go b/backend/graph/bookmark.resolvers.go new file mode 100644 index 0000000..153d9a3 --- /dev/null +++ b/backend/graph/bookmark.resolvers.go @@ -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 *bookmarkResolver) ID(ctx context.Context, obj *models.Bookmark) (int, error) { + return int(obj.ID), nil +} + +// Bookmark returns BookmarkResolver implementation. +func (r *Resolver) Bookmark() BookmarkResolver { return &bookmarkResolver{r} } + +type bookmarkResolver struct{ *Resolver } diff --git a/backend/graph/generated.go b/backend/graph/generated.go index a25adfa..8255da8 100644 --- a/backend/graph/generated.go +++ b/backend/graph/generated.go @@ -31,6 +31,7 @@ type Config = graphql.Config[ResolverRoot, DirectiveRoot, ComplexityRoot] type ResolverRoot interface { Activity() ActivityResolver + Bookmark() BookmarkResolver Favorite() FavoriteResolver JobApplication() JobApplicationResolver Message() MessageResolver @@ -58,6 +59,15 @@ type ComplexityRoot struct { User func(childComplexity int) int } + Bookmark struct { + Category func(childComplexity int) int + CreatedAt func(childComplexity int) int + ID func(childComplexity int) int + Link func(childComplexity int) int + Name func(childComplexity int) int + UpdatedAt func(childComplexity int) int + } + Favorite struct { CreatedAt func(childComplexity int) int ID func(childComplexity int) int @@ -99,10 +109,12 @@ type ComplexityRoot struct { Mutation struct { CreateActivity func(childComplexity int, input model.CreateActivityInput) int + CreateBookmark func(childComplexity int, input model.CreateBookmarkInput) int CreateFavorite func(childComplexity int, input model.CreateFavoriteInput) int CreateJobApplication func(childComplexity int, input model.CreateJobApplicationInput) int CreatePost func(childComplexity int, input model.CreatePostInput) int CreateUser func(childComplexity int, input model.CreateUserInput) int + DeleteBookmark func(childComplexity int, id int) int DeleteJobApplication func(childComplexity int, id int) int DeletePost func(childComplexity int, id int) int DeleteUser func(childComplexity int, id int) int @@ -125,6 +137,7 @@ type ComplexityRoot struct { Query struct { Activities func(childComplexity int) int + Bookmarks func(childComplexity int) int Favorites func(childComplexity int) int GiteaFeed func(childComplexity int) int JobApplication func(childComplexity int, id int) int @@ -205,6 +218,9 @@ type ComplexityRoot struct { type ActivityResolver interface { ID(ctx context.Context, obj *models.Activity) (int, error) } +type BookmarkResolver interface { + ID(ctx context.Context, obj *models.Bookmark) (int, error) +} type FavoriteResolver interface { ID(ctx context.Context, obj *models.Favorite) (int, error) } @@ -230,6 +246,8 @@ type MutationResolver interface { CreateActivity(ctx context.Context, input model.CreateActivityInput) (*models.Activity, error) CreateJobApplication(ctx context.Context, input model.CreateJobApplicationInput) (*models.JobApplication, error) UpdateJobApplication(ctx context.Context, id int, input model.UpdateJobApplicationInput) (*models.JobApplication, error) + CreateBookmark(ctx context.Context, input model.CreateBookmarkInput) (*models.Bookmark, error) + DeleteBookmark(ctx context.Context, id int) (*models.Bookmark, error) DeleteJobApplication(ctx context.Context, id int) (bool, error) } type PostResolver interface { @@ -249,6 +267,7 @@ type QueryResolver interface { GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error) SteamStatus(ctx context.Context) (*model.SteamStatus, error) Me(ctx context.Context) (*models.User, error) + Bookmarks(ctx context.Context) ([]*models.Bookmark, error) JobApplications(ctx context.Context) ([]*models.JobApplication, error) JobApplication(ctx context.Context, id int) (*models.JobApplication, error) } @@ -320,6 +339,43 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.AuthPayload.User(childComplexity), true + case "Bookmark.category": + if e.ComplexityRoot.Bookmark.Category == nil { + break + } + + return e.ComplexityRoot.Bookmark.Category(childComplexity), true + case "Bookmark.createdAt": + if e.ComplexityRoot.Bookmark.CreatedAt == nil { + break + } + + return e.ComplexityRoot.Bookmark.CreatedAt(childComplexity), true + case "Bookmark.id": + if e.ComplexityRoot.Bookmark.ID == nil { + break + } + + return e.ComplexityRoot.Bookmark.ID(childComplexity), true + case "Bookmark.link": + if e.ComplexityRoot.Bookmark.Link == nil { + break + } + + return e.ComplexityRoot.Bookmark.Link(childComplexity), true + case "Bookmark.name": + if e.ComplexityRoot.Bookmark.Name == nil { + break + } + + return e.ComplexityRoot.Bookmark.Name(childComplexity), true + case "Bookmark.updatedAt": + if e.ComplexityRoot.Bookmark.UpdatedAt == nil { + break + } + + return e.ComplexityRoot.Bookmark.UpdatedAt(childComplexity), true + case "Favorite.createdAt": if e.ComplexityRoot.Favorite.CreatedAt == nil { break @@ -497,6 +553,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Mutation.CreateActivity(childComplexity, args["input"].(model.CreateActivityInput)), true + case "Mutation.createBookmark": + if e.ComplexityRoot.Mutation.CreateBookmark == nil { + break + } + + args, err := ec.field_Mutation_createBookmark_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.CreateBookmark(childComplexity, args["input"].(model.CreateBookmarkInput)), true case "Mutation.createFavorite": if e.ComplexityRoot.Mutation.CreateFavorite == nil { break @@ -541,6 +608,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Mutation.CreateUser(childComplexity, args["input"].(model.CreateUserInput)), true + case "Mutation.deleteBookmark": + if e.ComplexityRoot.Mutation.DeleteBookmark == nil { + break + } + + args, err := ec.field_Mutation_deleteBookmark_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.ComplexityRoot.Mutation.DeleteBookmark(childComplexity, args["id"].(int)), true case "Mutation.deleteJobApplication": if e.ComplexityRoot.Mutation.DeleteJobApplication == nil { break @@ -674,6 +752,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Query.Activities(childComplexity), true + case "Query.bookmarks": + if e.ComplexityRoot.Query.Bookmarks == nil { + break + } + + return e.ComplexityRoot.Query.Bookmarks(childComplexity), true case "Query.favorites": if e.ComplexityRoot.Query.Favorites == nil { break @@ -974,6 +1058,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec := newExecutionContext(opCtx, e, make(chan graphql.DeferredResult)) inputUnmarshalMap := graphql.BuildUnmarshalerMap( ec.unmarshalInputCreateActivityInput, + ec.unmarshalInputCreateBookmarkInput, ec.unmarshalInputCreateFavoriteInput, ec.unmarshalInputCreateJobApplicationInput, ec.unmarshalInputCreatePostInput, @@ -1055,7 +1140,7 @@ func newExecutionContext( } } -//go:embed "schema/activity.graphql" "schema/auth.graphql" "schema/favorite.graphql" "schema/gitea.graphql" "schema/job_application.graphql" "schema/message.graphql" "schema/post.graphql" "schema/rowing.graphql" "schema/schema.graphql" "schema/spotify.graphql" "schema/steam.graphql" "schema/user.graphql" +//go:embed "schema/activity.graphql" "schema/auth.graphql" "schema/bookmark.graphql" "schema/favorite.graphql" "schema/gitea.graphql" "schema/job_application.graphql" "schema/message.graphql" "schema/post.graphql" "schema/rowing.graphql" "schema/schema.graphql" "schema/spotify.graphql" "schema/steam.graphql" "schema/user.graphql" var sourcesFS embed.FS func sourceData(filename string) string { @@ -1069,6 +1154,7 @@ func sourceData(filename string) string { var sources = []*ast.Source{ {Name: "schema/activity.graphql", Input: sourceData("schema/activity.graphql"), BuiltIn: false}, {Name: "schema/auth.graphql", Input: sourceData("schema/auth.graphql"), BuiltIn: false}, + {Name: "schema/bookmark.graphql", Input: sourceData("schema/bookmark.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/job_application.graphql", Input: sourceData("schema/job_application.graphql"), BuiltIn: false}, @@ -1097,6 +1183,17 @@ func (ec *executionContext) field_Mutation_createActivity_args(ctx context.Conte return args, nil } +func (ec *executionContext) field_Mutation_createBookmark_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNCreateBookmarkInput2adamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐCreateBookmarkInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_createFavorite_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1141,6 +1238,17 @@ func (ec *executionContext) field_Mutation_createUser_args(ctx context.Context, return args, nil } +func (ec *executionContext) field_Mutation_deleteBookmark_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "id", ec.unmarshalNID2int) + if err != nil { + return nil, err + } + args["id"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_deleteJobApplication_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -1544,6 +1652,180 @@ func (ec *executionContext) fieldContext_AuthPayload_user(_ context.Context, fie return fc, nil } +func (ec *executionContext) _Bookmark_id(ctx context.Context, field graphql.CollectedField, obj *models.Bookmark) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Bookmark_id, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Bookmark().ID(ctx, obj) + }, + nil, + ec.marshalNID2int, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Bookmark_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Bookmark", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _Bookmark_createdAt(ctx context.Context, field graphql.CollectedField, obj *models.Bookmark) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Bookmark_createdAt, + func(ctx context.Context) (any, error) { + return obj.CreatedAt, nil + }, + nil, + ec.marshalNTime2timeᚐTime, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Bookmark_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Bookmark", + 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) _Bookmark_updatedAt(ctx context.Context, field graphql.CollectedField, obj *models.Bookmark) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Bookmark_updatedAt, + func(ctx context.Context) (any, error) { + return obj.UpdatedAt, nil + }, + nil, + ec.marshalNTime2timeᚐTime, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Bookmark_updatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Bookmark", + 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) _Bookmark_category(ctx context.Context, field graphql.CollectedField, obj *models.Bookmark) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Bookmark_category, + func(ctx context.Context) (any, error) { + return obj.Category, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Bookmark_category(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Bookmark", + 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) _Bookmark_name(ctx context.Context, field graphql.CollectedField, obj *models.Bookmark) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Bookmark_name, + func(ctx context.Context) (any, error) { + return obj.Name, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Bookmark_name(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Bookmark", + 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) _Bookmark_link(ctx context.Context, field graphql.CollectedField, obj *models.Bookmark) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Bookmark_link, + func(ctx context.Context) (any, error) { + return obj.Link, nil + }, + nil, + ec.marshalNString2string, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Bookmark_link(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Bookmark", + 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) _Favorite_id(ctx context.Context, field graphql.CollectedField, obj *models.Favorite) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -2994,6 +3276,116 @@ func (ec *executionContext) fieldContext_Mutation_updateJobApplication(ctx conte return fc, nil } +func (ec *executionContext) _Mutation_createBookmark(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_createBookmark, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().CreateBookmark(ctx, fc.Args["input"].(model.CreateBookmarkInput)) + }, + nil, + ec.marshalNBookmark2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋmodelsᚐBookmark, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_createBookmark(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Bookmark_id(ctx, field) + case "createdAt": + return ec.fieldContext_Bookmark_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Bookmark_updatedAt(ctx, field) + case "category": + return ec.fieldContext_Bookmark_category(ctx, field) + case "name": + return ec.fieldContext_Bookmark_name(ctx, field) + case "link": + return ec.fieldContext_Bookmark_link(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Bookmark", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_createBookmark_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_deleteBookmark(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_deleteBookmark, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.Resolvers.Mutation().DeleteBookmark(ctx, fc.Args["id"].(int)) + }, + nil, + ec.marshalNBookmark2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋmodelsᚐBookmark, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Mutation_deleteBookmark(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Bookmark_id(ctx, field) + case "createdAt": + return ec.fieldContext_Bookmark_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Bookmark_updatedAt(ctx, field) + case "category": + return ec.fieldContext_Bookmark_category(ctx, field) + case "name": + return ec.fieldContext_Bookmark_name(ctx, field) + case "link": + return ec.fieldContext_Bookmark_link(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Bookmark", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_deleteBookmark_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_deleteJobApplication(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -3774,6 +4166,49 @@ func (ec *executionContext) fieldContext_Query_me(_ context.Context, field graph return fc, nil } +func (ec *executionContext) _Query_bookmarks(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Query_bookmarks, + func(ctx context.Context) (any, error) { + return ec.Resolvers.Query().Bookmarks(ctx) + }, + nil, + ec.marshalNBookmark2ᚕᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋmodelsᚐBookmarkᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_Query_bookmarks(_ 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 "id": + return ec.fieldContext_Bookmark_id(ctx, field) + case "createdAt": + return ec.fieldContext_Bookmark_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_Bookmark_updatedAt(ctx, field) + case "category": + return ec.fieldContext_Bookmark_category(ctx, field) + case "name": + return ec.fieldContext_Bookmark_name(ctx, field) + case "link": + return ec.fieldContext_Bookmark_link(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Bookmark", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Query_jobApplications(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -6398,6 +6833,50 @@ func (ec *executionContext) unmarshalInputCreateActivityInput(ctx context.Contex return it, nil } +func (ec *executionContext) unmarshalInputCreateBookmarkInput(ctx context.Context, obj any) (model.CreateBookmarkInput, error) { + var it model.CreateBookmarkInput + if obj == nil { + return it, nil + } + + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"category", "name", "link"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "category": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("category")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Category = data + case "name": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("name")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Name = data + case "link": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("link")) + data, err := ec.unmarshalNString2string(ctx, v) + if err != nil { + return it, err + } + it.Link = data + } + } + return it, nil +} + func (ec *executionContext) unmarshalInputCreateFavoriteInput(ctx context.Context, obj any) (model.CreateFavoriteInput, error) { var it model.CreateFavoriteInput if obj == nil { @@ -6873,6 +7352,101 @@ func (ec *executionContext) _AuthPayload(ctx context.Context, sel ast.SelectionS return out } +var bookmarkImplementors = []string{"Bookmark"} + +func (ec *executionContext) _Bookmark(ctx context.Context, sel ast.SelectionSet, obj *models.Bookmark) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, bookmarkImplementors) + + 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("Bookmark") + case "id": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Bookmark_id(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "createdAt": + out.Values[i] = ec._Bookmark_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "updatedAt": + out.Values[i] = ec._Bookmark_updatedAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "category": + out.Values[i] = ec._Bookmark_category(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "name": + out.Values[i] = ec._Bookmark_name(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "link": + out.Values[i] = ec._Bookmark_link(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + 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 favoriteImplementors = []string{"Favorite"} func (ec *executionContext) _Favorite(ctx context.Context, sel ast.SelectionSet, obj *models.Favorite) graphql.Marshaler { @@ -7360,6 +7934,20 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "createBookmark": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createBookmark(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "deleteBookmark": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_deleteBookmark(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } case "deleteJobApplication": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_deleteJobApplication(ctx, field) @@ -7765,6 +8353,28 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) + case "bookmarks": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._Query_bookmarks(ctx, field) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + 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) }) case "jobApplications": field := field @@ -8828,6 +9438,36 @@ func (ec *executionContext) marshalNAuthPayload2ᚖadamᚑfrenchᚗcoᚗukᚋbac return ec._AuthPayload(ctx, sel, v) } +func (ec *executionContext) marshalNBookmark2adamᚑfrenchᚗcoᚗukᚋbackendᚋmodelsᚐBookmark(ctx context.Context, sel ast.SelectionSet, v models.Bookmark) graphql.Marshaler { + return ec._Bookmark(ctx, sel, &v) +} + +func (ec *executionContext) marshalNBookmark2ᚕᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋmodelsᚐBookmarkᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.Bookmark) graphql.Marshaler { + ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { + fc := graphql.GetFieldContext(ctx) + fc.Result = &v[i] + return ec.marshalNBookmark2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋmodelsᚐBookmark(ctx, sel, v[i]) + }) + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNBookmark2ᚖadamᚑfrenchᚗcoᚗukᚋbackendᚋmodelsᚐBookmark(ctx context.Context, sel ast.SelectionSet, v *models.Bookmark) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._Bookmark(ctx, sel, v) +} + func (ec *executionContext) unmarshalNBoolean2bool(ctx context.Context, v any) (bool, error) { res, err := graphql.UnmarshalBoolean(v) return res, graphql.ErrorOnPath(ctx, err) @@ -8849,6 +9489,11 @@ func (ec *executionContext) unmarshalNCreateActivityInput2adamᚑfrenchᚗcoᚗu return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalNCreateBookmarkInput2adamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐCreateBookmarkInput(ctx context.Context, v any) (model.CreateBookmarkInput, error) { + res, err := ec.unmarshalInputCreateBookmarkInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNCreateFavoriteInput2adamᚑfrenchᚗcoᚗukᚋbackendᚋgraphᚋmodelᚐCreateFavoriteInput(ctx context.Context, v any) (model.CreateFavoriteInput, error) { res, err := ec.unmarshalInputCreateFavoriteInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/backend/graph/model/models_gen.go b/backend/graph/model/models_gen.go index 9f84e68..d3957aa 100644 --- a/backend/graph/model/models_gen.go +++ b/backend/graph/model/models_gen.go @@ -18,6 +18,12 @@ type CreateActivityInput struct { Link *string `json:"link,omitempty"` } +type CreateBookmarkInput struct { + Category string `json:"category"` + Name string `json:"name"` + Link string `json:"link"` +} + type CreateFavoriteInput struct { Type string `json:"type"` Name string `json:"name"` diff --git a/backend/graph/schema.resolvers.go b/backend/graph/schema.resolvers.go index cff47d2..b776416 100644 --- a/backend/graph/schema.resolvers.go +++ b/backend/graph/schema.resolvers.go @@ -349,6 +349,33 @@ func (r *mutationResolver) UpdateJobApplication(ctx context.Context, id int, inp return &app, nil } +// CreateBookmark is the resolver for the createBookmark field. +func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.CreateBookmarkInput) (*models.Bookmark, error) { + if !IsAdminFromCtx(ctx) { + return nil, fmt.Errorf("admin access required") + } + bookmark := models.Bookmark{Category: input.Category, Name: input.Name, Link: input.Link} + if err := r.Store.DB.Create(&bookmark).Error; err != nil { + return nil, err + } + return &bookmark, nil +} + +// DeleteBookmark is the resolver for the deleteBookmark field. +func (r *mutationResolver) DeleteBookmark(ctx context.Context, id int) (*models.Bookmark, error) { + if !IsAdminFromCtx(ctx) { + return nil, fmt.Errorf("admin access required") + } + var bookmark models.Bookmark + if err := r.Store.DB.First(&bookmark, id).Error; err != nil { + return nil, err + } + if err := r.Store.DB.Delete(&bookmark).Error; err != nil { + return nil, err + } + return &bookmark, nil +} + // DeleteJobApplication is the resolver for the deleteJobApplication field. func (r *mutationResolver) DeleteJobApplication(ctx context.Context, id int) (bool, error) { if !IsAdminFromCtx(ctx) { @@ -571,6 +598,19 @@ func (r *queryResolver) Me(ctx context.Context) (*models.User, error) { return &user, nil } +// Bookmarks is the resolver for the bookmarks field. +func (r *queryResolver) Bookmarks(ctx context.Context) ([]*models.Bookmark, error) { + var bookmarks []models.Bookmark + if err := r.Store.DB.Order("category ASC, created_at ASC").Find(&bookmarks).Error; err != nil { + return nil, err + } + result := make([]*models.Bookmark, len(bookmarks)) + for i := range bookmarks { + result[i] = &bookmarks[i] + } + return result, nil +} + // JobApplications is the resolver for the jobApplications field. func (r *queryResolver) JobApplications(ctx context.Context) ([]*models.JobApplication, error) { if !IsAdminFromCtx(ctx) { diff --git a/backend/graph/schema/bookmark.graphql b/backend/graph/schema/bookmark.graphql new file mode 100644 index 0000000..614b31e --- /dev/null +++ b/backend/graph/schema/bookmark.graphql @@ -0,0 +1,14 @@ +type Bookmark { + id: ID! + createdAt: Time! + updatedAt: Time! + category: String! + name: String! + link: String! +} + +input CreateBookmarkInput { + category: String! + name: String! + link: String! +} diff --git a/backend/graph/schema/schema.graphql b/backend/graph/schema/schema.graphql index 8f2d8e1..a06d126 100644 --- a/backend/graph/schema/schema.graphql +++ b/backend/graph/schema/schema.graphql @@ -14,6 +14,7 @@ type Query { giteaFeed: GiteaFeedItem steamStatus: SteamStatus me: User + bookmarks: [Bookmark!]! jobApplications: [JobApplication!]! jobApplication(id: ID!): JobApplication } @@ -32,5 +33,7 @@ type Mutation { createActivity(input: CreateActivityInput!): Activity! createJobApplication(input: CreateJobApplicationInput!): JobApplication! updateJobApplication(id: ID!, input: UpdateJobApplicationInput!): JobApplication! + createBookmark(input: CreateBookmarkInput!): Bookmark! + deleteBookmark(id: ID!): Bookmark! deleteJobApplication(id: ID!): Boolean! } diff --git a/backend/models/models.go b/backend/models/models.go index 1d80c5e..bdbce93 100644 --- a/backend/models/models.go +++ b/backend/models/models.go @@ -67,6 +67,16 @@ type Rowing struct { Calories float64 `json:"calories"` } +type Bookmark struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"` + Category string `gorm:"not null" json:"category"` + Name string `gorm:"not null" json:"name"` + Link string `gorm:"not null" json:"link"` +} + type JobApplication struct { ID uint `gorm:"primarykey" json:"id"` CreatedAt time.Time `json:"createdAt"` diff --git a/backend/services/database.go b/backend/services/database.go index 76fa187..56a0c5d 100644 --- a/backend/services/database.go +++ b/backend/services/database.go @@ -39,6 +39,7 @@ func migrateDatabase(db *gorm.DB) error { &models.Rowing{}, &models.Message{}, &models.JobApplication{}, + &models.Bookmark{}, ) if err != nil { return err diff --git a/vue/src/stores/homeData.js b/vue/src/stores/homeData.js index 593aac8..5d723ed 100644 --- a/vue/src/stores/homeData.js +++ b/vue/src/stores/homeData.js @@ -15,6 +15,7 @@ export const useHomeDataStore = defineStore("homeData", () => { const rowingSessions = ref([]); const gitFeed = ref(null); const steamStatus = ref(null); + const bookmarks = ref([]); const radioLive = ref(false); async function fetchAll() { @@ -27,6 +28,7 @@ export const useHomeDataStore = defineStore("homeData", () => { activities { id type name link createdAt } spotifyRecent { track { name album { name images { url } } artists { name } } playedAt } rowingSessions { id date time distance timePer500m calories } + bookmarks { id category name link } giteaFeed { avatarUrl repoUrl repoName opType commitMessage createdAt } steamStatus { online recentGames { appId name playtime2Weeks playtimeForever headerImageUrl } } me { id username admin } @@ -38,6 +40,7 @@ export const useHomeDataStore = defineStore("homeData", () => { favorites.value = data.favorites; activities.value = data.activities; spotifyRecent.value = data.spotifyRecent || []; + bookmarks.value = data.bookmarks || []; rowingSessions.value = data.rowingSessions; gitFeed.value = data.giteaFeed || null; steamStatus.value = data.steamStatus || null; @@ -64,6 +67,7 @@ export const useHomeDataStore = defineStore("homeData", () => { loaded, error, me, + bookmarks, posts, favorites, activities, diff --git a/vue/src/views/home/bookmarks/Bookmarks.vue b/vue/src/views/home/bookmarks/Bookmarks.vue index 236e3e1..dfbc019 100644 --- a/vue/src/views/home/bookmarks/Bookmarks.vue +++ b/vue/src/views/home/bookmarks/Bookmarks.vue @@ -1,240 +1,18 @@