~netlandish/links-dev

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

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

Details
Message ID
<20250416134302.3797-1-peter@netlandish.com>
Sender timestamp
1744789362
DKIM signature
missing
Download raw message
Patch: +921 -84
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
Details
Message ID
<D983ZCDTJTRG.1GH13RH13UD11@netlandish.com>
In-Reply-To
<20250416134302.3797-1-peter@netlandish.com> (view parent)
Sender timestamp
1744789688
DKIM signature
missing
Download raw message
Applied.

To git@git.code.netlandish.com:~netlandish/links
   b37df68..a3b9037  master -> master
Reply to thread Export thread (mbox)