Subject: [PATCH links] Adding Tag clouds to the various GraphQL resolvers for use on the website.

From: Peter Sanchez <[email-masked]>
Date: Wed, 16 Apr 2025 07:42:42 -0600

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

---

From: "Peter Sanchez" <[email-masked]>
Date: Wed, 16 Apr 2025 07:48:08 -0600

Applied.

To [email-masked]:~netlandish/links
   b37df68..a3b9037  master -> master