~netlandish/links-dev

links: Adding Tag clouds to the various GraphQL resolvers for use on the website. v1 APPLIED

Peter Sanchez: 1
 Adding Tag clouds to the various GraphQL resolvers for use on the website.

 15 files changed, 921 insertions(+), 84 deletions(-)
Export patchset (mbox)
How do I use this?

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 -3
Learn more about email & git

[PATCH links] Adding Tag clouds to the various GraphQL resolvers for use on the website. Export this patch

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