Просмотр исходного кода

Merge pull request #5001 from weseek/imprv/81-improvements-about-tag

imprv: improve tags functions
Yuki Takei 4 лет назад
Родитель
Сommit
0b77d0f7bc

+ 1 - 0
packages/app/package.json

@@ -135,6 +135,7 @@
     "re2": "^1.17.1",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
+    "react-tagcloud": "^2.1.1",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
     "rimraf": "^3.0.0",

+ 1 - 0
packages/app/resource/locales/en_US/translation.json

@@ -138,6 +138,7 @@
   "Shareable link": "Shareable link",
   "The whitelist of registration permission E-mail address": "The whitelist of registration permission E-mail address",
   "Add tags for this page": "Add tags for this page",
+  "Check All tags": "check all tags",
   "You have no tag, You can set tags on pages": "You have no tag, You can set tags on pages",
   "Show latest": "Show latest",
   "Load latest": "Load latest",

+ 1 - 0
packages/app/resource/locales/ja_JP/translation.json

@@ -137,6 +137,7 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Add tags for this page": "タグを付ける",
+  "Check All tags": "全てのタグをチェックする",
   "You have no tag, You can set tags on pages": "使用中のタグがありません",
   "Show latest": "最新のページを表示",
   "Load latest": "最新版を読み込む",

+ 1 - 0
packages/app/resource/locales/zh_CN/translation.json

@@ -146,6 +146,7 @@
 	"Shareable link": "可分享链接",
 	"The whitelist of registration permission E-mail address": "注册许可电子邮件地址的白名单",
 	"Add tags for this page": "添加标签",
+  "Check All tags": "检查所有标签",
 	"You have no tag, You can set tags on pages": "你没有标签,可以在页面上设置标签",
 	"Show latest": "显示最新",
 	"Load latest": "家在最新",

+ 26 - 2
packages/app/src/client/services/PageContainer.js

@@ -377,6 +377,7 @@ export default class PageContainer extends Container {
       revisionId: revision._id,
       revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
       remoteRevisionId: revision._id,
+      revisionAuthor: revision.author,
       revisionIdHackmdSynced: page.revisionHackmdSynced,
       hasDraftOnHackmd: page.hasDraftOnHackmd,
       markdown: revision.body,
@@ -404,8 +405,31 @@ export default class PageContainer extends Container {
       }
     }
 
