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

Merge commit '859afde6c97f5ce64e3f8e2adf284485e462574a' into fix/tag-form-bug

# Conflicts:
#	src/client/js/components/PageTagForm.jsx
yusuketk 7 лет назад
Родитель
Сommit
369ac0bfc7

+ 1 - 1
package.json

@@ -195,7 +195,7 @@
     "prettier-stylelint": "^0.4.2",
     "react": "^16.8.3",
     "react-bootstrap": "^0.32.1",
-    "react-bootstrap-typeahead": "^3.3.4",
+    "react-bootstrap-typeahead": "^3.4.2",
     "react-clipboard.js": "^2.0.0",
     "react-codemirror2": "^5.1.0",
     "react-dom": "^16.8.3",

+ 1 - 0
resource/locales/en-US/translation.json

@@ -11,6 +11,7 @@
   "Cancel": "Cancel",
   "Create": "Create",
   "Admin": "Admin",
+  "Tags": "Tags",
   "New": "New",
   "Shortcuts": "Shortcuts",
   "eg": "e.g.",

+ 8 - 0
resource/locales/ja/translation.json

@@ -11,6 +11,7 @@
   "Cancel": "キャンセル",
   "Create": "作成",
   "Admin": "管理",
+  "Tags": "タグ",
   "New": "作成",
   "Shortcuts": "ショートカット",
   "eg": "例:",
@@ -111,6 +112,7 @@
   "Shareable link": "このページの共有用URL",
   "The whitelist of registration permission E-mail address": "登録許可メールアドレスの<br>ホワイトリスト",
   "Selecting authentication mechanism": "認証機構選択",
+  "Add tags for this page": "タグを付ける",
 
 
 
