Peter Sanchez: 1 Adding Tag clouds to the various GraphQL resolvers for use on the website. 15 files changed, 921 insertions(+), 84 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.code.netlandish.com/~netlandish/links-dev/patches/125/mbox | git am -3Learn more about email & git
Updating handlers to request and handle tag clouds. Updating front end to display tag clouds Implements: https://todo.code.netlandish.com/~netlandish/links/95 Changelog-added: Tag clouds to recent, popular, feed, and organization bookmark pages. Changelog-changed: getPopularLinks return type is now different. Had to be changed to include the tag cloud. See updated schema.graphqls --- api/graph/generated.go | 482 +++++++++++++++++++++++++++++++--- api/graph/model/models_gen.go | 131 +++++++-- api/graph/schema.graphqls | 30 ++- api/graph/schema.resolvers.go | 60 ++++- cmd/links/main.go | 9 + cmd/test/helpers.go | 13 +- core/routes.go | 68 +++-- models/base_url.go | 7 +- models/models.go | 4 +- models/org_link.go | 5 + models/organization.go | 34 ++- models/tag.go | 104 ++++++++ static/css/style.css | 22 ++ templates/feed.html | 19 +- templates/link_list.html | 17 ++ 15 files changed, 921 insertions(+), 84 deletions(-) diff --git a/api/graph/generated.go b/api/graph/generated.go index 1ebec44..ba2d17c 100644 --- a/api/graph/generated.go +++ b/api/graph/generated.go @@ -302,6 +302,7 @@ type ComplexityRoot struct { PageInfo func(childComplexity int) int RestrictedCount func(childComplexity int) int Result func(childComplexity int) int + TagCloud func(childComplexity int) int } Organization struct { @@ -359,6 +360,11 @@ type ComplexityRoot struct { Result func(childComplexity int) int } + PopularLinkCursor struct { + Result func(childComplexity int) int + TagCloud func(childComplexity int) int + } + QRCode struct { Clicks func(childComplexity int) int CodeType func(childComplexity int) int @@ -425,6 +431,7 @@ type ComplexityRoot struct { } Tag struct { + Count func(childComplexity int) int CreatedOn func(childComplexity int) int ID func(childComplexity int) int Name func(childComplexity int) int @@ -525,7 +532,7 @@ type QueryResolver interface { GetOrganizations(ctx context.Context, input *model.GetOrganizationsInput) ([]*models.Organization, error) GetOrganization(ctx context.Context, slug string) (*models.Organization, error) GetPaymentHistory(ctx context.Context, input *model.GetPaymentInput) (*model.PaymentCursor, error) - GetPopularLinks(ctx context.Context, input *model.PopularLinksInput) ([]*models.BaseURL, error) + GetPopularLinks(ctx context.Context, input *model.PopularLinksInput) (*model.PopularLinkCursor, error) GetOrgLink(ctx context.Context, hash string) (*models.OrgLink, error) GetOrgLinks(ctx context.Context, input *model.GetLinkInput) (*model.OrgLinkCursor, error) GetOrgMembers(ctx context.Context, orgSlug string) ([]*models.User, error) @@ -1873,6 +1880,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.OrgLinkCursor.Result(childComplexity), true + case "OrgLinkCursor.tagCloud": + if e.complexity.OrgLinkCursor.TagCloud == nil { + break + } + + return e.complexity.OrgLinkCursor.TagCloud(childComplexity), true + case "Organization.createdOn": if e.complexity.Organization.CreatedOn == nil { break @@ -2111,6 +2125,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.PaymentCursor.Result(childComplexity), true + case "PopularLinkCursor.result": + if e.complexity.PopularLinkCursor.Result == nil { + break + } + + return e.complexity.PopularLinkCursor.Result(childComplexity), true + + case "PopularLinkCursor.tagCloud": + if e.complexity.PopularLinkCursor.TagCloud == nil { + break + } + + return e.complexity.PopularLinkCursor.TagCloud(childComplexity), true + case "QRCode.clicks": if e.complexity.QRCode.Clicks == nil { break @@ -2586,6 +2614,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.SocialLinks.Youtube(childComplexity), true + case "Tag.count": + if e.complexity.Tag.Count == nil { + break + } + + return e.complexity.Tag.Count(childComplexity), true + case "Tag.createdOn": if e.complexity.Tag.CreatedOn == nil { break @@ -6656,6 +6691,8 @@ func (ec *executionContext) fieldContext_BaseURL_tags(_ context.Context, field g return ec.fieldContext_Tag_slug(ctx, field) case "createdOn": return ec.fieldContext_Tag_createdOn(ctx, field) + case "count": + return ec.fieldContext_Tag_count(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) }, @@ -8390,6 +8427,8 @@ func (ec *executionContext) fieldContext_LinkShort_tags(_ context.Context, field return ec.fieldContext_Tag_slug(ctx, field) case "createdOn": return ec.fieldContext_Tag_createdOn(ctx, field) + case "count": + return ec.fieldContext_Tag_count(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) }, @@ -9358,6 +9397,8 @@ func (ec *executionContext) fieldContext_Listing_tags(_ context.Context, field g return ec.fieldContext_Tag_slug(ctx, field) case "createdOn": return ec.fieldContext_Tag_createdOn(ctx, field) + case "count": + return ec.fieldContext_Tag_count(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) }, @@ -15060,6 +15101,8 @@ func (ec *executionContext) fieldContext_OrgLink_tags(_ context.Context, field g return ec.fieldContext_Tag_slug(ctx, field) case "createdOn": return ec.fieldContext_Tag_createdOn(ctx, field) + case "count": + return ec.fieldContext_Tag_count(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) }, @@ -15497,6 +15540,59 @@ func (ec *executionContext) fieldContext_OrgLinkCursor_restrictedCount(_ context return fc, nil } +func (ec *executionContext) _OrgLinkCursor_tagCloud(ctx context.Context, field graphql.CollectedField, obj *model.OrgLinkCursor) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_OrgLinkCursor_tagCloud(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.TagCloud, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]*models.Tag) + fc.Result = res + return ec.marshalOTag2ᚕᚖlinksᚋmodelsᚐTag(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_OrgLinkCursor_tagCloud(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "OrgLinkCursor", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Tag_id(ctx, field) + case "name": + return ec.fieldContext_Tag_name(ctx, field) + case "slug": + return ec.fieldContext_Tag_slug(ctx, field) + case "createdOn": + return ec.fieldContext_Tag_createdOn(ctx, field) + case "count": + return ec.fieldContext_Tag_count(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _Organization_id(ctx context.Context, field graphql.CollectedField, obj *models.Organization) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Organization_id(ctx, field) if err != nil { @@ -17301,6 +17397,125 @@ func (ec *executionContext) fieldContext_PaymentCursor_pageInfo(_ context.Contex return fc, nil } +func (ec *executionContext) _PopularLinkCursor_result(ctx context.Context, field graphql.CollectedField, obj *model.PopularLinkCursor) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_PopularLinkCursor_result(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Result, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]*models.BaseURL) + fc.Result = res + return ec.marshalNBaseURL2ᚕᚖlinksᚋmodelsᚐBaseURL(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_PopularLinkCursor_result(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PopularLinkCursor", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_BaseURL_id(ctx, field) + case "title": + return ec.fieldContext_BaseURL_title(ctx, field) + case "url": + return ec.fieldContext_BaseURL_url(ctx, field) + case "counter": + return ec.fieldContext_BaseURL_counter(ctx, field) + case "tags": + return ec.fieldContext_BaseURL_tags(ctx, field) + case "publicReady": + return ec.fieldContext_BaseURL_publicReady(ctx, field) + case "hash": + return ec.fieldContext_BaseURL_hash(ctx, field) + case "data": + return ec.fieldContext_BaseURL_data(ctx, field) + case "createdOn": + return ec.fieldContext_BaseURL_createdOn(ctx, field) + case "updatedOn": + return ec.fieldContext_BaseURL_updatedOn(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type BaseURL", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _PopularLinkCursor_tagCloud(ctx context.Context, field graphql.CollectedField, obj *model.PopularLinkCursor) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_PopularLinkCursor_tagCloud(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.TagCloud, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.([]*models.Tag) + fc.Result = res + return ec.marshalOTag2ᚕᚖlinksᚋmodelsᚐTag(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_PopularLinkCursor_tagCloud(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PopularLinkCursor", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_Tag_id(ctx, field) + case "name": + return ec.fieldContext_Tag_name(ctx, field) + case "slug": + return ec.fieldContext_Tag_slug(ctx, field) + case "createdOn": + return ec.fieldContext_Tag_createdOn(ctx, field) + case "count": + return ec.fieldContext_Tag_count(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type Tag", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _QRCode_id(ctx context.Context, field graphql.CollectedField, obj *models.QRCode) (ret graphql.Marshaler) { fc, err := ec.fieldContext_QRCode_id(ctx, field) if err != nil { @@ -18528,16 +18743,16 @@ func (ec *executionContext) _Query_getPopularLinks(ctx context.Context, field gr directive1 := func(ctx context.Context) (interface{}, error) { scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "LINKS") if err != nil { - var zeroVal []*models.BaseURL + var zeroVal *model.PopularLinkCursor return zeroVal, err } kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO") if err != nil { - var zeroVal []*models.BaseURL + var zeroVal *model.PopularLinkCursor return zeroVal, err } if ec.directives.Access == nil { - var zeroVal []*models.BaseURL + var zeroVal *model.PopularLinkCursor return zeroVal, errors.New("directive access is not implemented") } return ec.directives.Access(ctx, nil, directive0, scope, kind) @@ -18550,10 +18765,10 @@ func (ec *executionContext) _Query_getPopularLinks(ctx context.Context, field gr if tmp == nil { return nil, nil } - if data, ok := tmp.([]*models.BaseURL); ok { + if data, ok := tmp.(*model.PopularLinkCursor); ok { return data, nil } - return nil, fmt.Errorf(`unexpected type %T from directive, should be []*links/models.BaseURL`, tmp) + return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/api/graph/model.PopularLinkCursor`, tmp) }) if err != nil { ec.Error(ctx, err) @@ -18565,9 +18780,9 @@ func (ec *executionContext) _Query_getPopularLinks(ctx context.Context, field gr } return graphql.Null } - res := resTmp.([]*models.BaseURL) + res := resTmp.(*model.PopularLinkCursor) fc.Result = res - return ec.marshalNBaseURL2ᚕᚖlinksᚋmodelsᚐBaseURL(ctx, field.Selections, res) + return ec.marshalNPopularLinkCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐPopularLinkCursor(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Query_getPopularLinks(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -18578,28 +18793,12 @@ func (ec *executionContext) fieldContext_Query_getPopularLinks(ctx context.Conte IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { switch field.Name { - case "id": - return ec.fieldContext_BaseURL_id(ctx, field) - case "title": - return ec.fieldContext_BaseURL_title(ctx, field) - case "url": - return ec.fieldContext_BaseURL_url(ctx, field) - case "counter": - return ec.fieldContext_BaseURL_counter(ctx, field) - case "tags": - return ec.fieldContext_BaseURL_tags(ctx, field) - case "publicReady": - return ec.fieldContext_BaseURL_publicReady(ctx, field) - case "hash": - return ec.fieldContext_BaseURL_hash(ctx, field) - case "data": - return ec.fieldContext_BaseURL_data(ctx, field) - case "createdOn": - return ec.fieldContext_BaseURL_createdOn(ctx, field) - case "updatedOn": - return ec.fieldContext_BaseURL_updatedOn(ctx, field) + case "result": + return ec.fieldContext_PopularLinkCursor_result(ctx, field) + case "tagCloud": + return ec.fieldContext_PopularLinkCursor_tagCloud(ctx, field) } - return nil, fmt.Errorf("no field named %q was found under type BaseURL", field.Name) + return nil, fmt.Errorf("no field named %q was found under type PopularLinkCursor", field.Name) }, } defer func() { @@ -18817,6 +19016,8 @@ func (ec *executionContext) fieldContext_Query_getOrgLinks(ctx context.Context, return ec.fieldContext_OrgLinkCursor_pageInfo(ctx, field) case "restrictedCount": return ec.fieldContext_OrgLinkCursor_restrictedCount(ctx, field) + case "tagCloud": + return ec.fieldContext_OrgLinkCursor_tagCloud(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type OrgLinkCursor", field.Name) }, @@ -20049,6 +20250,8 @@ func (ec *executionContext) fieldContext_Query_getFeed(ctx context.Context, fiel return ec.fieldContext_OrgLinkCursor_pageInfo(ctx, field) case "restrictedCount": return ec.fieldContext_OrgLinkCursor_restrictedCount(ctx, field) + case "tagCloud": + return ec.fieldContext_OrgLinkCursor_tagCloud(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type OrgLinkCursor", field.Name) }, @@ -21420,6 +21623,47 @@ func (ec *executionContext) fieldContext_Tag_createdOn(_ context.Context, field return fc, nil } +func (ec *executionContext) _Tag_count(ctx context.Context, field graphql.CollectedField, obj *models.Tag) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Tag_count(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Count, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalOInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Tag_count(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Tag", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *models.User) (ret graphql.Marshaler) { fc, err := ec.fieldContext_User_id(ctx, field) if err != nil { @@ -24635,7 +24879,7 @@ func (ec *executionContext) unmarshalInputGetFeedInput(ctx context.Context, obj asMap[k] = v } - fieldsInOrder := [...]string{"limit", "after", "before", "tag", "excludeTag", "search"} + fieldsInOrder := [...]string{"limit", "after", "before", "tag", "excludeTag", "search", "tagCloudType", "tagCloudOrder"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -24684,6 +24928,20 @@ func (ec *executionContext) unmarshalInputGetFeedInput(ctx context.Context, obj return it, err } it.Search = data + case "tagCloudType": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("tagCloudType")) + data, err := ec.unmarshalOCloudType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudType(ctx, v) + if err != nil { + return it, err + } + it.TagCloudType = data + case "tagCloudOrder": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("tagCloudOrder")) + data, err := ec.unmarshalOCloudOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudOrderType(ctx, v) + if err != nil { + return it, err + } + it.TagCloudOrder = data } } @@ -24697,7 +24955,7 @@ func (ec *executionContext) unmarshalInputGetLinkInput(ctx context.Context, obj asMap[k] = v } - fieldsInOrder := [...]string{"orgSlug", "limit", "after", "before", "tag", "excludeTag", "search", "filter"} + fieldsInOrder := [...]string{"orgSlug", "limit", "after", "before", "tag", "excludeTag", "search", "filter", "tagCloudType", "tagCloudOrder"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -24760,6 +25018,20 @@ func (ec *executionContext) unmarshalInputGetLinkInput(ctx context.Context, obj return it, err } it.Filter = data + case "tagCloudType": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("tagCloudType")) + data, err := ec.unmarshalOCloudType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudType(ctx, v) + if err != nil { + return it, err + } + it.TagCloudType = data + case "tagCloudOrder": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("tagCloudOrder")) + data, err := ec.unmarshalOCloudOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudOrderType(ctx, v) + if err != nil { + return it, err + } + it.TagCloudOrder = data } } @@ -25461,7 +25733,7 @@ func (ec *executionContext) unmarshalInputPopularLinksInput(ctx context.Context, asMap[k] = v } - fieldsInOrder := [...]string{"tag", "search", "limit"} + fieldsInOrder := [...]string{"tag", "search", "limit", "tagCloudOrder"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -25489,6 +25761,13 @@ func (ec *executionContext) unmarshalInputPopularLinksInput(ctx context.Context, return it, err } it.Limit = data + case "tagCloudOrder": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("tagCloudOrder")) + data, err := ec.unmarshalOCloudOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudOrderType(ctx, v) + if err != nil { + return it, err + } + it.TagCloudOrder = data } } @@ -28142,6 +28421,8 @@ func (ec *executionContext) _OrgLinkCursor(ctx context.Context, sel ast.Selectio out.Values[i] = ec._OrgLinkCursor_pageInfo(ctx, field, obj) case "restrictedCount": out.Values[i] = ec._OrgLinkCursor_restrictedCount(ctx, field, obj) + case "tagCloud": + out.Values[i] = ec._OrgLinkCursor_tagCloud(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -28654,6 +28935,47 @@ func (ec *executionContext) _PaymentCursor(ctx context.Context, sel ast.Selectio return out } +var popularLinkCursorImplementors = []string{"PopularLinkCursor"} + +func (ec *executionContext) _PopularLinkCursor(ctx context.Context, sel ast.SelectionSet, obj *model.PopularLinkCursor) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, popularLinkCursorImplementors) + + 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("PopularLinkCursor") + case "result": + out.Values[i] = ec._PopularLinkCursor_result(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "tagCloud": + out.Values[i] = ec._PopularLinkCursor_tagCloud(ctx, field, obj) + 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 qRCodeImplementors = []string{"QRCode"} func (ec *executionContext) _QRCode(ctx context.Context, sel ast.SelectionSet, obj *models.QRCode) graphql.Marshaler { @@ -29601,6 +29923,8 @@ func (ec *executionContext) _Tag(ctx context.Context, sel ast.SelectionSet, obj if out.Values[i] == graphql.Null { out.Invalids++ } + case "count": + out.Values[i] = ec._Tag_count(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -31066,6 +31390,20 @@ func (ec *executionContext) marshalNPaymentCursor2ᚖlinksᚋapiᚋgraphᚋmodel return ec._PaymentCursor(ctx, sel, v) } +func (ec *executionContext) marshalNPopularLinkCursor2linksᚋapiᚋgraphᚋmodelᚐPopularLinkCursor(ctx context.Context, sel ast.SelectionSet, v model.PopularLinkCursor) graphql.Marshaler { + return ec._PopularLinkCursor(ctx, sel, &v) +} + +func (ec *executionContext) marshalNPopularLinkCursor2ᚖlinksᚋapiᚋgraphᚋmodelᚐPopularLinkCursor(ctx context.Context, sel ast.SelectionSet, v *model.PopularLinkCursor) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._PopularLinkCursor(ctx, sel, v) +} + func (ec *executionContext) marshalNQRCode2linksᚋmodelsᚐQRCode(ctx context.Context, sel ast.SelectionSet, v models.QRCode) graphql.Marshaler { return ec._QRCode(ctx, sel, &v) } @@ -31635,6 +31973,38 @@ func (ec *executionContext) marshalOBoolean2ᚖbool(ctx context.Context, sel ast return res } +func (ec *executionContext) unmarshalOCloudOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudOrderType(ctx context.Context, v interface{}) (*model.CloudOrderType, error) { + if v == nil { + return nil, nil + } + var res = new(model.CloudOrderType) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOCloudOrderType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudOrderType(ctx context.Context, sel ast.SelectionSet, v *model.CloudOrderType) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + +func (ec *executionContext) unmarshalOCloudType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudType(ctx context.Context, v interface{}) (*model.CloudType, error) { + if v == nil { + return nil, nil + } + var res = new(model.CloudType) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOCloudType2ᚖlinksᚋapiᚋgraphᚋmodelᚐCloudType(ctx context.Context, sel ast.SelectionSet, v *model.CloudType) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) unmarshalOCompleteRegisterInput2ᚖlinksᚋapiᚋgraphᚋmodelᚐCompleteRegisterInput(ctx context.Context, v interface{}) (*model.CompleteRegisterInput, error) { if v == nil { return nil, nil @@ -32071,6 +32441,54 @@ func (ec *executionContext) marshalOTag2linksᚋmodelsᚐTag(ctx context.Context return ec._Tag(ctx, sel, &v) } +func (ec *executionContext) marshalOTag2ᚕᚖlinksᚋmodelsᚐTag(ctx context.Context, sel ast.SelectionSet, v []*models.Tag) graphql.Marshaler { + if v == nil { + return graphql.Null + } + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalOTag2ᚖlinksᚋmodelsᚐTag(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + return ret +} + +func (ec *executionContext) marshalOTag2ᚖlinksᚋmodelsᚐTag(ctx context.Context, sel ast.SelectionSet, v *models.Tag) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._Tag(ctx, sel, v) +} + func (ec *executionContext) unmarshalOTime2ᚖtimeᚐTime(ctx context.Context, v interface{}) (*time.Time, error) { if v == nil { return nil, nil diff --git a/api/graph/model/models_gen.go b/api/graph/model/models_gen.go index d78ce72..2404e55 100644 --- a/api/graph/model/models_gen.go +++ b/api/graph/model/models_gen.go @@ -171,23 +171,27 @@ type GetAdminOrganizationsInput struct { } type GetFeedInput struct { - Limit *int `json:"limit,omitempty"` - After *Cursor `json:"after,omitempty"` - Before *Cursor `json:"before,omitempty"` - Tag *string `json:"tag,omitempty"` - ExcludeTag *string `json:"excludeTag,omitempty"` - Search *string `json:"search,omitempty"` + Limit *int `json:"limit,omitempty"` + After *Cursor `json:"after,omitempty"` + Before *Cursor `json:"before,omitempty"` + Tag *string `json:"tag,omitempty"` + ExcludeTag *string `json:"excludeTag,omitempty"` + Search *string `json:"search,omitempty"` + TagCloudType *CloudType `json:"tagCloudType,omitempty"` + TagCloudOrder *CloudOrderType `json:"tagCloudOrder,omitempty"` } type GetLinkInput struct { - OrgSlug *string `json:"orgSlug,omitempty"` - Limit *int `json:"limit,omitempty"` - After *Cursor `json:"after,omitempty"` - Before *Cursor `json:"before,omitempty"` - Tag *string `json:"tag,omitempty"` - ExcludeTag *string `json:"excludeTag,omitempty"` - Search *string `json:"search,omitempty"` - Filter *string `json:"filter,omitempty"` + OrgSlug *string `json:"orgSlug,omitempty"` + Limit *int `json:"limit,omitempty"` + After *Cursor `json:"after,omitempty"` + Before *Cursor `json:"before,omitempty"` + Tag *string `json:"tag,omitempty"` + ExcludeTag *string `json:"excludeTag,omitempty"` + Search *string `json:"search,omitempty"` + Filter *string `json:"filter,omitempty"` + TagCloudType *CloudType `json:"tagCloudType,omitempty"` + TagCloudOrder *CloudOrderType `json:"tagCloudOrder,omitempty"` } type GetLinkShortInput struct { @@ -312,6 +316,7 @@ type OrgLinkCursor struct { Result []*models.OrgLink `json:"result"` PageInfo *PageInfo `json:"pageInfo,omitempty"` RestrictedCount *int `json:"restrictedCount,omitempty"` + TagCloud []*models.Tag `json:"tagCloud,omitempty"` } type OrganizationCursor struct { @@ -356,10 +361,16 @@ type PaymentCursor struct { PageInfo *PageInfo `json:"pageInfo,omitempty"` } +type PopularLinkCursor struct { + Result []*models.BaseURL `json:"result"` + TagCloud []*models.Tag `json:"tagCloud,omitempty"` +} + type PopularLinksInput struct { - Tag *string `json:"tag,omitempty"` - Search *string `json:"search,omitempty"` - Limit *int `json:"limit,omitempty"` + Tag *string `json:"tag,omitempty"` + Search *string `json:"search,omitempty"` + Limit *int `json:"limit,omitempty"` + TagCloudOrder *CloudOrderType `json:"tagCloudOrder,omitempty"` } type ProfileInput struct { @@ -580,6 +591,92 @@ func (e AccessScope) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type CloudOrderType string + +const ( + CloudOrderTypeCountAsc CloudOrderType = "COUNT_ASC" + CloudOrderTypeCountDesc CloudOrderType = "COUNT_DESC" + CloudOrderTypeNameAsc CloudOrderType = "NAME_ASC" + CloudOrderTypeNameDesc CloudOrderType = "NAME_DESC" +) + +var AllCloudOrderType = []CloudOrderType{ + CloudOrderTypeCountAsc, + CloudOrderTypeCountDesc, + CloudOrderTypeNameAsc, + CloudOrderTypeNameDesc, +} + +func (e CloudOrderType) IsValid() bool { + switch e { + case CloudOrderTypeCountAsc, CloudOrderTypeCountDesc, CloudOrderTypeNameAsc, CloudOrderTypeNameDesc: + return true + } + return false +} + +func (e CloudOrderType) String() string { + return string(e) +} + +func (e *CloudOrderType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = CloudOrderType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid CloudOrderType", str) + } + return nil +} + +func (e CloudOrderType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +type CloudType string + +const ( + CloudTypeLinks CloudType = "LINKS" + CloudTypeOrganization CloudType = "ORGANIZATION" +) + +var AllCloudType = []CloudType{ + CloudTypeLinks, + CloudTypeOrganization, +} + +func (e CloudType) IsValid() bool { + switch e { + case CloudTypeLinks, CloudTypeOrganization: + return true + } + return false +} + +func (e CloudType) String() string { + return string(e) +} + +func (e *CloudType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = CloudType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid CloudType", str) + } + return nil +} + +func (e CloudType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type DomainLevel string const ( diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index 2f54459..db6168a 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -116,6 +116,18 @@ enum QRCodeType { SHORT } +enum CloudType { + LINKS + ORGANIZATION +} + +enum CloudOrderType { + COUNT_ASC + COUNT_DESC + NAME_ASC + NAME_DESC +} + # Considering removing these Null* fields: # https://todo.code.netlandish.com/~netlandish/links/75 @@ -209,6 +221,7 @@ type Tag { name: String! slug: String! createdOn: Time! + count: Int } type OrgLink { @@ -364,6 +377,12 @@ type OrgLinkCursor { result: [OrgLink]! pageInfo: PageInfo restrictedCount: Int + tagCloud: [Tag] +} + +type PopularLinkCursor { + result: [BaseURL]! + tagCloud: [Tag] } type LinkShortCursor { @@ -548,6 +567,7 @@ input PopularLinksInput { tag: String search: String limit: Int + tagCloudOrder: CloudOrderType } input GetFeedInput { @@ -557,6 +577,8 @@ input GetFeedInput { tag: String excludeTag: String search: String + tagCloudType: CloudType + tagCloudOrder: CloudOrderType } input GetLinkInput { @@ -565,13 +587,15 @@ input GetLinkInput { after: Cursor before: Cursor tag: String - excludeTag: String, + excludeTag: String search: String filter: String + tagCloudType: CloudType + tagCloudOrder: CloudOrderType } input GetLinkShortInput { - orgSlug: String!, + orgSlug: String! limit: Int after: Cursor before: Cursor @@ -776,7 +800,7 @@ type Query { getPaymentHistory(input: GetPaymentInput): PaymentCursor! @access(scope: BILLING, kind: RO) "Returns current most popular links" - getPopularLinks(input: PopularLinksInput): [BaseURL]! @access(scope: LINKS, kind: RO) + getPopularLinks(input: PopularLinksInput): PopularLinkCursor! @access(scope: LINKS, kind: RO) "Returns a specific organization link" getOrgLink(hash: String!): OrgLink @access(scope: LINKS, kind: RO) diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index 78e264e..0ed7d79 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -4782,7 +4782,7 @@ func (r *qRCodeResolver) CodeType(ctx context.Context, obj *models.QRCode) (mode func (r *queryResolver) Version(ctx context.Context) (*model.Version, error) { return &model.Version{ Major: 0, - Minor: 2, + Minor: 3, Patch: 0, DeprecationDate: nil, }, nil @@ -5025,7 +5025,7 @@ func (r *queryResolver) GetPaymentHistory(ctx context.Context, input *model.GetP } // GetPopularLinks is the resolver for the getPopularLinks field. -func (r *queryResolver) GetPopularLinks(ctx context.Context, input *model.PopularLinksInput) ([]*models.BaseURL, error) { +func (r *queryResolver) GetPopularLinks(ctx context.Context, input *model.PopularLinksInput) (*model.PopularLinkCursor, error) { var query string if input != nil { if input.Tag != nil && *input.Tag != "" { @@ -5040,13 +5040,25 @@ func (r *queryResolver) GetPopularLinks(ctx context.Context, input *model.Popula return nil, err } + cloudOrder := model.CloudOrderTypeNameAsc if input != nil { if input.Limit != nil && *input.Limit > 0 && *input.Limit <= 50 { popularLinks = popularLinks[:*input.Limit] } + if input.TagCloudOrder != nil && *input.TagCloudOrder != "" { + cloudOrder = *input.TagCloudOrder + } } - return popularLinks, nil + tags, err := models.LinkTagCloud(ctx, popularLinks, models.TagCloudOrdering(cloudOrder)) + if err != nil { + return nil, err + } + + return &model.PopularLinkCursor{ + Result: popularLinks, + TagCloud: tags, + }, nil } // GetOrgLink is the resolver for the getOrgLink field. @@ -5118,6 +5130,15 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp return nil, nil } + cloudType := model.CloudTypeLinks + cloudOrder := model.CloudOrderTypeNameAsc + if input.TagCloudType != nil && *input.TagCloudType != "" { + cloudType = *input.TagCloudType + } + if input.TagCloudOrder != nil && *input.TagCloudOrder != "" { + cloudOrder = *input.TagCloudOrder + } + linkOpts := &database.FilterOptions{ Filter: sq.And{}, OrderBy: "ol.id DESC", @@ -5170,6 +5191,12 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp } } + if cloudType == model.CloudTypeOrganization && org == nil { + validator.Error("%s", lt.Translate("Invalid tag cloud type without organization slug")). + WithCode(valid.ErrValidationGlobalCode) + return nil, nil + } + if input.Filter != nil && *input.Filter != "" { // unread | starred if *input.Filter == links.FilterUnread || *input.Filter == links.FilterStarred { @@ -5236,10 +5263,21 @@ func (r *queryResolver) GetOrgLinks(ctx context.Context, input *model.GetLinkInp } } + var tags []*models.Tag + if cloudType == model.CloudTypeOrganization { + tags, err = org.TagCloud(ctx, models.TagCloudOrdering(cloudOrder)) + } else { + tags, err = models.LinkTagCloud(ctx, orgLinks, models.TagCloudOrdering(cloudOrder)) + } + if err != nil { + return nil, err + } + return &model.OrgLinkCursor{ Result: orgLinks, PageInfo: pageInfo, RestrictedCount: &resCount, + TagCloud: tags, }, nil } @@ -6220,6 +6258,11 @@ func (r *queryResolver) GetFeed(ctx context.Context, input *model.GetFeedInput) return nil, nil } + cloudOrder := model.CloudOrderTypeNameAsc + if input.TagCloudOrder != nil && *input.TagCloudOrder != "" { + cloudOrder = *input.TagCloudOrder + } + linkOpts := &database.FilterOptions{ Filter: sq.Or{ // An user follows another user @@ -6275,7 +6318,16 @@ func (r *queryResolver) GetFeed(ctx context.Context, input *model.GetFeedInput) return nil, err } - return &model.OrgLinkCursor{Result: orgLinks, PageInfo: pageInfo}, nil + tags, err := models.LinkTagCloud(ctx, orgLinks, models.TagCloudOrdering(cloudOrder)) + if err != nil { + return nil, err + } + + return &model.OrgLinkCursor{ + Result: orgLinks, + PageInfo: pageInfo, + TagCloud: tags, + }, nil } // GetFeedFollowing is the resolver for the getFeedFollowing field. diff --git a/cmd/links/main.go b/cmd/links/main.go index 732886e..9698153 100644 --- a/cmd/links/main.go +++ b/cmd/links/main.go @@ -298,6 +298,15 @@ func run() error { "newlinebr": func(blob string) string { return strings.ReplaceAll(blob, "\n", "<br />\n") }, + "tagClass": func(count int) string { + if count > 8 { + return "tag-large" + } else if count > 4 { + return "tag-medium" + } else { + return "tag-normal" + } + }, }) srv.TemplateAllowOverride( "seoData", diff --git a/cmd/test/helpers.go b/cmd/test/helpers.go index 3f2651c..967f17e 100644 --- a/cmd/test/helpers.go +++ b/cmd/test/helpers.go @@ -135,6 +135,15 @@ func NewWebTestServer(t *testing.T) (*server.Server, *echo.Echo) { "newlinebr": func(blob string) string { return strings.ReplaceAll(blob, "\n", "<br />\n") }, + "tagClass": func(count int) string { + if count > 8 { + return "tag-large" + } else if count > 4 { + return "tag-medium" + } else { + return "tag-normal" + } + }, }) err = srv.LoadTemplatesFS(links.TemplateFS, "templates/*.html", "templates/*.txt") if err != nil { @@ -158,9 +167,9 @@ func NewAPITestServer(t *testing.T) (*server.Server, *echo.Echo, string) { gqlConfig.Directives.Internal = directives.Internal gqlConfig.Directives.Private = directives.Private gqlConfig.Directives.Admin = directives.Admin - gqlConfig.Directives.Access = func(ctx context.Context, obj interface{}, + gqlConfig.Directives.Access = func(ctx context.Context, obj any, next graphql.Resolver, scope model.AccessScope, - kind model.AccessKind) (interface{}, error) { + kind model.AccessKind) (any, error) { return directives.Access(ctx, obj, next, scope.String(), kind.String()) } schema := graph.NewExecutableSchema(gqlConfig) diff --git a/core/routes.go b/core/routes.go index 94a8a23..ce9f659 100644 --- a/core/routes.go +++ b/core/routes.go @@ -7,6 +7,7 @@ import ( "html/template" "links" "links/analytics" + "links/api/graph/model" "links/domain" "links/internal/localizer" "links/models" @@ -1594,29 +1595,39 @@ func (s *Service) PopularLinkList(c echo.Context) error { } type GraphQLResponse struct { - PopularLinks []models.BaseURL `json:"getPopularLinks"` + PopularLinks struct { + Result []models.BaseURL `json:"result"` + TagCloud []models.Tag `json:"tagCloud"` + } `json:"getPopularLinks"` } var result GraphQLResponse op := gqlclient.NewOperation( `query GetPopularLinks($search: String, $tag: String) { getPopularLinks(input:{search: $search, tag: $tag}) { - id - title - url - hash - counter - tags { + result { id + title + url + hash + counter + tags { + id + name + slug + } + data { + meta { + image + description + siteName + } + } + } + tagCloud { name slug - } - data { - meta { - image - description - siteName - } + count } } }`) @@ -1645,10 +1656,11 @@ func (s *Service) PopularLinkList(c echo.Context) error { pd := localizer.NewPageData(lt.Translate("Bookmarks")) pd.Data["bookmark"] = lt.Translate("bookmark") pd.Data["popular"] = lt.Translate("Popular Bookmarks") + pd.Data["tags"] = lt.Translate("Tags") pd.Data["no_links"] = lt.Translate( "This ain't a popularity contest or anything but this is weird that there are no links!") - pLinks := result.PopularLinks + pLinks := result.PopularLinks.Result rssURL := c.Echo().Reverse(s.RouteName("popular_link_list_rss")) url := links.GetLinksDomainURL(c) @@ -1669,6 +1681,7 @@ func (s *Service) PopularLinkList(c echo.Context) error { "tagFilter": "", "rssURL": rssURL, "seoData": seoData, + "tagCloud": result.PopularLinks.TagCloud, } if links.IsRSS(c.Path()) { items := []links.Item{} @@ -1749,6 +1762,7 @@ func (s *Service) UserFeed(c echo.Context) error { HasNextPage bool HasPrevPage bool } `json:"pageInfo"` + TagCloud []models.Tag `json:"tagCloud"` } `json:"getFeed"` } @@ -1782,6 +1796,11 @@ func (s *Service) UserFeed(c echo.Context) error { hasPrevPage hasNextPage } + tagCloud { + name + slug + count + } } }`) @@ -1891,6 +1910,7 @@ func (s *Service) UserFeed(c echo.Context) error { "advancedSearch": true, "rssURL": c.Echo().Reverse("core:user_feed_rss"), "navFlag": "feed", + "tagCloud": result.OrgLinks.TagCloud, } if search != "" { gmap["search"] = search @@ -1916,14 +1936,16 @@ func (s *Service) OrgLinksList(c echo.Context) error { HasNextPage bool HasPrevPage bool } `json:"pageInfo"` - RestrictedCount int `json:"restrictedCount"` + RestrictedCount int `json:"restrictedCount"` + TagCloud []models.Tag `json:"tagCloud"` } `json:"getOrgLinks"` } var result GraphQLResponse op := gqlclient.NewOperation( `query GetOrgLinks($slug: String, $after: Cursor, $before: Cursor, - $tag: String, $excludeTag: String, $search: String, $filter: String) { + $tag: String, $excludeTag: String, $search: String, + $filter: String, $cloudType: CloudType, $cloudOrder: CloudOrderType) { getOrgLinks(input: { orgSlug: $slug, after: $after, @@ -1931,7 +1953,9 @@ func (s *Service) OrgLinksList(c echo.Context) error { tag: $tag, excludeTag: $excludeTag, search: $search, - filter: $filter + filter: $filter, + tagCloudType: $cloudType, + tagCloudOrder: $cloudOrder }) { result { id @@ -1967,6 +1991,11 @@ func (s *Service) OrgLinksList(c echo.Context) error { hasNextPage } restrictedCount + tagCloud { + name + slug + count + } } }`) @@ -1981,6 +2010,7 @@ func (s *Service) OrgLinksList(c echo.Context) error { // and not all the recent links slug = links.PullOrgSlug(c) op.Var("slug", slug) + op.Var("cloudType", model.CloudTypeOrganization) opts := &database.FilterOptions{ Filter: sq.And{ sq.Expr("o.slug = ?", slug), @@ -2166,6 +2196,7 @@ func (s *Service) OrgLinksList(c echo.Context) error { pd.Data["clear"] = lt.Translate("Clear") pd.Data["follow"] = lt.Translate("Follow") pd.Data["unfollow"] = lt.Translate("Unfollow") + pd.Data["tags"] = lt.Translate("Tags") orgLinks := result.OrgLinks.Result if links.IsRSS(c.Path()) { domain := fmt.Sprintf("%s://%s", gctx.Server.Config.Scheme, gctx.Server.Config.Domain) @@ -2255,6 +2286,7 @@ func (s *Service) OrgLinksList(c echo.Context) error { "autoCompleteOrgID": org.ID, "rssURL": rssURL, "followAction": followAction, + "tagCloud": result.OrgLinks.TagCloud, } if search != "" { diff --git a/models/base_url.go b/models/base_url.go index 1442e05..c94d16e 100644 --- a/models/base_url.go +++ b/models/base_url.go @@ -45,7 +45,7 @@ func (b BaseURLData) Value() (driver.Value, error) { } // Scan ... -func (b *BaseURLData) Scan(value interface{}) error { +func (b *BaseURLData) Scan(value any) error { d, ok := value.([]byte) if !ok { return errors.New("type assertion to []byte failed") @@ -251,3 +251,8 @@ func (b *BaseURL) QueryParams() url.Values { qs.Set("burlid", b.Hash) return qs } + +// GetID to satisfy idGetter +func (b *BaseURL) GetID() int { + return b.ID +} diff --git a/models/models.go b/models/models.go index 0a9e61a..c80f62d 100644 --- a/models/models.go +++ b/models/models.go @@ -129,6 +129,8 @@ type Tag struct { Name string `db:"name"` Slug string `db:"slug"` CreatedOn time.Time `db:"created_on"` + + Count int `db:"-" json:"count"` } // TagLink ... @@ -228,7 +230,7 @@ func (s Metadata) Value() (driver.Value, error) { } // Scan ... -func (s *Metadata) Scan(value interface{}) error { +func (s *Metadata) Scan(value any) error { b, ok := value.([]byte) if !ok { return errors.New("type assertion to []byte failed") diff --git a/models/org_link.go b/models/org_link.go index 67f5e9d..39238b1 100644 --- a/models/org_link.go +++ b/models/org_link.go @@ -226,6 +226,11 @@ func (o *OrgLink) QueryParams() url.Values { return qs } +// GetID to satisfy idGetter +func (o *OrgLink) GetID() int { + return o.ID +} + // Returns a list of links and total clicks func GetOrgLinksAnalytics(ctx context.Context, opts *database.FilterOptions) ([]*OrgLink, error) { if opts == nil { diff --git a/models/organization.go b/models/organization.go index 8c9c5ff..c90f238 100644 --- a/models/organization.go +++ b/models/organization.go @@ -8,10 +8,12 @@ import ( "errors" "fmt" "links/internal/localizer" + "slices" "time" sq "github.com/Masterminds/squirrel" "github.com/labstack/echo/v4" + "netlandish.com/x/gobwebs/auth" "netlandish.com/x/gobwebs/database" "netlandish.com/x/gobwebs/timezone" ) @@ -51,7 +53,7 @@ func (os OrganizationSettings) Value() (driver.Value, error) { } // Scan ... -func (os *OrganizationSettings) Scan(value interface{}) error { +func (os *OrganizationSettings) Scan(value any) error { b, ok := value.([]byte) if !ok { return errors.New("type assertion to []byte failed") @@ -255,10 +257,8 @@ func (o *Organization) CanAdminWrite(ctx context.Context, user *User) bool { func (o *Organization) IsRestricted(restrictedStatus []string) bool { status := o.Settings.Billing.Status - for _, i := range restrictedStatus { - if i == status { - return true - } + if slices.Contains(restrictedStatus, status) { + return true } return false } @@ -336,3 +336,27 @@ func ToggleOrganizaiongBatch(ctx context.Context, opts *database.FilterOptions, }) return err } + +func (o *Organization) TagCloud(ctx context.Context, order TagCloudOrdering) ([]*Tag, error) { + var canRead bool + opts := &database.FilterOptions{ + Filter: sq.Eq{"ol.org_id": o.ID}, + OrderBy: TagCloudOrderString(order), + } + user, ok := auth.ForContext(ctx).(*User) + if ok { + o, _ := user.GetOrgsID(ctx, OrgUserPermissionRead, o.ID) + if o != nil { + canRead = true + } + } + + // Only show tags for public links unless user has read permission + if !canRead { + opts.Filter = sq.And{ + opts.Filter, + sq.Eq{"ol.visibility": OrgLinkVisibilityPublic}, + } + } + return GetTagCloud(ctx, opts) +} diff --git a/models/tag.go b/models/tag.go index 4ea9215..06853cf 100644 --- a/models/tag.go +++ b/models/tag.go @@ -11,6 +11,37 @@ import ( "netlandish.com/x/gobwebs/timezone" ) +type idGetter interface { + GetID() int +} + +type TaggableModel interface { + idGetter + ~*BaseURL | ~*OrgLink +} + +type TagCloudOrdering string + +const ( + CountASC TagCloudOrdering = "COUNT_ASC" + CountDESC TagCloudOrdering = "COUNT_DESC" + NameASC TagCloudOrdering = "NAME_ASC" + NameDESC TagCloudOrdering = "NAME_DESC" +) + +func TagCloudOrderString(v TagCloudOrdering) string { + valid := map[TagCloudOrdering]string{ + CountASC: "tag_count ASC", + CountDESC: "tag_count DESC", + NameASC: "t.name ASC", + NameDESC: "t.name DESC", + } + if val, ok := valid[v]; ok { + return val + } + return "" +} + // GetTags ... func GetTags(ctx context.Context, opts *database.FilterOptions) ([]*Tag, error) { if opts == nil { @@ -147,3 +178,76 @@ func (t *Tag) Delete(ctx context.Context) error { }) return err } + +func GetTagCloud(ctx context.Context, opts *database.FilterOptions) ([]*Tag, error) { + var tags []*Tag + + tz := timezone.ForContext(ctx) + if err := database.WithTx(ctx, database.TxOptionsRO, func(tx *sql.Tx) error { + q := opts.GetBuilder(nil) + rows, err := q. + Columns("t.id", "t.name", "t.slug", "t.created_on", "count(*) as tag_count"). + From("tags t"). + Join("tag_links tl on tl.tag_id = t.id"). + Join("org_links ol on ol.id = tl.org_link_id"). + GroupBy("t.id", "t.name", "t.slug"). + Distinct(). + PlaceholderFormat(sq.Dollar). + RunWith(tx). + QueryContext(ctx) + + if err != nil { + if err == sql.ErrNoRows { + return nil + } + return err + } + defer rows.Close() + + for rows.Next() { + var t Tag + if err = rows.Scan(&t.ID, &t.Name, &t.Slug, &t.CreatedOn, &t.Count); err != nil { + return err + } + err = t.ToLocalTZ(tz) + if err != nil { + return err + } + tags = append(tags, &t) + } + return nil + }); err != nil { + return nil, err + } + return tags, nil +} + +// LinkTagCloud will generate a tag cloud for given org links +func LinkTagCloud[T TaggableModel](ctx context.Context, + items []T, order TagCloudOrdering) ([]*Tag, error) { + var ids []int + + if len(items) == 0 { + return []*Tag{}, nil + } + + for _, item := range items { + ids = append(ids, item.GetID()) + } + + var field string + switch any(items[0]).(type) { + case *BaseURL: + field = "ol.base_url_id" + case *OrgLink: + field = "ol.id" + default: + return nil, fmt.Errorf("unsupported type in LinkTagCloud") + } + + opts := &database.FilterOptions{ + Filter: sq.Eq{field: ids}, + OrderBy: TagCloudOrderString(order), + } + return GetTagCloud(ctx, opts) +} diff --git a/static/css/style.css b/static/css/style.css index 2d223f4..3630636 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -176,6 +176,28 @@ footer blockquote { .link-tag > *:not(:last-child) { margin-right: .8rem; } +.tag-normal { + font-size: .75em !important; +} +.tag-medium { + font-size: 1em !important; +} +.tag-large { + font-size: 1.25em !important; + /*font-weight: bold;*/ +} +.link-tag__side { + border: 1px solid; + letter-spacing: .5px; + line-height: 1; + font-size: .75em; + padding: .4rem; + margin-bottom: .8rem; + transition-property: all; + transition-timing-function: cubic-bezier(.4,0,.2,1); + transition-duration: .15s; + text-align: center; +} .link-tag__item { border: 1px solid var(--color-lightGrey); color: var(--color-darkGrey); diff --git a/templates/feed.html b/templates/feed.html index 43cfbe2..279245d 100644 --- a/templates/feed.html +++ b/templates/feed.html @@ -29,7 +29,7 @@ </div> <div class="col-2 is-right"> <button type="submit" class="button primary is-small advanced-search-btn">{{.pd.Data.apply}}</button> - <a href="{{.currURL}}{{if .hasUnreadFilter}}?filter=unread{{else if .hasStarredFilter}}?filter=starred{{end}}" class="button primary is-small advanced-search-btn"> + <a href="{{ reverse "core:user_feed" }}" class="button primary is-small advanced-search-btn"> {{.pd.Data.clear}} </a> </div> @@ -38,6 +38,8 @@ </form> <section class="card shadow-card"> + <div class="row"> + <div class="col-9"> <a href="{{buildURL .rssURL}}{{if .queries}}?{{.queries}}{{end}}" class="pull-right tooltip-link-rss" data-tooltip="RSS Feed{{if .queries}} (filtered){{end}}"> <svg style="width:20px" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" d="M12.75 19.5v-.75a7.5 7.5 0 0 0-7.5-7.5H4.5m0-6.75h.75c7.87 0 14.25 6.38 14.25 14.25v.75M6 18.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" /> @@ -91,5 +93,20 @@ {{end}} </footer> {{end}} + </div> {{- /* col-9 */ -}} + <div class="col-3"> + <p>{{ .pd.Data.tags }}</p> + <hr></hr> + <p> + {{ range .tagCloud }} + {{if isTagUsedInFilter .Slug $.tagFilter}} + <small class="link-tag__side link-tag__item--simple {{ tagClass .Count }}">#{{.Name}}</small> + {{else}} + <a href="{{if $.queries}}?{{addQueryElement $.queries "tag" .Slug}}{{else}}?tag={{.Slug}}{{end}}" class="link-tag__side link-tag__item--simple {{ tagClass .Count }}">#{{.Name}}</a> + {{end}} + {{ end }} + </p> + </div> + </div> {{- /* row */ -}} </section> {{template "base_footer" .}} diff --git a/templates/link_list.html b/templates/link_list.html index 6048241..e0d7d7f 100644 --- a/templates/link_list.html +++ b/templates/link_list.html @@ -42,6 +42,8 @@ {{end}} </form> <section class="card shadow-card container"> + <div class="row"> + <div class="col-9"> {{if .rssURL}} <a href="{{buildURL .rssURL}}{{if .queries}}?{{.queries}}{{end}}" class="pull-right tooltip-link-rss" data-tooltip="RSS Feed{{if .queries}} (filtered){{end}}"> <svg style="width:20px" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"> @@ -206,5 +208,20 @@ {{end}} </footer> {{end}} + </div> {{- /* col-9 */ -}} + <div class="col-3"> + <p>{{ .pd.Data.tags }}</p> + <hr></hr> + <p> + {{ range .tagCloud }} + {{if isTagUsedInFilter .Slug $.tagFilter}} + <small class="link-tag__side link-tag__item--simple {{ tagClass .Count }}">#{{.Name}}</small> + {{else}} + <a href="{{if $.queries}}?{{addQueryElement $.queries "tag" .Slug}}{{else}}?tag={{.Slug}}{{end}}" class="link-tag__side link-tag__item--simple {{ tagClass .Count }}">#{{.Name}}</a> + {{end}} + {{ end }} + </p> + </div> + </div> {{- /* row */ -}} </section> {{template "base_footer" .}} -- 2.47.2
Applied. To git@git.code.netlandish.com:~netlandish/links b37df68..a3b9037 master -> master