diff --git a/app/Actions/TagRepo.php b/app/Actions/TagRepo.php
index b892efe57..06a1b893d 100644
--- a/app/Actions/TagRepo.php
+++ b/app/Actions/TagRepo.php
@@ -4,6 +4,7 @@ namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Models\Entity;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@@ -12,22 +13,51 @@ class TagRepo
protected $tag;
protected $permissionService;
- /**
- * TagRepo constructor.
- */
- public function __construct(Tag $tag, PermissionService $ps)
+ public function __construct(PermissionService $ps)
{
- $this->tag = $tag;
$this->permissionService = $ps;
}
+ /**
+ * Start a query against all tags in the system.
+ */
+ public function queryWithTotals(string $searchTerm, string $nameFilter): Builder
+ {
+ $groupingAttribute = $nameFilter ? 'value' : 'name';
+ $query = Tag::query()
+ ->select([
+ 'name',
+ ($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'),
+ DB::raw('COUNT(id) as usages'),
+ DB::raw('SUM(IF(entity_type = \'BookStack\\\\Page\', 1, 0)) as page_count'),
+ DB::raw('SUM(IF(entity_type = \'BookStack\\\\Chapter\', 1, 0)) as chapter_count'),
+ DB::raw('SUM(IF(entity_type = \'BookStack\\\\Book\', 1, 0)) as book_count'),
+ DB::raw('SUM(IF(entity_type = \'BookStack\\\\BookShelf\', 1, 0)) as shelf_count'),
+ ])
+ ->groupBy($groupingAttribute)
+ ->orderBy($groupingAttribute);
+
+ if ($nameFilter) {
+ $query->where('name', '=', $nameFilter);
+ }
+
+ if ($searchTerm) {
+ $query->where(function(Builder $query) use ($searchTerm) {
+ $query->where('name', 'like', '%' . $searchTerm . '%')
+ ->orWhere('value', 'like', '%' . $searchTerm . '%');
+ });
+ }
+
+ return $this->permissionService->filterRestrictedEntityRelations($query, 'tags', 'entity_id', 'entity_type');
+ }
+
/**
* Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided.
*/
public function getNameSuggestions(?string $searchTerm): Collection
{
- $query = $this->tag->newQuery()
+ $query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->groupBy('name');
@@ -49,7 +79,7 @@ class TagRepo
*/
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
{
- $query = $this->tag->newQuery()
+ $query = Tag::query()
->select('*', DB::raw('count(*) as count'))
->groupBy('value');
@@ -90,9 +120,9 @@ class TagRepo
*/
protected function newInstanceFromInput(array $input): Tag
{
- $name = trim($input['name']);
- $value = isset($input['value']) ? trim($input['value']) : '';
-
- return $this->tag->newInstance(['name' => $name, 'value' => $value]);
+ return new Tag([
+ 'name' => trim($input['name']),
+ 'value' => trim($input['value'] ?? ''),
+ ]);
}
}
diff --git a/app/Http/Controllers/TagController.php b/app/Http/Controllers/TagController.php
index b0065af70..c8292a16b 100644
--- a/app/Http/Controllers/TagController.php
+++ b/app/Http/Controllers/TagController.php
@@ -17,6 +17,24 @@ class TagController extends Controller
$this->tagRepo = $tagRepo;
}
+ /**
+ * Show a listing of existing tags in the system.
+ */
+ public function index(Request $request)
+ {
+ $search = $request->get('search', '');
+ $nameFilter = $request->get('name', '');
+ $tags = $this->tagRepo
+ ->queryWithTotals($search, $nameFilter)
+ ->paginate(20);
+
+ return view('tags.index', [
+ 'tags' => $tags,
+ 'search' => $search,
+ 'nameFilter' => $nameFilter,
+ ]);
+ }
+
/**
* Get tag name suggestions from a given search term.
*/
diff --git a/resources/icons/info-filled.svg b/resources/icons/info-filled.svg
index 4c91c86b7..0597dbdf2 100644
--- a/resources/icons/info-filled.svg
+++ b/resources/icons/info-filled.svg
@@ -1,4 +1,3 @@
-
\ No newline at end of file
diff --git a/resources/icons/leaderboard.svg b/resources/icons/leaderboard.svg
new file mode 100644
index 000000000..9083330dc
--- /dev/null
+++ b/resources/icons/leaderboard.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php
index 161891bf4..722bf00db 100644
--- a/resources/lang/en/common.php
+++ b/resources/lang/en/common.php
@@ -45,6 +45,8 @@ return [
'unfavourite' => 'Unfavourite',
'next' => 'Next',
'previous' => 'Previous',
+ 'filter_active' => 'Active Filter:',
+ 'filter_clear' => 'Clear Filter',
// Sort Options
'sort_options' => 'Sort Options',
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index 4871b6225..71d062a02 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -258,6 +258,13 @@ return [
'tags_explain' => "Add some tags to better categorise your content. \n You can assign a value to a tag for more in-depth organisation.",
'tags_add' => 'Add another tag',
'tags_remove' => 'Remove this tag',
+ 'tags_usages' => 'Total tag usages',
+ 'tags_assigned_pages' => 'Assigned to Pages',
+ 'tags_assigned_chapters' => 'Assigned to Chapters',
+ 'tags_assigned_books' => 'Assigned to Books',
+ 'tags_assigned_shelves' => 'Assigned to Shelves',
+ 'tags_x_unique_values' => ':count unique values',
+ 'tags_all_values' => 'All values',
'attachments' => 'Attachments',
'attachments_explain' => 'Upload some files or attach some links to display on your page. These are visible in the page sidebar.',
'attachments_explain_instant_save' => 'Changes here are saved instantly.',
diff --git a/resources/sass/_blocks.scss b/resources/sass/_blocks.scss
index f9c206154..ef03699f1 100644
--- a/resources/sass/_blocks.scss
+++ b/resources/sass/_blocks.scss
@@ -245,7 +245,7 @@
@include lightDark(border-color, #CCC, #666);
a, span, a:hover, a:active {
padding: 4px 8px;
- @include lightDark(color, rgba(0, 0, 0, 0.6), rgba(255, 255, 255, 0.8));
+ @include lightDark(color, rgba(0, 0, 0, 0.7), rgba(255, 255, 255, 0.8));
transition: background-color ease-in-out 80ms;
text-decoration: none;
}
@@ -266,6 +266,35 @@
margin-bottom: 0;
}
+td .tag-item {
+ margin-bottom: 0;
+}
+
+/**
+ * Pill boxes
+ */
+
+.pill {
+ display: inline-block;
+ border: 1px solid currentColor;
+ padding: .2em .8em;
+ font-size: 0.8em;
+ border-radius: 1rem;
+ position: relative;
+ overflow: hidden;
+ line-height: 1.4;
+ &:before {
+ content: '';
+ background-color: currentColor;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ opacity: 0.1;
+ }
+}
+
/**
* API Docs
*/
diff --git a/resources/sass/_tables.scss b/resources/sass/_tables.scss
index c78e13446..dd585733c 100644
--- a/resources/sass/_tables.scss
+++ b/resources/sass/_tables.scss
@@ -35,7 +35,7 @@ table.table {
font-weight: bold;
}
tr:hover {
- @include lightDark(background-color, #eee, #333);
+ @include lightDark(background-color, #F2F2F2, #333);
}
.text-right {
text-align: end;
@@ -49,6 +49,12 @@ table.table {
a {
display: inline-block;
}
+ &.expand-to-padding {
+ margin-left: -$-s;
+ margin-right: -$-s;
+ width: calc(100% + (2*#{$-s}));
+ max-width: calc(100% + (2*#{$-s}));
+ }
}
table.no-style {
diff --git a/resources/views/entities/tag-list.blade.php b/resources/views/entities/tag-list.blade.php
index ffbd5c330..a49eef31b 100644
--- a/resources/views/entities/tag-list.blade.php
+++ b/resources/views/entities/tag-list.blade.php
@@ -1,11 +1,3 @@
@foreach($entity->tags as $tag)
-
- @if($linked ?? true)
-
- @if($tag->value)
@endif
- @else
-
@icon('tag'){{ $tag->name }}
- @if($tag->value)
{{$tag->value}}
@endif
- @endif
-
+ @include('entities.tag', ['tag' => $tag])
@endforeach
\ No newline at end of file
diff --git a/resources/views/entities/tag.blade.php b/resources/views/entities/tag.blade.php
new file mode 100644
index 000000000..057c70921
--- /dev/null
+++ b/resources/views/entities/tag.blade.php
@@ -0,0 +1,9 @@
+
+ @if($linked ?? true)
+
+ @if($tag->value)
@endif
+ @else
+
@icon('tag'){{ $tag->name }}
+ @if($tag->value)
{{$tag->value}}
@endif
+ @endif
+
\ No newline at end of file
diff --git a/resources/views/form/request-query-inputs.blade.php b/resources/views/form/request-query-inputs.blade.php
new file mode 100644
index 000000000..4f2fa061e
--- /dev/null
+++ b/resources/views/form/request-query-inputs.blade.php
@@ -0,0 +1,8 @@
+{{--
+$params - The query paramters to convert to inputs.
+--}}
+@foreach(array_intersect_key(request()->query(), array_flip($params)) as $name => $value)
+ @if ($value)
+
+ @endif
+@endforeach
\ No newline at end of file
diff --git a/resources/views/tags/index.blade.php b/resources/views/tags/index.blade.php
new file mode 100644
index 000000000..de231493e
--- /dev/null
+++ b/resources/views/tags/index.blade.php
@@ -0,0 +1,85 @@
+@extends('layouts.simple')
+
+@section('body')
+
+
+
+
+
+
{{ trans('entities.tags') }}
+
+
+
+
+ @if($nameFilter)
+
+ {{ trans('common.filter_active') }}
+ @include('entities.tag', ['tag' => new \BookStack\Actions\Tag(['name' => $nameFilter])])
+
+
+ @endif
+
+
+
+
+
+ {{ $tags->links() }}
+
+
+
+
+
+@stop
diff --git a/routes/web.php b/routes/web.php
index 419a1e7f5..646201d55 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -165,11 +165,10 @@ Route::middleware('auth')->group(function () {
Route::get('/ajax/page/{id}', [PageController::class, 'getPageAjax']);
Route::delete('/ajax/page/{id}', [PageController::class, 'ajaxDestroy']);
- // Tag routes (AJAX)
- Route::prefix('ajax/tags')->group(function () {
- Route::get('/suggest/names', [TagController::class, 'getNameSuggestions']);
- Route::get('/suggest/values', [TagController::class, 'getValueSuggestions']);
- });
+ // Tag routes
+ Route::get('/tags', [TagController::class, 'index']);
+ Route::get('/ajax/tags/suggest/names', [TagController::class, 'getNameSuggestions']);
+ Route::get('/ajax/tags/suggest/values', [TagController::class, 'getValueSuggestions']);
Route::get('/ajax/search/entities', [SearchController::class, 'searchEntitiesAjax']);