-    // hidden input
-    $('input[name="revision_id"]').val(newState.revisionId);
+  }
+
+  /**
+   * update page meta data
+   * @param {object} page Page instance
+   * @param {object} revision Revision instance
+   * @param {String[]} tags Array of Tag
+   */
+  updatePageMetaData(page, revision, tags) {
+
+    const newState = {
+      revisionId: revision._id,
+      revisionCreatedAt: new Date(revision.createdAt).getTime() / 1000,
+      remoteRevisionId: revision._id,
+      revisionAuthor: revision.author,
+      revisionIdHackmdSynced: page.revisionHackmdSynced,
+      hasDraftOnHackmd: page.hasDraftOnHackmd,
+      updatedAt: page.updatedAt,
+    };
+    if (tags != null) {
+      newState.tags = tags;
+    }
+
+    this.setState(newState);
+
   }
 
   /**

+ 5 - 9
packages/app/src/components/Page/TagLabels.jsx

@@ -50,21 +50,17 @@ class TagLabels extends React.Component {
       appContainer, editorContainer, pageContainer, editorMode,
     } = this.props;
 
-    const { pageId } = pageContainer.state;
-
+    const { pageId, revisionId } = pageContainer.state;
     // It will not be reflected in the DB until the page is refreshed
     if (editorMode === EditorMode.Editor) {
       return editorContainer.setState({ tags: newTags });
     }
-
     try {
-      const { tags } = await appContainer.apiPost('/tags.update', { pageId, tags: newTags });
-
-      // update pageContainer.state
-      pageContainer.setState({ tags });
-      // update editorContainer.state
+      const { tags, savedPage } = await appContainer.apiPost('/tags.update', {
+        pageId, tags: newTags, revisionId,
+      });
       editorContainer.setState({ tags });
-
+      pageContainer.updatePageMetaData(savedPage, savedPage.revision, tags);
       toastSuccess('updated tags successfully');
     }
     catch (err) {

+ 10 - 7
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -56,13 +56,16 @@ function LargePageItem({ page }) {
   }
 
   const tags = page.tags;
-  const tagElements = tags.map((tag) => {
-    return (
-      <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
-        {tag.name}
-      </a>
-    );
-  });
+  // when tag document is deleted from database directly tags includes null
+  const tagElements = tags.includes(null)
+    ? <></>
+    : tags.map((tag) => {
+      return (
+        <a key={tag.name} href={`/_search?q=tag:${tag.name}`} className="grw-tag-label badge badge-secondary mr-2 small">
+          {tag.name}
+        </a>
+      );
+    });
 
   return (
     <li className="list-group-item py-3 px-0">

+ 4 - 0
packages/app/src/components/Sidebar/SidebarContents.tsx

@@ -5,6 +5,7 @@ import { useCurrentSidebarContents } from '~/stores/ui';
 
 import RecentChanges from './RecentChanges';
 import CustomSidebar from './CustomSidebar';
+import Tag from './Tag';
 
 type Props = {
 };
@@ -18,6 +19,9 @@ const SidebarContents: FC<Props> = (props: Props) => {
     case SidebarContentsType.RECENT:
       Contents = RecentChanges;
       break;
+    case SidebarContentsType.TAG:
+      Contents = Tag;
+      break;
     default:
       Contents = CustomSidebar;
   }

+ 1 - 1
packages/app/src/components/Sidebar/SidebarNav.tsx

@@ -79,7 +79,7 @@ const SidebarNav: FC<Props> = (props: Props) => {
       <div className="grw-sidebar-nav-primary-container">
         {!isSharedUser && <PrimaryItem contents={SidebarContentsType.CUSTOM} label="Custom Sidebar" iconName="code" onItemSelected={onItemSelected} />}
         {!isSharedUser && <PrimaryItem contents={SidebarContentsType.RECENT} label="Recent Changes" iconName="update" onItemSelected={onItemSelected} />}
-        {/* <PrimaryItem id="tag" label="Tags" iconName="icon-tag" /> */}
+        {!isSharedUser && <PrimaryItem contents={SidebarContentsType.TAG} label="Tags" iconName="tag" onItemSelected={onItemSelected} /> }
         {/* <PrimaryItem id="favorite" label="Favorite" iconName="icon-star" /> */}
       </div>
       <div className="grw-sidebar-nav-secondary-container">

+ 44 - 0
packages/app/src/components/Sidebar/Tag.tsx

@@ -0,0 +1,44 @@
+import React, { FC, useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import TagsList from '../TagsList';
+
+const Tag: FC = () => {
+  const { t } = useTranslation('');
+  const [isOnReload, setIsOnReload] = useState<boolean>(false);
+
+  useEffect(() => {
+    setIsOnReload(false);
+  }, [isOnReload]);
+
+  return (
+    <>
+      <div className="grw-sidebar-content-header p-3 d-flex">
+        <h3 className="mb-0">{t('Tags')}</h3>
+        <button
+          type="button"
+          className="btn btn-sm ml-auto grw-btn-reload-rc"
+          onClick={() => {
+            setIsOnReload(true);
+          }}
+        >
+          <i className="icon icon-reload"></i>
+        </button>
+      </div>
+      <div className="d-flex justify-content-center">
+        <button
+          className="btn btn-primary my-4"
+          type="button"
+          onClick={() => { window.location.href = '/tags' }}
+        >
+          {t('Check All tags')}
+        </button>
+      </div>
+      <div className="grw-container-convertible mb-5 pb-5">
+        <TagsList isOnReload={isOnReload} />
+      </div>
+    </>
+  );
+
+};
+
+export default Tag;

+ 38 - 0
packages/app/src/components/TagCloudBox.tsx

@@ -0,0 +1,38 @@
+import React, { FC } from 'react';
+
+import { TagCloud } from 'react-tagcloud';
+
+type Tag = {
+  _id: string,
+  name: string,
+  count: number,
+}
+
+type Props = {
+  tags:Tag[],
+  minSize?: number,
+  maxSize?: number,
+}
+
+const MIN_FONT_SIZE = 12;
+const MAX_FONT_SIZE = 36;
+
+const TagCloudBox: FC<Props> = (props:Props) => {
+  return (
+    <>
+      <TagCloud
+        minSize={props.minSize || MIN_FONT_SIZE}
+        maxSize={props.maxSize || MAX_FONT_SIZE}
+        tags={props.tags.map((tag) => {
+          return { value: tag.name, count: tag.count };
+        })}
+        style={{ cursor: 'pointer' }}
+        className="simple-cloud"
+        onClick={(target) => { window.location.href = `/_search?q=tag:${encodeURIComponent(target.value)}` }}
+      />
+    </>
+  );
+
+};
+
+export default TagCloudBox;

+ 44 - 18
packages/app/src/components/TagsList.jsx

@@ -4,6 +4,9 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 
 import PaginationWrapper from './PaginationWrapper';
+import TagCloudBox from './TagCloudBox';
+import { apiGet } from '../client/util/apiv1-client';
+import { toastError } from '../client/util/apiNotification';
 
 class TagsList extends React.Component {
 
@@ -25,6 +28,12 @@ class TagsList extends React.Component {
     await this.getTagList(1);
   }
 
+  async componentDidUpdate() {
+    if (this.props.isOnReload) {
+      await this.getTagList(this.state.activePage);
+    }
+  }
+
   async handlePage(selectedPage) {
     await this.getTagList(selectedPage);
   }
@@ -32,7 +41,14 @@ class TagsList extends React.Component {
   async getTagList(selectPageNumber) {
     const limit = this.state.pagingLimit;
     const offset = (selectPageNumber - 1) * limit;
-    const res = await this.props.crowi.apiGet('/tags.list', { limit, offset });
+    let res;
+
+    try {
+      res = await apiGet('/tags.list', { limit, offset });
+    }
+    catch (error) {
+      toastError(error);
+    }
 
     const totalTags = res.totalCount;
     const tagData = res.data;
@@ -67,34 +83,44 @@ class TagsList extends React.Component {
     const messageForNoTag = this.state.tagData.length ? null : <h3>{ t('You have no tag, You can set tags on pages') }</h3>;
 
     return (
-      <div className="text-center">
-        <div className="tag-list">
-          <ul className="list-group text-left">
-            {this.generateTagList(this.state.tagData)}
-          </ul>
-          {messageForNoTag}
-        </div>
-        <div className="tag-list-pagination">
-          <PaginationWrapper
-            activePage={this.state.activePage}
-            changePage={this.handlePage}
-            totalItemsCount={this.state.totalTags}
-            pagingLimit={this.state.pagingLimit}
-            size="sm"
-          />
+      <>
+        <header className="py-0">
+          <h1 className="title text-center mt-5 mb-3 font-weight-bold">{`${t('Tags')}(${this.state.totalTags})`}</h1>
+        </header>
+        <div className="row text-center">
+          <div className="col-12 mb-5 px-5">
+            <TagCloudBox tags={this.state.tagData} minSize={20} />
+          </div>
+          <div className="col-12 tag-list mb-4">
+            <ul className="list-group text-left">
+              {this.generateTagList(this.state.tagData)}
+            </ul>
+            {messageForNoTag}
+          </div>
+          <div className="col-12 tag-list-pagination">
+            <PaginationWrapper
+              activePage={this.state.activePage}
+              changePage={this.handlePage}
+              totalItemsCount={this.state.totalTags}
+              pagingLimit={this.state.pagingLimit}
+              align="center"
+              size="md"
+            />
+          </div>
         </div>
-      </div>
+      </>
     );
   }
 
 }
 
 TagsList.propTypes = {
-  crowi: PropTypes.object.isRequired,
+  isOnReload: PropTypes.bool,
   t: PropTypes.func.isRequired, // i18next
 };
 
 TagsList.defaultProps = {
+  isOnReload: false,
 };
 
 export default withTranslation()(TagsList);

+ 1 - 0
packages/app/src/interfaces/ui.ts

@@ -1,6 +1,7 @@
 export const SidebarContentsType = {
   CUSTOM: 'custom',
   RECENT: 'recent',
+  TAG: 'tag',
 } as const;
 export const AllSidebarContentsType = Object.values(SidebarContentsType);
 export type SidebarContentsType = typeof SidebarContentsType[keyof typeof SidebarContentsType];

+ 69 - 0
packages/app/src/migrations/20210921173042-add-is-trashed-field.js

@@ -0,0 +1,69 @@
+import mongoose from 'mongoose';
+
+import { getMongoUri, mongoOptions } from '@growi/core';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:add-column-is-trashed');
+const Page = require('~/server/models/page')();
+
+const LIMIT = 1000;
+
+/**
+ * set isPageTrashed of pagetagrelations included in updateIdList as true
+ */
+const updateIsPageTrashed = async(db, updateIdList) => {
+  await db.collection('pagetagrelations').updateMany(
+    { relatedPage: { $in: updateIdList } },
+    { $set: { isPageTrashed: true } },
+  );
+};
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    let updateDeletedPageIds = [];
+
+    // set isPageTrashed as false temporarily
+    await db.collection('pagetagrelations').updateMany(
+      {},
+      { $set: { isPageTrashed: false } },
+    );
+
+    for await (const deletedPage of Page.find({ status: Page.STATUS_DELETED }).select('_id').cursor()) {
+      updateDeletedPageIds.push(deletedPage._id);
+      // excute updateMany by one thousand ids
+      if (updateDeletedPageIds.length === LIMIT) {
+        await updateIsPageTrashed(db, updateDeletedPageIds);
+        updateDeletedPageIds = [];
+      }
+    }
+
+    // use ids that have not been updated
+    if (updateDeletedPageIds.length > 0) {
+      await updateIsPageTrashed(db, updateDeletedPageIds);
+    }
+
+    logger.info('Migration has successfully applied');
+
+  },
+
+  async down(db) {
+    logger.info('Rollback migration');
+    mongoose.connect(getMongoUri(), mongoOptions);
+
+    try {
+      await db.collection('pagetagrelations').updateMany(
+        {},
+        { $unset: { isPageTrashed: '' } },
+      );
+      logger.info('Migration has been successfully rollbacked');
+    }
+    catch (err) {
+      logger.error(err);
+      logger.info('Migration has failed');
+    }
+
+  },
+};

+ 45 - 25
packages/app/src/server/models/page-tag-relation.js

@@ -24,6 +24,13 @@ const schema = new mongoose.Schema({
     type: ObjectId,
     ref: 'Tag',
     required: true,
+    index: true,
+  },
+  isPageTrashed: {
+    type: Boolean,
+    default: false,
+    required: true,
+    index: true,
   },
 });
 // define unique compound index
