]> git.immae.eu Git - github/shaarli/Shaarli.git/commitdiff
Merge pull request #835 from ArthurHoaro/feature/tag-cloud
authorArthurHoaro <arthur@hoa.ro>
Thu, 25 May 2017 13:28:26 +0000 (15:28 +0200)
committerGitHub <noreply@github.com>
Thu, 25 May 2017 13:28:26 +0000 (15:28 +0200)
Adds a taglist view with edit/delete buttons

12 files changed:
application/Router.php
application/Utils.php
index.php
tests/UtilsTest.php
tpl/default/changetag.html
tpl/default/css/shaarli.css
tpl/default/js/shaarli.js
tpl/default/page.footer.html
tpl/default/tag.cloud.html [moved from tpl/default/tagcloud.html with 97% similarity]
tpl/default/tag.list.html [new file with mode: 0644]
tpl/default/tag.sort.html [new file with mode: 0644]
tpl/vintage/tag.cloud.html [moved from tpl/vintage/tagcloud.html with 100% similarity]

index c9a519120eecf3274c1b66a7df5f4e2deacbec95..4df0387c56160f0f4a6998986fb6497d8f745139 100644 (file)
@@ -13,6 +13,8 @@ class Router
 
     public static $PAGE_TAGCLOUD = 'tagcloud';
 
+    public static $PAGE_TAGLIST = 'taglist';
+
     public static $PAGE_DAILY = 'daily';
 
     public static $PAGE_FEED_ATOM = 'atom';
@@ -45,6 +47,8 @@ class Router
 
     public static $PAGE_SAVE_PLUGINSADMIN = 'save_pluginadmin';
 
+    public static $GET_TOKEN = 'token';
+
     /**
      * Reproducing renderPage() if hell, to avoid regression.
      *
@@ -77,6 +81,10 @@ class Router
             return self::$PAGE_TAGCLOUD;
         }
 
+        if (startsWith($query, 'do='. self::$PAGE_TAGLIST)) {
+            return self::$PAGE_TAGLIST;
+        }
+
         if (startsWith($query, 'do='. self::$PAGE_OPENSEARCH)) {
             return self::$PAGE_OPENSEARCH;
         }
@@ -142,6 +150,10 @@ class Router
             return self::$PAGE_SAVE_PLUGINSADMIN;
         }
 
+        if (startsWith($query, 'do='. self::$GET_TOKEN)) {
+            return self::$GET_TOKEN;
+        }
+
         return self::$PAGE_LINKLIST;
     }
 }
index ab463af9749cf3a597305fa37e1e91dbcf26046f..9d0ebc5ea3a86c68ac7693c9f793855866658d0c 100644 (file)
@@ -435,3 +435,34 @@ function get_max_upload_size($limitPost, $limitUpload, $format = true)
     $maxsize = min($size1, $size2);
     return $format ? human_bytes($maxsize) : $maxsize;
 }
+
+/**
+ * Sort the given array alphabetically using php-intl if available.
+ * Case sensitive.
+ *
+ * Note: doesn't support multidimensional arrays
+ *
+ * @param array $data    Input array, passed by reference
+ * @param bool  $reverse Reverse sort if set to true
+ * @param bool  $byKeys  Sort the array by keys if set to true, by value otherwise.
+ */
+function alphabetical_sort(&$data, $reverse = false, $byKeys = false)
+{
+    $callback = function($a, $b) use ($reverse) {
+        // Collator is part of PHP intl.
+        if (class_exists('Collator')) {
+            $collator = new Collator(setlocale(LC_COLLATE, 0));
+            if (!intl_is_failure(intl_get_error_code())) {
+                return $collator->compare($a, $b) * ($reverse ? -1 : 1);
+            }
+        }
+
+        return strcasecmp($a, $b) * ($reverse ? -1 : 1);
+    };
+
+    if ($byKeys) {
+        uksort($data, $callback);
+    } else {
+        usort($data, $callback);
+    }
+}
index 40539a04666c1c16acbad9c604a5e7a2cef48cc0..61b71129810114d95dbb9697ae5e649de2fa475a 100644 (file)
--- a/index.php
+++ b/index.php
@@ -791,7 +791,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
     if ($targetPage == Router::$PAGE_TAGCLOUD)
     {
         $visibility = ! empty($_SESSION['privateonly']) ? 'private' : 'all';
-        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : array();
+        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
         $tags = $LINKSDB->linksCountPerTag($filteringTags, $visibility);
 
         // We sort tags alphabetically, then choose a font size according to count.
@@ -801,17 +801,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
             $maxcount = max($maxcount, $value);
         }
 