@@ -208,6 +210,12 @@
     },
     "exclude_prefix": {
       "desc": "ページ名が {{path}} から始まるページを除外"
+    },
+    "tag": {
+      "desc": "{{tag}} というタグを含むページを検索"
+    },
+    "exclude_tag": {
+      "desc": "{{tag}} というタグを含むページを除外"
     }
   },
   "search": {

+ 7 - 3
src/client/js/app.js

@@ -17,6 +17,7 @@ import GrowiRenderer from './util/GrowiRenderer';
 
 import HeaderSearchBox from './components/HeaderSearchBox';
 import SearchPage from './components/SearchPage';
+import TagsList from './components/TagsList';
 import PageEditor from './components/PageEditor';
 // eslint-disable-next-line import/no-duplicates
 import OptionsSelector from './components/PageEditor/OptionsSelector';
@@ -31,7 +32,7 @@ import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment from './components/PageAttachment';
 import PageStatusAlert from './components/PageStatusAlert';
 import RevisionPath from './components/Page/RevisionPath';
-import TagViewer from './components/Page/TagViewer';
+import TagLabel from './components/Page/TagLabel';
 import RevisionUrl from './components/Page/RevisionUrl';
 import BookmarkButton from './components/BookmarkButton';
 import LikeButton from './components/LikeButton';
@@ -297,6 +298,8 @@ const componentMappings = {
   'bookmark-button': <BookmarkButton pageId={pageId} crowi={crowi} />,
   'bookmark-button-lg': <BookmarkButton pageId={pageId} crowi={crowi} size="lg" />,
 
+  'tags-page': <TagsList crowi={crowi} />,
+
   'create-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} addTrailingSlash />,
   'rename-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
   'duplicate-page-name-input': <PagePathAutoComplete crowi={crowi} initializedPath={pagePath} />,
@@ -310,8 +313,9 @@ if (pageId) {
 if (pagePath) {
   componentMappings.page = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} onSaveWithShortcut={saveWithShortcut} />;
   componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} crowi={crowi} />;
-  componentMappings['tag-viewer'] = <TagViewer crowi={crowi} pageId={pageId} sendTagData={setTagData} />;
-  componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
+  // [TODO] there is a need to decide the destination of RevisionUrl
+  componentMappings['revision-url'] = <RevisionUrl crowi={crowi} pageId={pageId} pagePath={pagePath} sendTagData={setTagData} />;
+  componentMappings['tag-label'] = <I18nextProvider i18n={i18n}><TagLabel crowi={crowi} pageId={pageId} sendTagData={setTagData} /></I18nextProvider>;
 }
 
 Object.keys(componentMappings).forEach((key) => {

+ 27 - 32
src/client/js/components/Page/TagViewer.jsx → src/client/js/components/Page/TagLabel.jsx

@@ -1,16 +1,11 @@
 import React from 'react';
 import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
 import Button from 'react-bootstrap/es/Button';
-import OverlayTrigger from 'react-bootstrap/es/OverlayTrigger';
-import Tooltip from 'react-bootstrap/es/Tooltip';
 import Modal from 'react-bootstrap/es/Modal';
 import PageTagForm from '../PageTagForm';
 
-/**
- * show tag labels on view and edit tag button on edit
- * tag labels on view is not implemented yet(GC-1391)
- */
-export default class TagViewer extends React.Component {
+class TagLabel extends React.Component {
 
   constructor(props) {
     super(props);
@@ -37,7 +32,6 @@ export default class TagViewer extends React.Component {
     }
   }
 
-  // receive new tag from PageTagForm component
   addNewTag(newPageTags) {
     this.setState({ newPageTags });
   }
@@ -56,31 +50,29 @@ export default class TagViewer extends React.Component {
   }
 
   render() {
-    const tagEditorButtonStyle = {
-      marginLeft: '0.2em',
-      padding: '0 2px',
-    };
+    const tags = [];
+    const { t } = this.props;
+
+    for (let i = 0; i < this.state.currentPageTags.length; i++) {
+      tags.push(
+        <i className="tag-icon icon-tag"></i>,
+        <a className="tag-name text-muted" href={`/_search?q=tag:${this.state.currentPageTags[i]}`} key={i.toString()}>{this.state.currentPageTags[i]}</a>,
+      );
+
+    }
 
     return (
-      <span className="btn-tag-container">
-        <OverlayTrigger
-          key="tooltip"
-          placement="bottom"
-          overlay={(
-            <Tooltip id="tag-edit-button-tooltip" className="tag-tooltip">
-              {this.state.currentPageTags.length !== 0 ? this.state.currentPageTags.join() : 'tag is not set' }
-            </Tooltip>
-          )}
+      <div className="tag-viewer text-muted">
+        {this.state.currentPageTags.length === 0 && (
+          <a className="display-of-notag text-muted" onClick={this.handleShowModal}>{ t('Add tags for this page') }</a>
+        )}
+        {tags}
+        <i
+          className="manage-tags icon-plus"
+          onClick={this.handleShowModal}
+
         >
-          <Button
-            variant="primary"
-            onClick={this.handleShowModal}
-            className="btn btn-default btn-tag"
-            style={tagEditorButtonStyle}
-          >
-            <i className="fa fa-tags"></i>{this.state.currentPageTags.length}
-          </Button>
-        </OverlayTrigger>
+        </i>
         <Modal show={this.state.isOpenModal} onHide={this.handleCloseModal} id="editTagModal">
           <Modal.Header closeButton className="bg-primary">
             <Modal.Title className="text-white">Page Tag</Modal.Title>
@@ -94,14 +86,17 @@ export default class TagViewer extends React.Component {
             </Button>
           </Modal.Footer>
         </Modal>
-      </span>
+      </div>
     );
   }
 
 }
 
-TagViewer.propTypes = {
+TagLabel.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
   crowi: PropTypes.object.isRequired,
   pageId: PropTypes.string,
   sendTagData: PropTypes.func.isRequired,
 };
+
+export default withTranslation()(TagLabel);

+ 8 - 0
src/client/js/components/SearchForm.js

@@ -72,6 +72,14 @@ export default class SearchForm extends React.Component {
             <th className="text-right pt-2"><code>-prefix:/user/</code></th>
             <td><h6 className="m-0 pt-1">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
           </tr>
+          <tr>
+            <th className="text-right pt-2"><code>tag:wiki</code></th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.tag.desc', { tag: 'wiki' }) }</h6></td>
+          </tr>
+          <tr>
+            <th className="text-right pt-2"><code>-tag:wiki</code></th>
+            <td><h6 className="m-0 pt-1">{ t('search_help.exclude_tag.desc', { tag: 'wiki' }) }</h6></td>
+          </tr>
         </tbody>
       </table>
     );

+ 4 - 1
src/client/js/components/SearchPage/SearchResultList.js

@@ -17,7 +17,10 @@ export default class SearchResultList extends React.Component {
     const resultList = this.props.pages.map((page) => {
       return (
         <div id={page._id} key={page._id} className="search-result-page">
-          <h2><a href={page.path}>{page.path}</a></h2>
+          <h2 className="inline"><a href={page.path}>{page.path}</a></h2>
+          { page.tags.length > 0 && (
+            <span><i className="tag-icon icon-tag"></i> {page.tags.join(', ')}</span>
+          )}
           <RevisionLoader
             crowi={this.props.crowi}
             crowiRenderer={this.growiRenderer}

+ 1 - 0
src/client/js/components/SearchTypeahead.js

@@ -173,6 +173,7 @@ export default class SearchTypeahead extends React.Component {
       <div className="search-typeahead">
         <AsyncTypeahead
           {...this.props}
+          id="async-typeahead"
           ref={(c) => { this.typeahead = c }}
           inputProps={inputProps}
           isLoading={this.state.isLoading}

+ 185 - 0
src/client/js/components/TagsList.jsx

@@ -0,0 +1,185 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Pagination from 'react-bootstrap/lib/Pagination';
+
+export default class TagsList extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      tagData: [],
+      activePage: 1,
+      paginationNumbers: {},
+    };
+
+    this.calculatePagination = this.calculatePagination.bind(this);
+  }
+
+  async componentWillMount() {
+    await this.getTagList(1);
+  }
+
+  async getTagList(selectPageNumber) {
+    const limit = 10;
+    const offset = (selectPageNumber - 1) * limit;
+    const res = await this.props.crowi.apiGet('/tags.list', { limit, offset });
+
+    const totalCount = res.totalCount;
+    const tagData = res.data;
+    const activePage = selectPageNumber;
+    const paginationNumbers = this.calculatePagination(limit, totalCount, activePage);
+
+    this.setState({
+      tagData,
+      activePage,
+      paginationNumbers,
+    });
+  }
+
+  calculatePagination(limit, totalCount, activePage) {
+    // calc totalPageNumber
+    const totalPage = Math.floor(totalCount / limit) + (totalCount % limit === 0 ? 0 : 1);
+
+    let paginationStart = activePage - 2;
+    let maxViewPageNum = activePage + 2;
+    // pagination Number area size = 5 , pageNumber calculate in here
+    // activePage Position calculate ex. 4 5 [6] 7 8 (Page8 over is Max), 3 4 5 [6] 7 (Page7 is Max)
+    if (paginationStart < 1) {
+      const diff = 1 - paginationStart;
+      paginationStart += diff;
+      maxViewPageNum = Math.min(totalPage, maxViewPageNum + diff);
+    }
+    if (maxViewPageNum > totalPage) {
+      const diff = maxViewPageNum - totalPage;
+      maxViewPageNum -= diff;
+      paginationStart = Math.max(1, paginationStart - diff);
+    }
+
+    return {
+      totalPage,
+      paginationStart,
+      maxViewPageNum,
+    };
+  }
+
+  /**
+   * generate Elements of Tag
+   *
+   * @param {any} pages Array of pages Model Obj
+   *
+   */
+  generateTagList(tagData) {
+    return tagData.map((data) => {
+      return (
+        <a key={data.name} href={`/_search?q=tag:${data.name}`} className="list-group-item">
+          <p className="float-left my-0">{data.name}</p>
+        </a>
+      );
+    });
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set << & <
+   */
+  generateFirstPrev(activePage) {
+    const paginationItems = [];
+    if (activePage !== 1) {
+      paginationItems.push(
+        <Pagination.First key="first" onClick={() => { return this.getTagList(1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" onClick={() => { return this.getTagList(this.state.activePage - 1) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.First key="first" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Prev key="prev" disabled />,
+      );
+
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   *  ex. << < 4 5 6 7 8 > >>, << < 1 2 3 4 > >>
+   * this function set  numbers
+   */
+  generatePaginations(activePage, paginationStart, maxViewPageNum) {
+    const paginationItems = [];
+    for (let number = paginationStart; number <= maxViewPageNum; number++) {
+      paginationItems.push(
+        <Pagination.Item key={number} active={number === activePage} onClick={() => { return this.getTagList(number) }}>{number}</Pagination.Item>,
+      );
+    }
+    return paginationItems;
+  }
+
+  /**
+   * generate Elements of Pagination First Prev
+   * ex.  <<   <   1  2  3  >  >>
+   * this function set > & >>
+   */
+  generateNextLast(activePage, totalPage) {
+    const paginationItems = [];
+    if (totalPage !== activePage) {
+      paginationItems.push(
+        <Pagination.Next key="next" onClick={() => { return this.getTagList(this.state.activePage + 1) }} />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" onClick={() => { return this.getTagList(totalPage) }} />,
+      );
+    }
+    else {
+      paginationItems.push(
+        <Pagination.Next key="next" disabled />,
+      );
+      paginationItems.push(
+        <Pagination.Last key="last" disabled />,
+      );
+
+    }
+    return paginationItems;
+  }
+
+  render() {
+    const tagList = this.generateTagList(this.state.tagData);
+
+    const paginationItems = [];
+
+    const activePage = this.state.activePage;
+    const totalPage = this.state.paginationNumbers.totalPage;
+    const paginationStart = this.state.paginationNumbers.paginationStart;
+    const maxViewPageNum = this.state.paginationNumbers.maxViewPageNum;
+    const firstPrevItems = this.generateFirstPrev(activePage);
+    paginationItems.push(firstPrevItems);
+    const paginations = this.generatePaginations(activePage, paginationStart, maxViewPageNum);
+    paginationItems.push(paginations);
+    const nextLastItems = this.generateNextLast(activePage, totalPage);
+    paginationItems.push(nextLastItems);
+
+    return (
+      <div>
+        <ul className="list-group mx-4">{tagList}</ul>
+        <div className="text-center">
+          <Pagination>{paginationItems}</Pagination>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+TagsList.propTypes = {
+  crowi: PropTypes.object.isRequired,
+};
+
+TagsList.defaultProps = {
+};

+ 6 - 5
src/client/styles/scss/_search.scss

@@ -45,7 +45,7 @@
         font-size: 0.9em;
         color: #999;
 
-        >span {
+        > span {
           margin-right: 0.3rem;
         }
       }
@@ -114,7 +114,6 @@
 }
 
 .search-sidebar {
-
   .search-form,
   .form-group,
   .rbt-input.form-control,
@@ -157,7 +156,7 @@
       }
 
       .nav {
-        >li {
+        > li {
           padding: 2px 8px;
 
           &.active {
@@ -187,12 +186,14 @@
       // adjust for anchor links by the height of fixed .search-page-input
       margin-top: -48px;
 
-      >h2 {
+      > h2 {
+        display: inline;
+        margin-right: 10px;
         font-size: 20px;
         line-height: 1em;
       }
 
-      &:first-child>h2 {
+      &:first-child > h2 {
         margin-top: 0;
       }
 

+ 25 - 0
src/client/styles/scss/_tag.scss

@@ -0,0 +1,25 @@
+.manage-tags {
+  margin-left: 5px;
+  font-size: 12px;
+  cursor: pointer;
+}
+
+.tag-icon:not(:first-child) {
+  margin-left: 5px;
+}
+
+.display-of-notag,
+.tag-icon {
+  font-size: 10px;
+}
+
+.tag-name {
+  margin-left: 1px;
+  font-size: 10px;
+}
+
+#editTagModal {
+  .form-control {
+    height: auto;
+  }
+}

+ 4 - 0
src/client/styles/scss/style-app.scss

@@ -39,6 +39,7 @@
 @import 'user_growi';
 @import 'handsontable';
 @import 'wiki';
+@import 'tag';
 
 /*
  * for Guest User Mode
@@ -64,14 +65,17 @@
     width: 48px;
     height: 48px;
   }
+
   &.picture-md {
     width: 24px;
     height: 24px;
   }
+
   &.picture-sm {
     width: 18px;
     height: 18px;
   }
+
   &.picture-xs {
     width: 14px;
     height: 14px;

+ 15 - 0
src/server/models/page-tag-relation.js

@@ -37,6 +37,21 @@ class PageTagRelation {
     }
   }
 
+  static async createTagListWithCount(option) {
+    const opt = option || {};
+    const sortOpt = opt.sortOpt || {};
+    const offset = opt.offset || 0;
+    const limit = opt.limit || 50;
+
+    const list = await this.aggregate()
+      .group({ _id: '$relatedTag', count: { $sum: 1 } })
+      .sort(sortOpt)
+      .skip(offset)
+      .limit(limit);
+
+    return list;
+  }
+
 }
 
 module.exports = function() {

+ 1 - 1
src/server/models/page.js

@@ -579,7 +579,7 @@ module.exports = function(crowi) {
       /\s+\/\s+/, // avoid miss in renaming
       /.+\/edit$/,
       /.+\.md$/,
-      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments)(\/.*|$)/,
+      /^\/(installer|register|login|logout|admin|me|files|trash|paste|comments|tags)(\/.*|$)/,
     ];
 
     let isCreatable = true;

+ 2 - 1
src/server/models/tag.js

@@ -32,7 +32,8 @@ class Tag {
 
 }
 
-module.exports = function() {
+module.exports = function(crowi) {
+  Tag.crowi = crowi;
   schema.loadClass(Tag);
   const model = mongoose.model('Tag', schema);
   return model;

+ 2 - 0
src/server/routes/index.js

@@ -202,6 +202,8 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.revertRemove' , loginRequired(crowi, app) , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.duplicate', accessTokenParser, loginRequired(crowi, app), csrf, page.api.duplicate);
+  app.get('/tags'                     , loginRequired(crowi, app, false), tag.showPage);
+  app.get('/_api/tags.list'           , accessTokenParser, loginRequired(crowi, app, false), tag.api.list);
   app.get('/_api/tags.search'         , accessTokenParser, loginRequired(crowi, app, false), tag.api.search);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);

+ 9 - 1
src/server/routes/search.js

@@ -69,7 +69,15 @@ module.exports = function(crowi, app) {
         scoreMap[esPage._id] = esPage._score;
       }
 
-      const findResult = await Page.findListByPageIds(esResult.data);
+      const ids = esResult.data.map((page) => { return page._id });
+      const findResult = await Page.findListByPageIds(ids);
+
+      // add tag data to result pages
+      findResult.pages.map((page) => {
+        const data = esResult.data.find((data) => { return page.id === data._id });
+        page._doc.tags = data._source.tag_names;
+        return page;
+      });
 
       result.meta = esResult.meta;
       result.totalCount = findResult.totalCount;

+ 48 - 0
src/server/routes/tag.js

@@ -1,12 +1,16 @@
 module.exports = function(crowi, app) {
 
   const Tag = crowi.model('Tag');
+  const PageTagRelation = crowi.model('PageTagRelation');
   const ApiResponse = require('../util/apiResponse');
   const actions = {};
   const api = {};
 
   actions.api = api;
 
+  actions.showPage = function(req, res) {
+    return res.render('tags');
+  };
 
   /**
    * @api {get} /tags.search search tags
@@ -21,5 +25,49 @@ module.exports = function(crowi, app) {
     return res.json(ApiResponse.success({ tags }));
   };
 
+  /**
+   * @api {get} /tags.list get tagnames and count pages relate each tag
+   * @apiName tagList
+   * @apiGroup Tag
+   *
+   * @apiParam {Number} limit
+   * @apiParam {Number} offset
+   */
+  api.list = async function(req, res) {
+    const limit = +req.query.limit || 50;
+    const offset = +req.query.offset || 0;
+    const sortOpt = { count: -1 };
+    const queryOptions = { offset, limit, sortOpt };
+    const result = {};
+
+    try {
+      // get tag list contains id and count properties
+      const list = await PageTagRelation.createTagListWithCount(queryOptions);
+      const ids = 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 = 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 = await Tag.count();
+
+      return res.json(ApiResponse.success(result));
+    }
+    catch (err) {
+      return res.json(ApiResponse.error(err));
+    }
+  };
+
+
   return actions;
 };

+ 2 - 2
src/server/util/search.js

@@ -387,7 +387,7 @@ SearchClient.prototype.search = async function(query) {
 
 SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
   // getting path by default is almost for debug
-  let fields = ['path', 'bookmark_count'];
+  let fields = ['path', 'bookmark_count', 'tag_names'];
   if (option) {
     fields = option.fields || fields;
   }
@@ -408,7 +408,7 @@ SearchClient.prototype.createSearchQuerySortedByUpdatedAt = function(option) {
 };
 
 SearchClient.prototype.createSearchQuerySortedByScore = function(option) {
-  let fields = ['path', 'bookmark_count'];
+  let fields = ['path', 'bookmark_count', 'tag_names'];
   if (option) {
     fields = option.fields || fields;
   }

+ 5 - 3
src/server/views/layout-growi/widget/header.html

@@ -8,9 +8,11 @@
       </div>
       <div class="title-container">
         <h1 class="title" id="revision-path"></h1>
-        {# [TODO] GC-1391 activate #}
-        {# <h1 class="title" id="tag-viewer"></h1> #}
-        <div id="revision-url" class="url-line"></div>
+
+        <!-- [TODO] commentout Until the destination is decided -->
+        <!-- <div id="revision-url" class="url-line"></div> -->
+        <div class="title" id="tag-label"></div>
+
       </div>
       {% if page %}
       {% include '../../widget/header-buttons.html' %}

+ 7 - 0
src/server/views/layout/layout.html

@@ -152,6 +152,13 @@
             <i class="ti-menu"></i>
           </a>
         </li>
+        <li>
+          <li class="nav-item-admin">
+            <a href="/tags">
+              <i class="tag-icon icon-tag"></i><span>{{ t('Tags') }}</span>
+            </a>
+          </li>
+        </li>
         <li>
           {% if searchConfigured() %}
           <div class="navbar-form navbar-left search-top" role="search" id="search-top"></div>

+ 26 - 0
src/server/views/tags.html

@@ -0,0 +1,26 @@
+{% extends 'layout/layout.html' %}
+
+{% block layout_main %}
+<div class="container-fluid">
+  <div class="row bg-title hidden-print">
+    <div class="col-xs-12 header-container">
+      {% block content_header %}
+      <div class="header-wrap">
+        <header id="page-header">
+          <h1 id="admin-title" class="title">Tags</h1>
+        </header>
+      </div>
+      {% endblock %}
+    </div>
+  </div>
+  <div class="row">
+    <div id="main" class="main m-t-15 col-md-12 tags-page">
+      <div class="" id="tags-page"></div>
+    </div>
+  </div>
+</div><!-- /.container-fluid -->
+
+<footer class="footer">
+  {% include 'widget/system-version.html' %}
+</footer>
+{% endblock %} {# layout_main #}

Разница между файлами не показана из-за своего большого размера
+ 0 - 137
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов