mirror of https://github.com/pixelfed/pixelfed
commit
093012a809
@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use Cache;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Hashtag;
|
||||
use App\StatusHashtag;
|
||||
use App\Http\Resources\AdminHashtag;
|
||||
use App\Services\TrendingHashtagService;
|
||||
|
||||
trait AdminHashtagsController
|
||||
{
|
||||
public function hashtagsHome(Request $request)
|
||||
{
|
||||
return view('admin.hashtags.home');
|
||||
}
|
||||
|
||||
public function hashtagsApi(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'action' => 'sometimes|in:banned,nsfw',
|
||||
'sort' => 'sometimes|in:id,name,cached_count,can_search,can_trend,is_banned,is_nsfw',
|
||||
'dir' => 'sometimes|in:asc,desc'
|
||||
]);
|
||||
$action = $request->input('action');
|
||||
$query = $request->input('q');
|
||||
$sort = $request->input('sort');
|
||||
$order = $request->input('dir');
|
||||
|
||||
$hashtags = Hashtag::when($query, function($q, $query) {
|
||||
return $q->where('name', 'like', $query . '%');
|
||||
})
|
||||
->when($sort, function($q, $sort) use($order) {
|
||||
return $q->orderBy($sort, $order);
|
||||
}, function($q) {
|
||||
return $q->orderByDesc('id');
|
||||
})
|
||||
->when($action, function($q, $action) {
|
||||
if($action === 'banned') {
|
||||
return $q->whereIsBanned(true);
|
||||
} else if ($action === 'nsfw') {
|
||||
return $q->whereIsNsfw(true);
|
||||
}
|
||||
})
|
||||
->cursorPaginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return AdminHashtag::collection($hashtags);
|
||||
}
|
||||
|
||||
public function hashtagsStats(Request $request)
|
||||
{
|
||||
$stats = [
|
||||
'total_unique' => Hashtag::count(),
|
||||
'total_posts' => StatusHashtag::count(),
|
||||
'added_14_days' => Hashtag::where('created_at', '>', now()->subDays(14))->count(),
|
||||
'total_banned' => Hashtag::whereIsBanned(true)->count(),
|
||||
'total_nsfw' => Hashtag::whereIsNsfw(true)->count()
|
||||
];
|
||||
|
||||
return response()->json($stats);
|
||||
}
|
||||
|
||||
public function hashtagsGet(Request $request)
|
||||
{
|
||||
return new AdminHashtag(Hashtag::findOrFail($request->input('id')));
|
||||
}
|
||||
|
||||
public function hashtagsUpdate(Request $request)
|
||||
{
|
||||
$this->validate($request, [
|
||||
'id' => 'required',
|
||||
'name' => 'required',
|
||||
'slug' => 'required',
|
||||
'can_search' => 'required:boolean',
|
||||
'can_trend' => 'required:boolean',
|
||||
'is_nsfw' => 'required:boolean',
|
||||
'is_banned' => 'required:boolean'
|
||||
]);
|
||||
|
||||
$hashtag = Hashtag::whereSlug($request->input('slug'))->findOrFail($request->input('id'));
|
||||
$canTrendPrev = $hashtag->can_trend == null ? true : $hashtag->can_trend;
|
||||
$hashtag->is_banned = $request->input('is_banned');
|
||||
$hashtag->is_nsfw = $request->input('is_nsfw');
|
||||
$hashtag->can_search = $hashtag->is_banned ? false : $request->input('can_search');
|
||||
$hashtag->can_trend = $hashtag->is_banned ? false : $request->input('can_trend');
|
||||
$hashtag->save();
|
||||
|
||||
TrendingHashtagService::refresh();
|
||||
|
||||
return new AdminHashtag($hashtag);
|
||||
}
|
||||
|
||||
public function hashtagsClearTrendingCache(Request $request)
|
||||
{
|
||||
TrendingHashtagService::refresh();
|
||||
return [];
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class AdminHashtag extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @param \Illuminate\Http\Request $request
|
||||
* @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable
|
||||
*/
|
||||
public function toArray($request)
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'can_trend' => $this->can_trend === null ? true : (bool) $this->can_trend,
|
||||
'can_search' => $this->can_search === null ? true : (bool) $this->can_search,
|
||||
'is_nsfw' => (bool) $this->is_nsfw,
|
||||
'is_banned' => (bool) $this->is_banned,
|
||||
'cached_count' => $this->cached_count ?? 0,
|
||||
'created_at' => $this->created_at
|
||||
];
|
||||
}
|
||||
}
|
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
use App\Hashtag;
|
||||
use App\StatusHashtag;
|
||||
|
||||
class TrendingHashtagService
|
||||
{
|
||||
const CACHE_KEY = 'api:discover:v1.1:trending:hashtags';
|
||||
|
||||
public static function key($k = null)
|
||||
{
|
||||
return self::CACHE_KEY . $k;
|
||||
}
|
||||
|
||||
public static function getBannedHashtags()
|
||||
{
|
||||
return Cache::remember(self::key(':is_banned'), 1209600, function() {
|
||||
return Hashtag::whereIsBanned(true)->pluck('id')->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getBannedHashtagNames()
|
||||
{
|
||||
return Cache::remember(self::key(':is_banned:names'), 1209600, function() {
|
||||
return Hashtag::find(self::getBannedHashtags())->pluck('name')->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getNonTrendingHashtags()
|
||||
{
|
||||
return Cache::remember(self::key(':can_trend'), 1209600, function() {
|
||||
return Hashtag::whereCanTrend(false)->pluck('id')->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getNsfwHashtags()
|
||||
{
|
||||
return Cache::remember(self::key(':is_nsfw'), 1209600, function() {
|
||||
return Hashtag::whereIsNsfw(true)->pluck('id')->toArray();
|
||||
});
|
||||
}
|
||||
|
||||
public static function getMinRecentId()
|
||||
{
|
||||
return Cache::remember(self::key('-min-id'), 86400, function() {
|
||||
$minId = StatusHashtag::where('created_at', '>', now()->subMinutes(config('trending.hashtags.recency_mins')))->first();
|
||||
if(!$minId) {
|
||||
return 0;
|
||||
}
|
||||
return $minId->id;
|
||||
});
|
||||
}
|
||||
|
||||
public static function getTrending()
|
||||
{
|
||||
$minId = self::getMinRecentId();
|
||||
|
||||
$skipIds = array_merge(self::getBannedHashtags(), self::getNonTrendingHashtags(), self::getNsfwHashtags());
|
||||
|
||||
return Cache::remember(self::CACHE_KEY, config('trending.hashtags.ttl'), function() use($minId, $skipIds) {
|
||||
return StatusHashtag::select('hashtag_id', \DB::raw('count(*) as total'))
|
||||
->whereNotIn('hashtag_id', $skipIds)
|
||||
->where('id', '>', $minId)
|
||||
->groupBy('hashtag_id')
|
||||
->orderBy('total', 'desc')
|
||||
->take(config('trending.hashtags.limit'))
|
||||
->get()
|
||||
->map(function($h) {
|
||||
$hashtag = Hashtag::find($h->hashtag_id);
|
||||
if(!$hashtag) {
|
||||
return;
|
||||
}
|
||||
return [
|
||||
'id' => $h->hashtag_id,
|
||||
'total' => $h->total,
|
||||
'name' => '#'.$hashtag->name,
|
||||
'hashtag' => $hashtag->name,
|
||||
'url' => $hashtag->url()
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values();
|
||||
});
|
||||
}
|
||||
|
||||
public static function del($k)
|
||||
{
|
||||
return Cache::forget(self::key($k));
|
||||
}
|
||||
|
||||
public static function refresh()
|
||||
{
|
||||
Cache::forget(self::key(':is_banned'));
|
||||
Cache::forget(self::key(':is_nsfw'));
|
||||
Cache::forget(self::key(':can_trend'));
|
||||
Cache::forget(self::key('-min-id'));
|
||||
Cache::forget(self::key());
|
||||
}
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'hashtags' => [
|
||||
'ttl' => env('PF_HASHTAGS_TRENDING_TTL', 43200),
|
||||
'recency_mins' => env('PF_HASHTAGS_TRENDING_RECENCY_MINS', 20160),
|
||||
'limit' => env('PF_HASHTAGS_TRENDING_LIMIT', 20)
|
||||
]
|
||||
];
|
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Schema::table('hashtags', function (Blueprint $table) {
|
||||
$table->unsignedInteger('cached_count')->nullable();
|
||||
$table->boolean('can_trend')->nullable()->index()->after('slug');
|
||||
$table->boolean('can_search')->nullable()->index()->after('can_trend');
|
||||
$table->index('is_nsfw');
|
||||
$table->index('is_banned');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
Schema::table('hashtags', function (Blueprint $table) {
|
||||
$table->dropColumn('cached_count');
|
||||
$table->dropColumn('can_trend');
|
||||
$table->dropColumn('can_search');
|
||||
$table->dropIndex('hashtags_is_nsfw_index');
|
||||
$table->dropIndex('hashtags_is_banned_index');
|
||||
});
|
||||
}
|
||||
};
|
@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Hashtag;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function up()
|
||||
{
|
||||
Hashtag::withoutEvents(function() {
|
||||
Hashtag::chunkById(50, function($hashtags) {
|
||||
foreach($hashtags as $hashtag) {
|
||||
$count = DB::table('status_hashtags')->whereHashtagId($hashtag->id)->count();
|
||||
$hashtag->cached_count = $count;
|
||||
$hashtag->can_trend = true;
|
||||
$hashtag->can_search = true;
|
||||
$hashtag->save();
|
||||
}
|
||||
}, 'id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function down()
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,462 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="header bg-primary pb-3 mt-n4">
|
||||
<div class="container-fluid">
|
||||
<div class="header-body">
|
||||
<div class="row align-items-center py-4">
|
||||
<div class="col-lg-6 col-7">
|
||||
<p class="display-1 text-white d-inline-block mb-0">Hashtags</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<h5 class="text-light text-uppercase mb-0">Unique Hashtags</h5>
|
||||
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.total_unique) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<h5 class="text-light text-uppercase mb-0">Total Hashtags</h5>
|
||||
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.total_posts) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<h5 class="text-light text-uppercase mb-0">New (past 14 days)</h5>
|
||||
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.added_14_days) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<h5 class="text-light text-uppercase mb-0">Banned Hashtags</h5>
|
||||
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.total_banned) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<h5 class="text-light text-uppercase mb-0">NSFW Hashtags</h5>
|
||||
<span class="text-white h2 font-weight-bold mb-0 human-size">{{ prettyCount(stats.total_nsfw) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xl-2 col-md-6">
|
||||
<div class="mb-3">
|
||||
<h5 class="text-light text-uppercase mb-0">Clear Trending Cache</h5>
|
||||
<button class="btn btn-outline-white btn-block btn-sm py-0 mt-1" @click="clearTrendingCache">Clear Cache</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loaded" class="my-5 text-center">
|
||||
<b-spinner />
|
||||
</div>
|
||||
|
||||
<div v-else class="m-n2 m-lg-4">
|
||||
<div class="container-fluid mt-4">
|
||||
<div class="row mb-3 justify-content-between">
|
||||
<div class="col-12 col-md-8">
|
||||
<ul class="nav nav-pills">
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 0}]" @click="toggleTab(0)">All</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 1}]" @click="toggleTab(1)">Trending</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 2}]" @click="toggleTab(2)">Banned</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button :class="['nav-link', { active: tabIndex == 3}]" @click="toggleTab(3)">NSFW</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<autocomplete
|
||||
:search="composeSearch"
|
||||
:disabled="searchLoading"
|
||||
placeholder="Search hashtags"
|
||||
aria-label="Search hashtags"
|
||||
:get-result-value="getTagResultValue"
|
||||
@submit="onSearchResultClick"
|
||||
ref="autocomplete"
|
||||
>
|
||||
<template #result="{ result, props }">
|
||||
<li
|
||||
v-bind="props"
|
||||
class="autocomplete-result d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<div class="font-weight-bold" :class="{ 'text-danger': result.is_banned }">
|
||||
#{{ result.name }}
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
{{ prettyCount(result.cached_count) }} posts
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</autocomplete>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="[0, 2, 3].includes(this.tabIndex)" class="table-responsive">
|
||||
<table class="table table-dark">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('ID', 'id')" @click="toggleCol('id')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('Hashtag', 'name')" @click="toggleCol('name')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('Count', 'cached_count')" @click="toggleCol('cached_count')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('Can Search', 'can_search')" @click="toggleCol('can_search')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('Can Trend', 'can_trend')" @click="toggleCol('can_trend')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('NSFW', 'is_nsfw')" @click="toggleCol('is_nsfw')"></th>
|
||||
<th scope="col" class="cursor-pointer" v-html="buildColumn('Banned', 'is_banned')" @click="toggleCol('is_banned')"></th>
|
||||
<th scope="col">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(hashtag, idx) in hashtags">
|
||||
<td class="font-weight-bold text-monospace text-muted">
|
||||
<a href="#" @click.prevent="openEditHashtagModal(hashtag, idx)">
|
||||
{{ hashtag.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="font-weight-bold">{{ hashtag.name }}</td>
|
||||
<td class="font-weight-bold">
|
||||
<a :href="`/i/web/hashtag/${hashtag.slug}`">
|
||||
{{ hashtag.cached_count ?? 0 }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="font-weight-bold" v-html="boolIcon(hashtag.can_search, 'text-success', 'text-danger')"></td>
|
||||
<td class="font-weight-bold" v-html="boolIcon(hashtag.can_trend, 'text-success', 'text-danger')"></td>
|
||||
<td class="font-weight-bold" v-html="boolIcon(hashtag.is_nsfw, 'text-danger')"></td>
|
||||
<td class="font-weight-bold" v-html="boolIcon(hashtag.is_banned, 'text-danger')"></td>
|
||||
<td class="font-weight-bold">{{ timeAgo(hashtag.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="[0, 2, 3].includes(this.tabIndex)" class="d-flex align-items-center justify-content-center">
|
||||
<button
|
||||
class="btn btn-primary rounded-pill"
|
||||
:disabled="!pagination.prev"
|
||||
@click="paginate('prev')">
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary rounded-pill"
|
||||
:disabled="!pagination.next"
|
||||
@click="paginate('next')">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="this.tabIndex == 1" class="table-responsive">
|
||||
<table class="table table-dark">
|
||||
<thead class="thead-dark">
|
||||
<tr>
|
||||
<th scope="col">ID</th>
|
||||
<th scope="col">Hashtag</th>
|
||||
<th scope="col">Trending Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(hashtag, idx) in trendingTags">
|
||||
<td class="font-weight-bold text-monospace text-muted">
|
||||
<a href="#" @click.prevent="openEditHashtagModal(hashtag, idx)">
|
||||
{{ hashtag.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="font-weight-bold">{{ hashtag.hashtag }}</td>
|
||||
<td class="font-weight-bold">
|
||||
<a :href="`/i/web/hashtag/${hashtag.hashtag}`">
|
||||
{{ hashtag.total ?? 0 }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<b-modal v-model="showEditModal" title="Edit Hashtag" :ok-only="true" :lazy="true" :static="true">
|
||||
<div v-if="editingHashtag && editingHashtag.name" class="list-group">
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Name</div>
|
||||
<div class="font-weight-bold">{{ editingHashtag.name }}</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Total Uses</div>
|
||||
<div class="font-weight-bold">{{ editingHashtag.cached_count.toLocaleString('en-CA', { compactDisplay: "short"}) }}</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Can Trend</div>
|
||||
<div class="mr-n2 mb-1">
|
||||
<b-form-checkbox v-model="editingHashtag.can_trend" switch size="lg"></b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Can Search</div>
|
||||
<div class="mr-n2 mb-1">
|
||||
<b-form-checkbox v-model="editingHashtag.can_search" switch size="lg"></b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">Banned</div>
|
||||
<div class="mr-n2 mb-1">
|
||||
<b-form-checkbox v-model="editingHashtag.is_banned" switch size="lg"></b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-group-item d-flex align-items-center justify-content-between">
|
||||
<div class="text-muted small">NSFW</div>
|
||||
<div class="mr-n2 mb-1">
|
||||
<b-form-checkbox v-model="editingHashtag.is_nsfw" switch size="lg"></b-form-checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<div v-if="editingHashtag && editingHashtag.name && editSaved">
|
||||
<p class="text-primary small font-weight-bold text-center mt-1 mb-0">Hashtag changes successfully saved!</p>
|
||||
</div>
|
||||
</transition>
|
||||
</b-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script type="text/javascript">
|
||||
import Autocomplete from '@trevoreyre/autocomplete-vue'
|
||||
import '@trevoreyre/autocomplete-vue/dist/style.css'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Autocomplete,
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
loaded: false,
|
||||
tabIndex: 0,
|
||||
stats: {
|
||||
"total_unique": 0,
|
||||
"total_posts": 0,
|
||||
"added_14_days": 0,
|
||||
"total_banned": 0,
|
||||
"total_nsfw": 0
|
||||
},
|
||||
hashtags: [],
|
||||
pagination: [],
|
||||
sortCol: undefined,
|
||||
sortDir: undefined,
|
||||
trendingTags: [],
|
||||
bannedTags: [],
|
||||
showEditModal: false,
|
||||
editingHashtag: undefined,
|
||||
editSaved: false,
|
||||
editSavedTimeout: undefined,
|
||||
searchLoading: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fetchStats();
|
||||
this.fetchHashtags();
|
||||
|
||||
this.$root.$on('bv::modal::hidden', (bvEvent, modalId) => {
|
||||
this.editSaved = false;
|
||||
clearTimeout(this.editSavedTimeout);
|
||||
this.editingHashtag = undefined;
|
||||
});
|
||||
},
|
||||
|
||||
watch: {
|
||||
editingHashtag: {
|
||||
deep: true,
|
||||
immediate: true,
|
||||
handler: function(updated, old) {
|
||||
if(updated != null && old != null) {
|
||||
this.storeHashtagEdit(updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchStats() {
|
||||
axios.get('/i/admin/api/hashtags/stats')
|
||||
.then(res => {
|
||||
this.stats = res.data;
|
||||
})
|
||||
},
|
||||
|
||||
fetchHashtags(url = '/i/admin/api/hashtags/query') {
|
||||
axios.get(url)
|
||||
.then(res => {
|
||||
this.hashtags = res.data.data;
|
||||
this.pagination = {
|
||||
next: res.data.links.next,
|
||||
prev: res.data.links.prev
|
||||
};
|
||||
this.loaded = true;
|
||||
})
|
||||
},
|
||||
|
||||
prettyCount(str) {
|
||||
if(str) {
|
||||
return str.toLocaleString('en-CA', { compactDisplay: "short", notation: "compact"});
|
||||
}
|
||||
return str;
|
||||
},
|
||||
|
||||
timeAgo(str) {
|
||||
if(!str) {
|
||||
return str;
|
||||
}
|
||||
return App.util.format.timeAgo(str);
|
||||
},
|
||||
|
||||
boolIcon(val, success = 'text-success', danger = 'text-muted') {
|
||||
if(val) {
|
||||
return `<i class="far fa-check-circle fa-lg ${success}"></i>`;
|
||||
}
|
||||
|
||||
return `<i class="far fa-times-circle fa-lg ${danger}"></i>`;
|
||||
},
|
||||
|
||||
paginate(dir) {
|
||||
event.currentTarget.blur();
|
||||
let url = dir == 'next' ? this.pagination.next : this.pagination.prev;
|
||||
this.fetchHashtags(url);
|
||||
},
|
||||
|
||||
toggleCol(col) {
|
||||
this.sortCol = col;
|
||||
|
||||
if(!this.sortDir) {
|
||||
this.sortDir = 'desc';
|
||||
} else {
|
||||
this.sortDir = this.sortDir == 'asc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
let url = '/i/admin/api/hashtags/query?sort=' + col + '&dir=' + this.sortDir;
|
||||
this.fetchHashtags(url);
|
||||
},
|
||||
|
||||
buildColumn(name, col) {
|
||||
let icon = `<i class="far fa-sort"></i>`;
|
||||
if(col == this.sortCol) {
|
||||
icon = this.sortDir == 'desc' ?
|
||||
`<i class="far fa-sort-up"></i>` :
|
||||
`<i class="far fa-sort-down"></i>`
|
||||
}
|
||||
return `${name} ${icon}`;
|
||||
},
|
||||
|
||||
toggleTab(idx) {
|
||||
this.loaded = false;
|
||||
this.tabIndex = idx;
|
||||
|
||||
if(idx === 0) {
|
||||
this.fetchHashtags();
|
||||
} else if(idx === 1) {
|
||||
axios.get('/api/v1.1/discover/posts/hashtags')
|
||||
.then(res => {
|
||||
this.trendingTags = res.data;
|
||||
this.loaded = true;
|
||||
})
|
||||
} else if(idx === 2) {
|
||||
let url = '/i/admin/api/hashtags/query?action=banned';
|
||||
this.fetchHashtags(url);
|
||||
} else if(idx === 3) {
|
||||
let url = '/i/admin/api/hashtags/query?action=nsfw';
|
||||
this.fetchHashtags(url);
|
||||
}
|
||||
},
|
||||
|
||||
openEditHashtagModal(hashtag) {
|
||||
this.editSaved = false;
|
||||
clearTimeout(this.editSavedTimeout);
|
||||
|
||||
this.$nextTick(() => {
|
||||
axios.get('/i/admin/api/hashtags/get', {
|
||||
params: {
|
||||
id: hashtag.id
|
||||
}
|
||||
})
|
||||
.then(res => {
|
||||
this.editingHashtag = res.data.data;
|
||||
this.showEditModal = true;
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
storeHashtagEdit(hashtag, idx) {
|
||||
this.editSaved = false;
|
||||
|
||||
if(hashtag.is_banned && (hashtag.can_trend || hashtag.can_search)) {
|
||||
swal('Banned Hashtag Limits', 'Banned hashtags cannot trend or be searchable, to allow those you need to unban the hashtag', 'error');
|
||||
}
|
||||
|
||||
axios.post('/i/admin/api/hashtags/update', hashtag)
|
||||
.then(res => {
|
||||
this.editSaved = true;
|
||||
|
||||
if(this.tabIndex !== 1) {
|
||||
this.hashtags = this.hashtags.map(h => {
|
||||
if(h.id == hashtag.id) {
|
||||
h = res.data.data
|
||||
}
|
||||
return h;
|
||||
});
|
||||
}
|
||||
|
||||
this.editSavedTimeout = setTimeout(() => {
|
||||
this.editSaved = false;
|
||||
}, 5000);
|
||||
})
|
||||
.catch(err => {
|
||||
swal('Oops!', 'An error occured, please try again.', 'error');
|
||||
console.log(err);
|
||||
})
|
||||
},
|
||||
|
||||
composeSearch(input) {
|
||||
if (input.length < 1) { return []; };
|
||||
return axios.get('/i/admin/api/hashtags/query', {
|
||||
params: {
|
||||
q: input,
|
||||
sort: 'cached_count',
|
||||
dir: 'desc'
|
||||
}
|
||||
}).then(res => {
|
||||
return res.data.data;
|
||||
});
|
||||
},
|
||||
|
||||
getTagResultValue(result) {
|
||||
return result.name;
|
||||
},
|
||||
|
||||
onSearchResultClick(result) {
|
||||
this.openEditHashtagModal(result);
|
||||
return;
|
||||
},
|
||||
|
||||
clearTrendingCache() {
|
||||
event.currentTarget.blur();
|
||||
if(!window.confirm('Are you sure you want to clear the trending hashtags cache?')){
|
||||
return;
|
||||
}
|
||||
axios.post('/i/admin/api/hashtags/clear-trending-cache')
|
||||
.then(res => {
|
||||
swal('Cache Cleared!', 'Successfully cleared the trending hashtag cache!', 'success');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,43 +1,13 @@
|
||||
@extends('admin.partial.template-full')
|
||||
|
||||
@section('section')
|
||||
<div class="title">
|
||||
<h3 class="font-weight-bold d-inline-block">Hashtags</h3>
|
||||
</div>
|
||||
<hr>
|
||||
<table class="table table-responsive">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th scope="col" width="10%">#</th>
|
||||
<th scope="col" width="30%">Hashtag</th>
|
||||
<th scope="col" width="15%">Status Count</th>
|
||||
<th scope="col" width="10%">NSFW</th>
|
||||
<th scope="col" width="10%">Banned</th>
|
||||
<th scope="col" width="15%">Created</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach($hashtags as $tag)
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/i/admin/apps/show/{{$tag->id}}" class="btn btn-sm btn-outline-primary">
|
||||
{{$tag->id}}
|
||||
</a>
|
||||
</td>
|
||||
<td class="font-weight-bold">{{$tag->name}}</td>
|
||||
<td class="font-weight-bold text-center">
|
||||
<a href="{{$tag->url()}}">
|
||||
{{$tag->posts()->count()}}
|
||||
</a>
|
||||
</td>
|
||||
<td class="font-weight-bold">{{$tag->is_nsfw ? 'true' : 'false'}}</td>
|
||||
<td class="font-weight-bold">{{$tag->is_banned ? 'true' : 'false'}}</td>
|
||||
<td class="font-weight-bold">{{$tag->created_at->diffForHumans()}}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="d-flex justify-content-center mt-5 small">
|
||||
{{$hashtags->links()}}
|
||||
</div>
|
||||
<hashtag-component />
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script type="text/javascript">
|
||||
new Vue({ el: '#panel'});
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
|
Loading…
Reference in New Issue