-        // Sort tags alphabetically: case insensitive, support locale if available.
-        uksort($tags, function($a, $b) {
-            // Collator is part of PHP intl.
-            if (class_exists('Collator')) {
-                $c = new Collator(setlocale(LC_COLLATE, 0));
-                if (!intl_is_failure(intl_get_error_code())) {
-                    return $c->compare($a, $b);
-                }
-            }
-            return strcasecmp($a, $b);
-        });
+        alphabetical_sort($tags, true, true);
 
         $tagList = array();
         foreach($tags as $key => $value) {
@@ -835,7 +825,32 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
             $PAGE->assign($key, $value);
         }
 
-        $PAGE->renderPage('tagcloud');
+        $PAGE->renderPage('tag.cloud');
+        exit;
+    }
+
+    // -------- Tag cloud
+    if ($targetPage == Router::$PAGE_TAGLIST)
+    {
+        $visibility = ! empty($_SESSION['privateonly']) ? 'private' : 'all';
+        $filteringTags = isset($_GET['searchtags']) ? explode(' ', $_GET['searchtags']) : [];
+        $tags = $LINKSDB->linksCountPerTag($filteringTags, $visibility);
+
+        if (! empty($_GET['sort']) && $_GET['sort'] === 'alpha') {
+            alphabetical_sort($tags, false, true);
+        }
+
+        $data = [
+            'search_tags' => implode(' ', $filteringTags),
+            'tags' => $tags,
+        ];
+        $pluginManager->executeHooks('render_taglist', $data, ['loggedin' => isLoggedIn()]);
+
+        foreach ($data as $key => $value) {
+            $PAGE->assign($key, $value);
+        }
+
+        $PAGE->renderPage('tag.list');
         exit;
     }
 
@@ -1152,6 +1167,7 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
     if ($targetPage == Router::$PAGE_CHANGETAG)
     {
         if (empty($_POST['fromtag']) || (empty($_POST['totag']) && isset($_POST['renametag']))) {
+            $PAGE->assign('fromtag', ! empty($_GET['fromtag']) ? escape($_GET['fromtag']) : '');
             $PAGE->renderPage('changetag');
             exit;
         }
@@ -1582,6 +1598,13 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
         exit;
     }
 
+    // Get a fresh token
+    if ($targetPage == Router::$GET_TOKEN) {
+        header('Content-Type:text/plain');
+        echo getToken($conf);
+        exit;
+    }
+
     // -------- Otherwise, simply display search form and links:
     showLinkList($PAGE, $LINKSDB, $conf, $pluginManager);
     exit;
index d6a0aad5e8e0201da8441dcaa83eab6d7181575b..3d1aa6538918f8ad48dc5436b91dd03253a3eadd 100644 (file)
@@ -417,4 +417,116 @@ class UtilsTest extends PHPUnit_Framework_TestCase
         $this->assertEquals('1048576', get_max_upload_size('1m', '2m', false));
         $this->assertEquals('100', get_max_upload_size(100, 100, false));
     }
