Adds a taglist view with edit/delete buttons
* The tag list can be sort alphabetically or by most used tag * Edit/Delete are perform using AJAX, or fallback to 'do=changetag' page * New features aren't backported to vintage theme
This commit is contained in:
parent
bc988eb042
commit
aa4797ba36
9 changed files with 453 additions and 16 deletions
|
@ -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';
|
||||
|
@ -79,6 +81,10 @@ public static function findPage($query, $get, $loggedIn)
|
|||
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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
40
index.php
40
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) {
|
||||
|
@ -839,6 +829,31 @@ function renderPage($conf, $pluginManager, $LINKSDB, $history)
|
|||
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;
|
||||
}
|
||||
|
||||
// Daily page.
|
||||
if ($targetPage == Router::$PAGE_DAILY) {
|
||||
showDaily($PAGE, $LINKSDB, $conf, $pluginManager);
|
||||
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -417,4 +417,116 @@ public function testGetMaxUploadSizeRaw()
|
|||
$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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 @@ <h2 class="window-title">{"Manage tags"|t}</h2>
|
|||
<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"}
|
||||
|
|
|
@ -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,38 @@ 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 .delete-tag {
|
||||
color: #ac2925;
|
||||
display: none;
|
||||
}
|
||||
|
||||
#taglist .rename-tag {
|
||||
color: #0b5ea6;
|
||||
}
|
||||
|
||||
#taglist .validate-rename-tag {
|
||||
color: #1b926c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picture wall CSS
|
||||
*/
|
||||
|
@ -1227,3 +1260,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;
|
||||
}
|
||||
|
|
|
@ -412,8 +412,139 @@ window.onload = function () {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag list operations
|
||||
*
|
||||
* TODO: support error code in the backend for AJAX requests
|
||||
*/
|
||||
// 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');
|
||||
form.style.display = form.style.display == 'none' ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
// 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);
|
||||
input.parentNode.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));
|
||||
}
|
||||
};
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +576,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';
|
||||
|
|
82
tpl/default/tag.list.html
Normal file
82
tpl/default/tag.list.html
Normal file
|
@ -0,0 +1,82 @@
|
|||
<!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>
|
||||
<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" style="display:none;">
|
||||
<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>
|
||||
|
||||
{include="tag.sort"}
|
||||
|
||||
{include="page.footer"}
|
||||
</body>
|
||||
</html>
|
||||
|
8
tpl/default/tag.sort.html
Normal file
8
tpl/default/tag.sort.html
Normal file
|
@ -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> ·
|
||||
<a href="?do=taglist&sort=usage" title="cloud">{'Most used'|t}</a> ·
|
||||
<a href="?do=taglist&sort=alpha" title="cloud">{'Alphabetical'|t}</a>
|
||||
</div>
|
||||
</div>
|
Loading…
Reference in a new issue