@@ -39,27 +46,34 @@ schema.plugin(uniqueValidator);
 class PageTagRelation {
 
   static async createTagListWithCount(option) {
-    const Tag = mongoose.model('Tag');
     const opt = option || {};
     const sortOpt = opt.sortOpt || {};
-    const offset = opt.offset || 0;
-    const limit = opt.limit || 50;
+    const offset = opt.offset;
+    const limit = opt.limit;
 
-    const existTagIds = await Tag.find().distinct('_id');
     const tags = await this.aggregate()
-      .match({ relatedTag: { $in: existTagIds } })
-      .group({ _id: '$relatedTag', count: { $sum: 1 } })
-      .sort(sortOpt);
-
-    const list = tags.slice(offset, offset + limit);
-    const totalCount = tags.length;
-
-    return { list, totalCount };
+      .match({ isPageTrashed: false })
+      .lookup({
+        from: 'tags',
+        localField: 'relatedTag',
+        foreignField: '_id',
+        as: 'tag',
+      })
+      .unwind('$tag')
+      .group({ _id: '$relatedTag', count: { $sum: 1 }, name: { $first: '$tag.name' } })
+      .sort(sortOpt)
+      .skip(offset)
+      .limit(limit);
+
+    const totalCount = (await this.find({ isPageTrashed: false }).distinct('relatedTag')).length;
+
+    return { data: tags, totalCount };
   }
 
-  static async findByPageId(pageId) {
-    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('-_id relatedTag');
-    return relations.filter((relation) => { return relation.relatedTag !== null });
+  static async findByPageId(pageId, options = {}) {
+    const isAcceptRelatedTagNull = options.nullable || null;
+    const relations = await this.find({ relatedPage: pageId }).populate('relatedTag').select('relatedTag');
+    return isAcceptRelatedTagNull ? relations : relations.filter((relation) => { return relation.relatedTag !== null });
   }
 
   static async listTagNamesByPage(pageId) {
@@ -125,17 +139,23 @@ class PageTagRelation {
     const Tag = mongoose.model('Tag');
 
     // get relations for this page
-    const relations = await this.findByPageId(pageId);
-
-    // unlink relations
-    const unlinkTagRelations = relations.filter((relation) => { return !tags.includes(relation.relatedTag.name) });
-    const bulkDeletePromise = this.deleteMany({
-      relatedPage: pageId,
-      relatedTag: { $in: unlinkTagRelations.map((relation) => { return relation.relatedTag._id }) },
+    const relations = await this.findByPageId(pageId, { nullable: true });
+
+    const unlinkTagRelationIds = [];
+    const relatedTagNames = [];
+
+    relations.forEach((relation) => {
+      if (relation.relatedTag == null) {
+        unlinkTagRelationIds.push(relation._id);
+      }
+      else {
+        relatedTagNames.push(relation.relatedTag.name);
+        if (!tags.includes(relation.relatedTag.name)) {
+          unlinkTagRelationIds.push(relation._id);
+        }
+      }
     });
-
-    // filter tags to create
-    const relatedTagNames = relations.map((relation) => { return relation.relatedTag.name });
+    const bulkDeletePromise = this.deleteMany({ _id: { $in: unlinkTagRelationIds } });
     // find or create tags
     const tagsToCreate = tags.filter((tag) => { return !relatedTagNames.includes(tag) });
     const tagEntities = await Tag.findOrCreateMany(tagsToCreate);

+ 2 - 0
packages/app/src/server/routes/apiv3/pages.js

@@ -197,7 +197,9 @@ module.exports = (crowi) => {
 
   async function saveTagsAction({ createdPage, pageTags }) {
     if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
       await PageTagRelation.updatePageTags(createdPage.id, pageTags);
+      tagEvent.emit('update', createdPage, pageTags);
       return PageTagRelation.listTagNamesByPage(createdPage.id);
     }
 

+ 2 - 0
packages/app/src/server/routes/page.js

@@ -851,8 +851,10 @@ module.exports = function(crowi, app) {
 
     let savedTags;
     if (pageTags != null) {
+      const tagEvent = crowi.event('tag');
       await PageTagRelation.updatePageTags(pageId, pageTags);
       savedTags = await PageTagRelation.listTagNamesByPage(pageId);
+      tagEvent.emit('update', page, savedTags);
     }
 
     const result = {

+ 16 - 22
packages/app/src/server/routes/tag.js

@@ -136,15 +136,27 @@ module.exports = function(crowi, app) {
    */
   api.update = async function(req, res) {
     const Page = crowi.model('Page');
+    const User = crowi.model('User');
     const PageTagRelation = crowi.model('PageTagRelation');
+    const Revision = crowi.model('Revision');
     const tagEvent = crowi.event('tag');
     const pageId = req.body.pageId;
     const tags = req.body.tags;
+    const userId = req.user._id;
+    const revisionId = req.body.revisionId;
 
     const result = {};
     try {
       // TODO GC-1921 consider permission
       const page = await Page.findById(pageId);
+      const user = await User.findById(userId);
+
+      if (!await Page.isAccessiblePageByViewer(page._id, user)) {
+        return res.json(ApiResponse.error("You don't have permission to update this page."));
+      }
+
+      const previousRevision = await Revision.findById(revisionId);
+      result.savedPage = await Page.updatePage(page, previousRevision.body, previousRevision.body, req.user);
       await PageTagRelation.updatePageTags(pageId, tags);
       result.tags = await PageTagRelation.listTagNamesByPage(pageId);
 
@@ -203,32 +215,14 @@ module.exports = function(crowi, app) {
   api.list = async function(req, res) {
     const limit = +req.query.limit || 50;
     const offset = +req.query.offset || 0;
-    const sortOpt = { count: -1 };
+    const sortOpt = { count: -1, _id: -1 };
     const queryOptions = { offset, limit, sortOpt };
-    const result = {};
 
     try {
-      // get tag list contains id and count properties
-      const listData = await PageTagRelation.createTagListWithCount(queryOptions);
-      const ids = listData.list.map((obj) => { return obj._id });
-
-      // get tag documents for add name data to the list
-      const tags = await Tag.find({ _id: { $in: ids } });
-
-      // add name property
-      result.data = listData.list.map((elm) => {
-        const data = {};
-        const tag = tags.find((tag) => { return (tag.id === elm._id.toString()) });
-
-        data._id = elm._id;
-        data.name = tag.name;
-        data.count = elm.count; // the number of related pages
-        return data;
-      });
-
-      result.totalCount = listData.totalCount;
+      // get tag list contains id name and count properties
+      const tagsWithCount = await PageTagRelation.createTagListWithCount(queryOptions);
 
-      return res.json(ApiResponse.success(result));
+      return res.json(ApiResponse.success(tagsWithCount));
     }
     catch (err) {
       return res.json(ApiResponse.error(err));

+ 6 - 0
packages/app/src/server/service/page.js

@@ -23,6 +23,7 @@ class PageService {
   constructor(crowi) {
     this.crowi = crowi;
     this.pageEvent = crowi.event('page');
+    this.tagEvent = crowi.event('tag');
 
     // init
     this.initPageEvent();
@@ -379,6 +380,7 @@ class PageService {
     if (originTags != null) {
       await PageTagRelation.updatePageTags(createdPage.id, originTags);
       savedTags = await PageTagRelation.listTagNamesByPage(createdPage.id);
+      this.tagEvent.emit('update', createdPage, savedTags);
     }
 
     const result = serializePageSecurely(createdPage);
@@ -513,6 +515,7 @@ class PageService {
 
   async deletePage(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
 
     const newPath = Page.getDeletedPageName(page.path);
@@ -537,6 +540,7 @@ class PageService {
         path: newPath, status: Page.STATUS_DELETED, deleteUser: user._id, deletedAt: Date.now(),
       },
     }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: true } });
     const body = `redirect ${newPath}`;
     await Page.create(page.path, body, user, { redirectTo: newPath });
 
@@ -755,6 +759,7 @@ class PageService {
 
   async revertDeletedPage(page, user, options = {}, isRecursively = false) {
     const Page = this.crowi.model('Page');
+    const PageTagRelation = this.crowi.model('PageTagRelation');
     const Revision = this.crowi.model('Revision');
 
     const newPath = Page.getRevertDeletedPageName(page.path);
@@ -783,6 +788,7 @@ class PageService {
         path: newPath, status: Page.STATUS_PUBLISHED, lastUpdateUser: user._id, deleteUser: null, deletedAt: null,
       },
     }, { new: true });
+    await PageTagRelation.updateMany({ relatedPage: page._id }, { $set: { isPageTrashed: false } });
     await Revision.updateMany({ path: page.path }, { $set: { path: newPath } });
 
     return updatedPage;

+ 2 - 5
packages/app/src/server/views/tags.html

@@ -5,11 +5,8 @@
 {% block html_base_css %}tags-page{% endblock %}
 
 {% block layout_main %}
-<header class="py-0">
-  <h1 class="title">{{ t('Tags') }}</h1>
-</header>
-
-<div class="container-fluid">
+<div id="grw-fav-sticky-trigger" class="sticky-top"></div>
+<div class="grw-container-convertible">
   <div class="row">
     <div id="main" class="main mt-3 col-md-12 tags-page">
       <div class="" id="tags-page"></div>

+ 15 - 1
yarn.lock

@@ -16369,7 +16369,7 @@ randombytes@^2.1.0:
   dependencies:
     safe-buffer "^5.1.0"
 
-randomcolor@>=0.5.4:
+randomcolor@>=0.5.4, randomcolor@^0.5.4:
   version "0.5.4"
   resolved "https://registry.yarnpkg.com/randomcolor/-/randomcolor-0.5.4.tgz#df615b13f25b89ea58c5f8f72647f0a6f07adcc3"
   integrity sha512-nYd4nmTuuwMFzHL6W+UWR5fNERGZeVauho8mrJDUSXdNDbao4rbrUwhuLgKC/j8VCS5+34Ria8CsTDuBjrIrQA==
@@ -16633,6 +16633,15 @@ react-scrolllock@^1.0.9:
     create-react-class "^15.5.2"
     prop-types "^15.5.10"
 
+react-tagcloud@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/react-tagcloud/-/react-tagcloud-2.1.1.tgz#b8883634f76b5681c91a178689070efa0d442657"
+  integrity sha512-cM96jzUOKQqu2qlzwcO91r239MSDbFiAslFNk4Hja3MaZ4Y89goIzbTyXZwonkeJck1zY5wkNhJYeJ8YSdOwXg==
+  dependencies:
+    prop-types "^15.6.2"
+    randomcolor "^0.5.4"
+    shuffle-array "^1.0.1"
+
 react-transition-group@^2.2.0:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.2.1.tgz#e9fb677b79e6455fd391b03823afe84849df4a10"
@@ -18041,6 +18050,11 @@ should@^13.2.1:
     should-type-adaptors "^1.0.1"
     should-util "^1.0.0"
 
+shuffle-array@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/shuffle-array/-/shuffle-array-1.0.1.tgz#c4ff3cfe74d16f93730592301b25e6577b12898b"
+  integrity sha1-xP88/nTRb5NzBZIwGyXmV3sSiYs=
+
 side-channel@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"