Add database-backed bookmarks via GraphQL
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m47s

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 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 12:04:13 +01:00
parent 390f69858c
commit 66f32cdbd2
11 changed files with 767 additions and 238 deletions

View File

@@ -57,6 +57,12 @@ models:
fields: fields:
deletedAt: deletedAt:
resolver: false resolver: false
Bookmark:
model:
- adam-french.co.uk/backend/models.Bookmark
fields:
deletedAt:
resolver: false
JobApplication: JobApplication:
model: model:
- adam-french.co.uk/backend/models.JobApplication - adam-french.co.uk/backend/models.JobApplication

View 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 *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 }

View File

@@ -31,6 +31,7 @@ type Config = graphql.Config[ResolverRoot, DirectiveRoot, ComplexityRoot]
type ResolverRoot interface { type ResolverRoot interface {
Activity() ActivityResolver Activity() ActivityResolver
Bookmark() BookmarkResolver
Favorite() FavoriteResolver Favorite() FavoriteResolver
JobApplication() JobApplicationResolver JobApplication() JobApplicationResolver
Message() MessageResolver Message() MessageResolver
@@ -58,6 +59,15 @@ type ComplexityRoot struct {
User func(childComplexity int) int 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 { Favorite struct {
CreatedAt func(childComplexity int) int CreatedAt func(childComplexity int) int
ID func(childComplexity int) int ID func(childComplexity int) int
@@ -99,10 +109,12 @@ type ComplexityRoot struct {
Mutation struct { Mutation struct {
CreateActivity func(childComplexity int, input model.CreateActivityInput) int CreateActivity func(childComplexity int, input model.CreateActivityInput) int
CreateBookmark func(childComplexity int, input model.CreateBookmarkInput) int
CreateFavorite func(childComplexity int, input model.CreateFavoriteInput) int CreateFavorite func(childComplexity int, input model.CreateFavoriteInput) int
CreateJobApplication func(childComplexity int, input model.CreateJobApplicationInput) int CreateJobApplication func(childComplexity int, input model.CreateJobApplicationInput) int
CreatePost func(childComplexity int, input model.CreatePostInput) int CreatePost func(childComplexity int, input model.CreatePostInput) int
CreateUser func(childComplexity int, input model.CreateUserInput) int CreateUser func(childComplexity int, input model.CreateUserInput) int
DeleteBookmark func(childComplexity int, id int) int
DeleteJobApplication func(childComplexity int, id int) int DeleteJobApplication func(childComplexity int, id int) int
DeletePost func(childComplexity int, id int) int DeletePost func(childComplexity int, id int) int
DeleteUser func(childComplexity int, id int) int DeleteUser func(childComplexity int, id int) int
@@ -125,6 +137,7 @@ type ComplexityRoot struct {
Query struct { Query struct {
Activities func(childComplexity int) int Activities func(childComplexity int) int
Bookmarks func(childComplexity int) int
Favorites func(childComplexity int) int Favorites func(childComplexity int) int
GiteaFeed func(childComplexity int) int GiteaFeed func(childComplexity int) int
JobApplication func(childComplexity int, id int) int JobApplication func(childComplexity int, id int) int
@@ -205,6 +218,9 @@ type ComplexityRoot struct {
type ActivityResolver interface { type ActivityResolver interface {
ID(ctx context.Context, obj *models.Activity) (int, error) ID(ctx context.Context, obj *models.Activity) (int, error)
} }
type BookmarkResolver interface {
ID(ctx context.Context, obj *models.Bookmark) (int, error)
}
type FavoriteResolver interface { type FavoriteResolver interface {
ID(ctx context.Context, obj *models.Favorite) (int, error) 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) CreateActivity(ctx context.Context, input model.CreateActivityInput) (*models.Activity, error)
CreateJobApplication(ctx context.Context, input model.CreateJobApplicationInput) (*models.JobApplication, error) CreateJobApplication(ctx context.Context, input model.CreateJobApplicationInput) (*models.JobApplication, error)
UpdateJobApplication(ctx context.Context, id int, input model.UpdateJobApplicationInput) (*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) DeleteJobApplication(ctx context.Context, id int) (bool, error)
} }
type PostResolver interface { type PostResolver interface {
@@ -249,6 +267,7 @@ type QueryResolver interface {
GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error) GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error)
SteamStatus(ctx context.Context) (*model.SteamStatus, error) SteamStatus(ctx context.Context) (*model.SteamStatus, error)
Me(ctx context.Context) (*models.User, error) Me(ctx context.Context) (*models.User, error)
Bookmarks(ctx context.Context) ([]*models.Bookmark, error)
JobApplications(ctx context.Context) ([]*models.JobApplication, error) JobApplications(ctx context.Context) ([]*models.JobApplication, error)
JobApplication(ctx context.Context, id int) (*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 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": case "Favorite.createdAt":
if e.ComplexityRoot.Favorite.CreatedAt == nil { if e.ComplexityRoot.Favorite.CreatedAt == nil {
break 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 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": case "Mutation.createFavorite":
if e.ComplexityRoot.Mutation.CreateFavorite == nil { if e.ComplexityRoot.Mutation.CreateFavorite == nil {
break 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 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": case "Mutation.deleteJobApplication":
if e.ComplexityRoot.Mutation.DeleteJobApplication == nil { if e.ComplexityRoot.Mutation.DeleteJobApplication == nil {
break break
@@ -674,6 +752,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
} }
return e.ComplexityRoot.Query.Activities(childComplexity), true 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": case "Query.favorites":
if e.ComplexityRoot.Query.Favorites == nil { if e.ComplexityRoot.Query.Favorites == nil {
break break
@@ -974,6 +1058,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler {
ec := newExecutionContext(opCtx, e, make(chan graphql.DeferredResult)) ec := newExecutionContext(opCtx, e, make(chan graphql.DeferredResult))
inputUnmarshalMap := graphql.BuildUnmarshalerMap( inputUnmarshalMap := graphql.BuildUnmarshalerMap(
ec.unmarshalInputCreateActivityInput, ec.unmarshalInputCreateActivityInput,
ec.unmarshalInputCreateBookmarkInput,
ec.unmarshalInputCreateFavoriteInput, ec.unmarshalInputCreateFavoriteInput,
ec.unmarshalInputCreateJobApplicationInput, ec.unmarshalInputCreateJobApplicationInput,
ec.unmarshalInputCreatePostInput, 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 var sourcesFS embed.FS
func sourceData(filename string) string { func sourceData(filename string) string {
@@ -1069,6 +1154,7 @@ func sourceData(filename string) string {
var sources = []*ast.Source{ 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/bookmark.graphql", Input: sourceData("schema/bookmark.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/gitea.graphql", Input: sourceData("schema/gitea.graphql"), BuiltIn: false},
{Name: "schema/job_application.graphql", Input: sourceData("schema/job_application.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 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) { func (ec *executionContext) field_Mutation_createFavorite_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error var err error
args := map[string]any{} args := map[string]any{}
@@ -1141,6 +1238,17 @@ func (ec *executionContext) field_Mutation_createUser_args(ctx context.Context,
return args, nil 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) { func (ec *executionContext) field_Mutation_deleteJobApplication_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) {
var err error var err error
args := map[string]any{} args := map[string]any{}
@@ -1544,6 +1652,180 @@ func (ec *executionContext) fieldContext_AuthPayload_user(_ context.Context, fie
return fc, nil 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) { func (ec *executionContext) _Favorite_id(ctx context.Context, field graphql.CollectedField, obj *models.Favorite) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -2994,6 +3276,116 @@ func (ec *executionContext) fieldContext_Mutation_updateJobApplication(ctx conte
return fc, nil 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) { func (ec *executionContext) _Mutation_deleteJobApplication(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -3774,6 +4166,49 @@ func (ec *executionContext) fieldContext_Query_me(_ context.Context, field graph
return fc, nil 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) { func (ec *executionContext) _Query_jobApplications(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -6398,6 +6833,50 @@ func (ec *executionContext) unmarshalInputCreateActivityInput(ctx context.Contex
return it, nil 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) { func (ec *executionContext) unmarshalInputCreateFavoriteInput(ctx context.Context, obj any) (model.CreateFavoriteInput, error) {
var it model.CreateFavoriteInput var it model.CreateFavoriteInput
if obj == nil { if obj == nil {
@@ -6873,6 +7352,101 @@ func (ec *executionContext) _AuthPayload(ctx context.Context, sel ast.SelectionS
return out 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"} var favoriteImplementors = []string{"Favorite"}
func (ec *executionContext) _Favorite(ctx context.Context, sel ast.SelectionSet, obj *models.Favorite) graphql.Marshaler { 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 { if out.Values[i] == graphql.Null {
out.Invalids++ 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": case "deleteJobApplication":
out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) {
return ec._Mutation_deleteJobApplication(ctx, field) 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) }) 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) }) out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) })
case "jobApplications": case "jobApplications":
field := field field := field
@@ -8828,6 +9438,36 @@ func (ec *executionContext) marshalNAuthPayload2ᚖadamᚑfrenchᚗcoᚗukᚋbac
return ec._AuthPayload(ctx, sel, v) 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) { func (ec *executionContext) unmarshalNBoolean2bool(ctx context.Context, v any) (bool, error) {
res, err := graphql.UnmarshalBoolean(v) res, err := graphql.UnmarshalBoolean(v)
return res, graphql.ErrorOnPath(ctx, err) return res, graphql.ErrorOnPath(ctx, err)
@@ -8849,6 +9489,11 @@ func (ec *executionContext) unmarshalNCreateActivityInput2adamᚑfrenchᚗcoᚗu
return res, graphql.ErrorOnPath(ctx, err) 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) { 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) res, err := ec.unmarshalInputCreateFavoriteInput(ctx, v)
return res, graphql.ErrorOnPath(ctx, err) return res, graphql.ErrorOnPath(ctx, err)

View File

@@ -18,6 +18,12 @@ type CreateActivityInput struct {
Link *string `json:"link,omitempty"` Link *string `json:"link,omitempty"`
} }
type CreateBookmarkInput struct {
Category string `json:"category"`
Name string `json:"name"`
Link string `json:"link"`
}
type CreateFavoriteInput struct { type CreateFavoriteInput struct {
Type string `json:"type"` Type string `json:"type"`
Name string `json:"name"` Name string `json:"name"`

View File

@@ -349,6 +349,33 @@ func (r *mutationResolver) UpdateJobApplication(ctx context.Context, id int, inp
return &app, nil 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. // DeleteJobApplication is the resolver for the deleteJobApplication field.
func (r *mutationResolver) DeleteJobApplication(ctx context.Context, id int) (bool, error) { func (r *mutationResolver) DeleteJobApplication(ctx context.Context, id int) (bool, error) {
if !IsAdminFromCtx(ctx) { if !IsAdminFromCtx(ctx) {
@@ -571,6 +598,19 @@ func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
return &user, nil 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. // JobApplications is the resolver for the jobApplications field.
func (r *queryResolver) JobApplications(ctx context.Context) ([]*models.JobApplication, error) { func (r *queryResolver) JobApplications(ctx context.Context) ([]*models.JobApplication, error) {
if !IsAdminFromCtx(ctx) { if !IsAdminFromCtx(ctx) {

View File

@@ -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!
}

View File

@@ -14,6 +14,7 @@ type Query {
giteaFeed: GiteaFeedItem giteaFeed: GiteaFeedItem
steamStatus: SteamStatus steamStatus: SteamStatus
me: User me: User
bookmarks: [Bookmark!]!
jobApplications: [JobApplication!]! jobApplications: [JobApplication!]!
jobApplication(id: ID!): JobApplication jobApplication(id: ID!): JobApplication
} }
@@ -32,5 +33,7 @@ type Mutation {
createActivity(input: CreateActivityInput!): Activity! createActivity(input: CreateActivityInput!): Activity!
createJobApplication(input: CreateJobApplicationInput!): JobApplication! createJobApplication(input: CreateJobApplicationInput!): JobApplication!
updateJobApplication(id: ID!, input: UpdateJobApplicationInput!): JobApplication! updateJobApplication(id: ID!, input: UpdateJobApplicationInput!): JobApplication!
createBookmark(input: CreateBookmarkInput!): Bookmark!
deleteBookmark(id: ID!): Bookmark!
deleteJobApplication(id: ID!): Boolean! deleteJobApplication(id: ID!): Boolean!
} }

View File

@@ -67,6 +67,16 @@ type Rowing struct {
Calories float64 `json:"calories"` 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 { type JobApplication struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`

View File

@@ -39,6 +39,7 @@ func migrateDatabase(db *gorm.DB) error {
&models.Rowing{}, &models.Rowing{},
&models.Message{}, &models.Message{},
&models.JobApplication{}, &models.JobApplication{},
&models.Bookmark{},
) )
if err != nil { if err != nil {
return err return err

View File

@@ -15,6 +15,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
const rowingSessions = ref([]); const rowingSessions = ref([]);
const gitFeed = ref(null); const gitFeed = ref(null);
const steamStatus = ref(null); const steamStatus = ref(null);
const bookmarks = ref([]);
const radioLive = ref(false); const radioLive = ref(false);
async function fetchAll() { async function fetchAll() {
@@ -27,6 +28,7 @@ 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 }
bookmarks { id category name link }
giteaFeed { avatarUrl repoUrl repoName opType commitMessage createdAt } giteaFeed { avatarUrl repoUrl repoName opType commitMessage createdAt }
steamStatus { online recentGames { appId name playtime2Weeks playtimeForever headerImageUrl } } steamStatus { online recentGames { appId name playtime2Weeks playtimeForever headerImageUrl } }
me { id username admin } me { id username admin }
@@ -38,6 +40,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
favorites.value = data.favorites; favorites.value = data.favorites;
activities.value = data.activities; activities.value = data.activities;
spotifyRecent.value = data.spotifyRecent || []; spotifyRecent.value = data.spotifyRecent || [];
bookmarks.value = data.bookmarks || [];
rowingSessions.value = data.rowingSessions; rowingSessions.value = data.rowingSessions;
gitFeed.value = data.giteaFeed || null; gitFeed.value = data.giteaFeed || null;
steamStatus.value = data.steamStatus || null; steamStatus.value = data.steamStatus || null;
@@ -64,6 +67,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
loaded, loaded,
error, error,
me, me,
bookmarks,
posts, posts,
favorites, favorites,
activities, activities,

View File

@@ -1,240 +1,18 @@
<script setup> <script setup>
import { computed } from "vue";
import LinkTable from "@/components/util/LinkTable.vue"; import LinkTable from "@/components/util/LinkTable.vue";
import { useHomeDataStore } from "@/stores/homeData";
const links = [ const homeData = useHomeDataStore();
[
"Reading Links", const groupedBookmarks = computed(() => {
[ const groups = {};
{ for (const b of homeData.bookmarks) {
name: "Substack", if (!groups[b.category]) groups[b.category] = [];
link: "https://substack.com/", groups[b.category].push(b);
}, }
{ return Object.entries(groups);
name: "Medium", });
link: "https://medium.com/",
},
{
name: "4Chan",
link: "https://www.4chan.org/",
},
],
],
[
"Job Links",
[
{
name: "LinkedIn",
link: "https://www.linkedin.com/",
},
{
name: "Jack and Jill",
link: "https://app.jackandjill.ai",
},
{
name: "LinkedIn",
link: "https://www.linkedin.com/",
},
{
name: "Prospects",
link: "https://www.prospects.ac.uk/",
},
{
name: "GOV",
link: "https://findajob.dwp.gov.uk",
},
{
name: "Glassdoor",
link: "https://www.glassdoor.co.uk/",
},
{
name: "Indeed",
link: "https://www.indeed.co.uk/",
},
],
],
[
"Learning Links",
[
{
name: "Leetcode",
link: "https://leetcode.com/",
},
{
name: "ISLP",
link: "https://hastie.su.domains/ISLP/ISLP_website.pdf.download.html",
},
],
],
[
"Social Links",
[
{
name: "Outlook",
link: "https://outlook.live.com/",
},
{
name: "Gmail",
link: "https://mail.google.com/",
},
{
name: "Whatsapp",
link: "https://web.whatsapp.com/",
},
],
],
[
"Radio links",
[
{
name: "Radio Helsinki",
link: "https://www.radiohelsinki.fi/",
},
{
name: "Palanga Street Radio",
link: "https://palanga.live/",
},
{
name: "IDA Radio",
link: "https://idaidaida.net/",
},
{
name: "Tīrkultūra",
link: "https://www.tirkultura.lv/",
},
],
],
[
"Hacking Links",
[
{
name: "pwn.college",
link: "https://pwn.college/",
},
{
name: "OSINT Framework",
link: "https://osintframework.com/",
},
{
name: "OverTheWire",
link: "https://overthewire.org/",
},
{
name: "TryHackMe",
link: "https://tryhackme.com/",
},
],
],
[
"Chinese Links",
[
{
name: "MDBG Chinese Dictionary",
link: "https://www.mdbg.net/chinese/dictionary",
},
{
name: "Stroke Order",
link: "https://www.strokeorder.com/",
},
{
name: "HSK 1 Peking University",
link: "https://youtube.com/playlist?list=PLVWfp7qXLmKVfSUkucXErLncKn-JqgBbK&si=2ytO3inS8-iOAOx2",
},
{
name: "Stroke Order",
link: "https://www.strokeorder.com/",
},
{
name: "Offbeat Mandarin",
link: "https://www.youtube.com/@OffbeatMandarin",
},
],
],
[
"Art links",
[
{
name: "Frida Kahlo",
link: "https://www.fridakahlo.org/",
},
{
name: "Cameron's World",
link: "https://www.cameronsworld.net/",
},
{
name: "Neocities",
link: "https://neocities.org/",
},
],
],
[
"Vue links",
[
{
name: "Vue",
link: "https://vuejs.org/guide/introduction.html",
},
{
name: "Vue Router",
link: "https://router.vuejs.org/introduction.html",
},
{
name: "Pinia",
link: "https://pinia.vuejs.org/introduction.html",
},
],
],
[
"Go links",
[
{
name: "Golang",
link: "https://golang.org/doc/",
},
{
name: "Gin Gonic",
link: "https://gin-gonic.com/en/docs/introduction/",
},
{
name: "GORM",
link: "https://gorm.io/gen/index.html",
},
],
],
[
"Doc links",
[
{
name: "Rust",
link: "https://doc.rust-lang.org/stable/book/index.html",
},
{
name: "Javascript",
link: "https://developer.mozilla.org/en-US/docs/Web/JavaScript",
},
{
name: "Python",
link: "https://docs.python.org/3/",
},
],
],
[
"Article links",
[
{
name: "Go and GORM",
link: "https://medium.com/@chaewonkong/learn-go-understanding-and-implementing-foreign-keys-with-gorm-6d7608e1dbf6",
},
{
name: "JWT Auth in GO",
link: "https://medium.com/monstar-lab-bangladesh-engineering/jwt-auth-in-go-dde432440924",
},
{
name: "Websockets in GO",
link: "https://medium.com/@tanngontn/golang-gin-framework-with-normal-websocket-and-websocket-with-producer-is-rabbitmq-guide-93cad7d290f7",
},
],
],
];
</script> </script>
<template> <template>
@@ -245,9 +23,9 @@ const links = [
<div class="w-full h-fit"> <div class="w-full h-fit">
<LinkTable <LinkTable
class="flex flex-col flex-wrap" class="flex flex-col flex-wrap"
v-for="link in links" v-for="group in groupedBookmarks"
:title="link[0]" :title="group[0]"
:items="link[1]" :items="group[1]"
/> />
</div> </div>
</div> </div>