+
+    /**
+     * Test alphabetical_sort by value, not reversed, with php-intl.
+     */
+    public function testAlphabeticalSortByValue()
+    {
+        $arr = [
+            'zZz',
+            'éee',
+            'éae',
+            'eee',
+            'A',
+            'a',
+            'zzz',
+        ];
+        $expected = [
+            'a',
+            'A',
+            'éae',
+            'eee',
+            'éee',
+            'zzz',
+            'zZz',
+        ];
+
+        alphabetical_sort($arr);
+        $this->assertEquals($expected, $arr);
+    }
+
+    /**
+     * Test alphabetical_sort by value, reversed, with php-intl.
+     */
+    public function testAlphabeticalSortByValueReversed()
+    {
+        $arr = [
+            'zZz',
+            'éee',
+            'éae',
+            'eee',
+            'A',
+            'a',
+            'zzz',
+        ];
+        $expected = [
+            'zZz',
+            'zzz',
+            'éee',
+            'eee',
+            'éae',
+            'A',
+            'a',
+        ];
+
+        alphabetical_sort($arr, true);
+        $this->assertEquals($expected, $arr);
+    }
+
+    /**
+     * Test alphabetical_sort by keys, not reversed, with php-intl.
+     */
+    public function testAlphabeticalSortByKeys()
+    {
+        $arr = [
+            'zZz' => true,
+            'éee' => true,
+            'éae' => true,
+            'eee' => true,
+            'A' => true,
+            'a' => true,
+            'zzz' => true,
+        ];
+        $expected = [
+            'a' => true,
+            'A' => true,
+            'éae' => true,
+            'eee' => true,
+            'éee' => true,
+            'zzz' => true,
+            'zZz' => true,
+        ];
+
+        alphabetical_sort($arr, true, true);
+        $this->assertEquals($expected, $arr);
+    }
+
+    /**
+     * Test alphabetical_sort by keys, reversed, with php-intl.
+     */
+    public function testAlphabeticalSortByKeysReversed()
+    {
+        $arr = [
+            'zZz' => true,
+            'éee' => true,
+            'éae' => true,
+            'eee' => true,
+            'A' => true,
+            'a' => true,
+            'zzz' => true,
+        ];
+        $expected = [
+            'zZz' => true,
+            'zzz' => true,
+            'éee' => true,
+            'eee' => true,
+            'éae' => true,
+            'A' => true,
+            'a' => true,
+        ];
+
+        alphabetical_sort($arr, true, true);
+        $this->assertEquals($expected, $arr);
+    }
 }
index 8d263a16df5bddb334a7daac16f29793d1694bb4..49dd20d951952082202e213a8433db1f619c6da2 100644 (file)
@@ -11,7 +11,7 @@
     <h2 class="window-title">{"Manage tags"|t}</h2>
     <form method="POST" action="#" name="changetag" id="changetag">
       <div>
-        <input type="text" name="fromtag" placeholder="{'Tag'|t}"
+        <input type="text" name="fromtag" placeholder="{'Tag'|t}" value="{$fromtag}"
                list="tagsList" autocomplete="off" class="awesomplete autofocus" data-minChars="1">
         <datalist id="tagsList">
           {loop="$tags"}<option>{$key}</option>{/loop}
@@ -31,6 +31,8 @@
         <input type="submit" value="{'Delete'|t}" name="deletetag" class="button button-red confirm-delete">
       </div>
     </form>
+
+    <p>You can also edit tags in the <a href="?do=taglist&sort=usage">tag list</a>.</p>
   </div>
 </div>
 {include="page.footer"}
index 4415a1b70f5a32fb0133cc9d0979b0961ba3fac8..2892064837ed49ca0293fd36139d0137c59ef1b8 100644 (file)
@@ -751,10 +751,11 @@ body, .pure-g [class*="pure-u"] {
 .page-form a {
     color: #1b926c;
     font-weight: bold;
+    text-decoration: none;
 }
 
 .page-form p {
-    padding: 0 10px;
+    padding: 5px 10px;
     margin: 0;
 }
 
@@ -1070,7 +1071,7 @@ form[name="linkform"].page-form {
 }
 
 #cloudtag, #cloudtag a {
-    color: #000;
+    color: #252525;
     text-decoration: none;
 }
 
@@ -1078,6 +1079,42 @@ form[name="linkform"].page-form {
     color: #7f7f7f;
 }
 
+/**
+ * TAG LIST
+ */
+#taglist {
+    padding: 0 10px;
+}
+
+#taglist a {
+    color: #252525;
+    text-decoration: none;
+}
+
+#taglist .count {
+    display: inline-block;
+    width: 35px;
+    text-align: right;
+    color: #7f7f7f;
+}
+
+#taglist .rename-tag-form {
+    display: none;
+}
+
+#taglist .delete-tag {
+    color: #ac2925;
+    display: none;
+}
+
+#taglist .rename-tag {
+    color: #0b5ea6;
+}
+
+#taglist .validate-rename-tag {
+    color: #1b926c;
+}
+
 /**
  * Picture wall CSS
  */
