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