@@ -1227,3 +1264,16 @@ form[name="linkform"].page-form {
 .pure-button {
     -moz-user-select: auto;
 }
+
+.tag-sort {
+    margin-top: 30px;
+    text-align: center;
+}
+
+.tag-sort a {
+    display: inline-block;
+    margin: 0 15px;
+    color: white;
+    text-decoration: none;
+    font-weight: bold;
+}
index ceb1d1b863fd096a437ff24e8ac1b661e07347f8..4ebb7815a1d4c4860224228e65f4f9f5f68af00f 100644 (file)
@@ -412,8 +412,197 @@ window.onload = function () {
             }
         });
     }
+
+    /**
+     * Tag list operations
+     *
+     * TODO: support error code in the backend for AJAX requests
+     */
+    var existingTags = document.querySelector('input[name="taglist"]').value.split(' ');
+    var awesomepletes = [];
+
+    // Display/Hide rename form
+    var renameTagButtons = document.querySelectorAll('.rename-tag');
+    [].forEach.call(renameTagButtons, function(rename) {
+        rename.addEventListener('click', function(event) {
+            event.preventDefault();
+            var block = findParent(event.target, 'div', {'class': 'tag-list-item'});
+            var form = block.querySelector('.rename-tag-form');
+            if (form.style.display == 'none' || form.style.display == '') {
+                form.style.display = 'block';
+            } else {
+                form.style.display = 'none';
+            }
+            block.querySelector('input').focus();
+        });
+    });
+
+    // Rename a tag with an AJAX request
+    var renameTagSubmits = document.querySelectorAll('.validate-rename-tag');
+    [].forEach.call(renameTagSubmits, function(rename) {
+        rename.addEventListener('click', function(event) {
+            event.preventDefault();
+            var block = findParent(event.target, 'div', {'class': 'tag-list-item'});
+            var input = block.querySelector('.rename-tag-input');
+            var totag = input.value.replace('/"/g', '\\"');
+            if (totag.trim() == '') {
+                return;
+            }
+            var fromtag = block.getAttribute('data-tag');
+            var token = document.getElementById('token').value;
+
+            xhr = new XMLHttpRequest();
+            xhr.open('POST', '?do=changetag');
+            xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+            xhr.onload = function() {
+                if (xhr.status !== 200) {
+                    alert('An error occurred. Return code: '+ xhr.status);
+                    location.reload();
+                } else {
+                    block.setAttribute('data-tag', totag);
+                    input.setAttribute('name', totag);
+                    input.setAttribute('value', totag);
+                    findParent(input, 'div', {'class': 'rename-tag-form'}).style.display = 'none';
+                    block.querySelector('a.tag-link').innerHTML = htmlEntities(totag);
+                    block.querySelector('a.tag-link').setAttribute('href', '?searchtags='+ encodeURIComponent(totag));
+                    block.querySelector('a.rename-tag').setAttribute('href', '?do=changetag&fromtag='+ encodeURIComponent(totag));
+
+                    // Refresh awesomplete values
+                    for (var key in existingTags) {
+                        if (existingTags[key] == fromtag) {
+                            existingTags[key] = totag;
+                        }
+                    }
+                    awesomepletes = updateAwesompleteList('.rename-tag-input', existingTags, awesomepletes);
+                }
+            };
+            xhr.send('renametag=1&fromtag='+ encodeURIComponent(fromtag) +'&totag='+ encodeURIComponent(totag) +'&token='+ token);
+            refreshToken();
+        });
+    });
+
+    // Validate input with enter key
+    var renameTagInputs = document.querySelectorAll('.rename-tag-input');
+    [].forEach.call(renameTagInputs, function(rename) {
+
+        rename.addEventListener('keypress', function(event) {
+            if (event.keyCode === 13) { // enter
+                findParent(event.target, 'div', {'class': 'tag-list-item'}).querySelector('.validate-rename-tag').click();
+            }
+        });
+    });
+
+    // Delete a tag with an AJAX query (alert popup confirmation)
+    var deleteTagButtons = document.querySelectorAll('.delete-tag');
+    [].forEach.call(deleteTagButtons, function(rename) {
+        rename.style.display = 'inline';
+        rename.addEventListener('click', function(event) {
+            event.preventDefault();
+            var block = findParent(event.target, 'div', {'class': 'tag-list-item'});
+            var tag = block.getAttribute('data-tag');
+            var token = document.getElementById('token').value;
+
+            if (confirm('Are you sure you want to delete the tag "'+ tag +'"?')) {
+                xhr = new XMLHttpRequest();
+                xhr.open('POST', '?do=changetag');
+                xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
+                xhr.onload = function() {
+                    block.remove();
+                };
+                xhr.send(encodeURI('deletetag=1&fromtag='+ tag +'&token='+ token));
+                refreshToken();
+            }
+        });
+    });
+
+    updateAwesompleteList('.rename-tag-input', document.querySelector('input[name="taglist"]').value.split(' '), awesomepletes);
 };
 
+/**
+ * Find a parent element according to its tag and its attributes
+ *
+ * @param element    Element where to start the search
+ * @param tagName    Expected parent tag name
+ * @param attributes Associative array of expected attributes (name=>value).
+ *
+ * @returns Found element or null.
+ */
+function findParent(element, tagName, attributes)
+{
+    while (element) {
+        if (element.tagName.toLowerCase() == tagName) {
+            var match = true;
+            for (var key in attributes) {
+                if (! element.hasAttribute(key)
+                    || (attributes[key] != '' && element.getAttribute(key).indexOf(attributes[key]) == -1)
+                ) {
+                    match = false;
+                    break;
+                }
+            }
+
+            if (match) {
+                return element;
+            }
+        }
+        element = element.parentElement;
+    }
+    return null;
+}
+
+/**
+ * Ajax request to refresh the CSRF token.
+ */
+function refreshToken()
+{
+    var xhr = new XMLHttpRequest();
+    xhr.open('GET', '?do=token');
+    xhr.onload = function() {
+        var token = document.getElementById('token');
+        token.setAttribute('value', xhr.responseText);
+    };
+    xhr.send();
+}
+
+/**
+ * Update awesomplete list of tag for all elements matching the given selector
+ *
+ * @param selector  CSS selector
+ * @param tags      Array of tags
+ * @param instances List of existing awesomplete instances
+ */
+function updateAwesompleteList(selector, tags, instances)
+{
+    // First load: create Awesomplete instances
+    if (instances.length == 0) {
+        var elements = document.querySelectorAll(selector);
+        [].forEach.call(elements, function (element) {
+            instances.push(new Awesomplete(
+                element,
+                {'list': tags}
+            ));
+        });
+    } else {
+        // Update awesomplete tag list
+        for (var key in instances) {
+            instances[key].list = tags;
+        }
+    }
+    return instances;
+}
+
+/**
+ * html_entities in JS
+ *
+ * @see http://stackoverflow.com/questions/18749591/encode-html-entities-in-javascript
+ */
+function htmlEntities(str)
+{
+    return str.replace(/[\u00A0-\u9999<>\&]/gim, function(i) {
+        return '&#'+i.charCodeAt(0)+';';
+    });
+}
+
 function activateFirefoxSocial(node) {
     var loc = location.href;
     var baseURL = loc.substring(0, loc.lastIndexOf("/"));
@@ -445,8 +634,11 @@ function activateFirefoxSocial(node) {
  * @param currentContinent Current selected continent
  * @param reset            Set to true to reset the selected value
  */
-function hideTimezoneCities(cities, currentContinent, reset = false) {
+function hideTimezoneCities(cities, currentContinent) {
     var first = true;
+    if (reset == null) {
+        reset = false;
+    }
     [].forEach.call(cities, function (option) {
         if (option.getAttribute('data-continent') != currentContinent) {
             option.className = 'hidden';
index 77fc65dd9630feef1520b28c43569f18d13f8b85..02fc76428e3110420f6d284cec6e7151b65a24ba 100644 (file)
@@ -16,6 +16,9 @@
   </div>
   <div class="pure-u-2-24"></div>
 </div>
+
+<input type="hidden" name="token" value="{$token}" id="token" />
+
 {loop="$plugins_footer.endofpage"}
     {$value}
 {/loop}
similarity index 97%
rename from tpl/default/tagcloud.html
rename to tpl/default/tag.cloud.html
index efe6e9376df21c156280b8bf1426e902c5b16e41..59aa2ee065d325f667c8bceec3f75947071d002a 100644 (file)
@@ -6,6 +6,8 @@
 <body>
 {include="page.header"}
 
+{include="tag.sort"}
+
 <div class="pure-g">
   <div class="pure-u-lg-1-6 pure-u-1-24"></div>
   <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor">
@@ -54,6 +56,8 @@
   </div>
 </div>
 
+{include="tag.sort"}
+
 {include="page.footer"}
 </body>
 </html>
diff --git a/tpl/default/tag.list.html b/tpl/default/tag.list.html
new file mode 100644 (file)
index 0000000..62e2e7c
--- /dev/null
@@ -0,0 +1,86 @@
+<!DOCTYPE html>
+<html>
+<head>
+  {include="includes"}
+</head>
+<body>
+{include="page.header"}
+
+{include="tag.sort"}
+
+<div class="pure-g">
+  <div class="pure-u-lg-1-6 pure-u-1-24"></div>
+  <div class="pure-u-lg-2-3 pure-u-22-24 page-form page-visitor">
+    {$countTags=count($tags)}
+    <h2 class="window-title">{'Tag list'|t} - {$countTags} {'tags'|t}</h2>
+
+    <div id="search-tagcloud" class="pure-g">
+      <div class="pure-u-lg-1-4"></div>
+      <div class="pure-u-1 pure-u-lg-1-2">
+        <form method="GET">
+          <input type="hidden" name="do" value="taglist">
+          <input type="text" name="searchtags" placeholder="{'Filter by tag'|t}"
+                 {if="!empty($search_tags)"}
+                 value="{$search_tags}"
+                 {/if}
+          autocomplete="off" data-multiple data-autofirst data-minChars="1"
+          data-list="{loop="$tags"}{$key}, {/loop}"
+          >
+          <button type="submit" class="search-button"><i class="fa fa-search"></i></button>
+        </form>
+      </div>
+      <div class="pure-u-lg-1-4"></div>
+    </div>
+
+    <div id="plugin_zone_start_tagcloud" class="plugin_zone">
+      {loop="$plugin_start_zone"}
+        {$value}
+      {/loop}
+    </div>
+
+    <div id="taglist">
+      {loop="tags"}
+        <div class="tag-list-item pure-g" data-tag="{$key}">
+          <div class="pure-u-1">
+            {if="isLoggedIn()===true"}
+              <a href="#" class="delete-tag"><i class="fa fa-trash"></i></a>&nbsp;&nbsp;
+              <a href="?do=changetag&fromtag={$key|urlencode}" class="rename-tag">
+                <i class="fa fa-pencil-square-o {$key}"></i>
+              </a>
+            {/if}
+
+            <a href="?addtag={$key|urlencode}" title="{'Filter by tag'|t}" class="count">{$value}</a>
+            <a href="?searchtags={$key|urlencode}" class="tag-link">{$key}</a>
+
+            {loop="$value.tag_plugin"}
+              {$value}
+            {/loop}
+          </div>
+          {if="isLoggedIn()===true"}
+            <div class="rename-tag-form pure-u-1">
+              <input type="text" name="{$key}" value="{$key}" class="rename-tag-input" />
+              <a href="#" class="validate-rename-tag"><i class="fa fa-check"></i></a>
+            </div>
+          {/if}
+        </div>
+      {/loop}
+    </div>
+
+    <div id="plugin_zone_end_tagcloud" class="plugin_zone">
+      {loop="$plugin_end_zone"}
+      {$value}
+      {/loop}
+    </div>
+  </div>
+</div>
+
+{if="isLoggedIn()===true"}
+  <input type="hidden" name="taglist" value="{loop="$tags"}{$key} {/loop}"
+{/if}
+
+{include="tag.sort"}
+
+{include="page.footer"}
+</body>
+</html>
+
diff --git a/tpl/default/tag.sort.html b/tpl/default/tag.sort.html
new file mode 100644 (file)
index 0000000..89acda0
--- /dev/null
@@ -0,0 +1,8 @@
+<div class="pure-g">
+  <div class="pure-u-1 pure-alert pure-alert-success tag-sort">
+    {'Sort by:'|t}
+    <a href="?do=tagcloud" title="cloud">{'Cloud'|t}</a> &middot;
+    <a href="?do=taglist&sort=usage" title="cloud">{'Most used'|t}</a> &middot;
+    <a href="?do=taglist&sort=alpha" title="cloud">{'Alphabetical'|t}</a>
+  </div>
+</div>
\ No newline at end of file