Merge pull request #4021 from pixelfed/staging

Staging
This commit is contained in:
daniel 2022-12-27 05:37:14 -07:00 committed by GitHub
commit 093012a809
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 854 additions and 93 deletions

View file

@ -10,6 +10,7 @@
- Manually generate in-app registration confirmation links (php artisan user:app-magic-link) ([73eb9e36](https://github.com/pixelfed/pixelfed/commit/73eb9e36))
- Optional home feed caching ([3328b367](https://github.com/pixelfed/pixelfed/commit/3328b367))
- Admin Invites ([b73ca9a1](https://github.com/pixelfed/pixelfed/commit/b73ca9a1))
- Hashtag administration ([84872311](https://github.com/pixelfed/pixelfed/commit/84872311))
### Updates
- Update ApiV1Controller, include self likes in favourited_by endpoint ([58b331d2](https://github.com/pixelfed/pixelfed/commit/58b331d2))
@ -69,6 +70,8 @@
- Update reply pipelines, restore reply_count logic ([0d780ffb](https://github.com/pixelfed/pixelfed/commit/0d780ffb))
- Update StatusTagsPipeline, reject if `type` not set ([91085c45](https://github.com/pixelfed/pixelfed/commit/91085c45))
- Update ReplyPipelines, use more efficent reply count calculation ([d4dfa95c](https://github.com/pixelfed/pixelfed/commit/d4dfa95c))
- Update StatusDelete pipeline, dispatch async ([257c0949](https://github.com/pixelfed/pixelfed/commit/257c0949))
- Update lexer/extractor to handle banned hashtags ([909a8a5a](https://github.com/pixelfed/pixelfed/commit/909a8a5a))
- ([](https://github.com/pixelfed/pixelfed/commit/))
## [v0.11.4 (2022-10-04)](https://github.com/pixelfed/pixelfed/compare/v0.11.3...v0.11.4)

View file

@ -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 [];
}
}

View file

@ -12,6 +12,7 @@ use App\{
Profile,
Report,
Status,
StatusHashtag,
Story,
User
};
@ -22,6 +23,7 @@ use Illuminate\Support\Facades\Redis;
use App\Http\Controllers\Admin\{
AdminDirectoryController,
AdminDiscoverController,
AdminHashtagsController,
AdminInstanceController,
AdminReportController,
// AdminGroupsController,
@ -43,6 +45,7 @@ class AdminController extends Controller
use AdminReportController,
AdminDirectoryController,
AdminDiscoverController,
AdminHashtagsController,
// AdminGroupsController,
AdminMediaController,
AdminSettingsController,
@ -201,12 +204,6 @@ class AdminController extends Controller
return view('admin.apps.home', compact('apps'));
}
public function hashtagsHome(Request $request)
{
$hashtags = Hashtag::orderByDesc('id')->paginate(10);
return view('admin.hashtags.home', compact('hashtags'));
}
public function messagesHome(Request $request)
{
$messages = Contact::orderByDesc('id')->paginate(10);

View file

@ -24,6 +24,7 @@ use App\Services\ReblogService;
use App\Services\StatusHashtagService;
use App\Services\SnowflakeService;
use App\Services\StatusService;
use App\Services\TrendingHashtagService;
use App\Services\UserFilterService;
class DiscoverController extends Controller
@ -181,33 +182,7 @@ class DiscoverController extends Controller
{
abort_if(!$request->user(), 403);
$res = Cache::remember('api:discover:v1.1:trending:hashtags', 43200, function() {
$minId = StatusHashtag::where('created_at', '>', now()->subDays(14))->first();
if(!$minId) {
return [];
}
return StatusHashtag::select('hashtag_id', \DB::raw('count(*) as total'))
->where('id', '>', $minId->id)
->groupBy('hashtag_id')
->orderBy('total','desc')
->take(20)
->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();
});
$res = TrendingHashtagService::getTrending();
return $res;
}

View file

@ -225,7 +225,7 @@ class StatusController extends Controller
StatusService::del($status->id, true);
if ($status->profile_id == $user->profile->id || $user->is_admin == true) {
Cache::forget('profile:status_count:'.$status->profile_id);
StatusDelete::dispatchNow($status);
StatusDelete::dispatch($status);
}
if($request->wantsJson()) {

View file

@ -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
];
}
}

View file

@ -41,7 +41,7 @@ class MediaDeletePipeline implements ShouldQueue
array_pop($e);
$i = implode('/', $e);
if(config('pixelfed.cloud_storage') == true) {
if(config_cache('pixelfed.cloud_storage') == true) {
$disk = Storage::disk(config('filesystems.cloud'));
if($path && $disk->exists($path)) {
@ -63,9 +63,9 @@ class MediaDeletePipeline implements ShouldQueue
$disk->delete($thumb);
}
$media->forceDelete();
$media->delete();
return;
return 1;
}
}

View file

@ -50,6 +50,9 @@ class StatusDelete implements ShouldQueue
*/
public $deleteWhenMissingModels = true;
public $timeout = 900;
public $tries = 2;
/**
* Create a new job instance.
*
@ -131,7 +134,7 @@ class StatusDelete implements ShouldQueue
->where('item_id', $status->id)
->delete();
$status->forceDelete();
$status->delete();
return 1;
}

View file

@ -15,6 +15,7 @@ use App\Mention;
use App\Services\AccountService;
use App\Hashtag;
use App\StatusHashtag;
use App\Services\TrendingHashtagService;
class StatusTagsPipeline implements ShouldQueue
{
@ -61,6 +62,14 @@ class StatusTagsPipeline implements ShouldQueue
$name = substr($tag['name'], 0, 1) == '#' ?
substr($tag['name'], 1) : $tag['name'];
$banned = TrendingHashtagService::getBannedHashtagNames();
if(count($banned)) {
if(in_array(strtolower($name), array_map('strtolower', $banned))) {
continue;
}
}
$hashtag = Hashtag::firstOrCreate([
'slug' => str_slug($name)
], [

View file

@ -2,6 +2,7 @@
namespace App\Observers;
use DB;
use App\StatusHashtag;
use App\Services\StatusHashtagService;
@ -23,6 +24,7 @@ class StatusHashtagObserver
public function created(StatusHashtag $hashtag)
{
StatusHashtagService::set($hashtag->hashtag_id, $hashtag->status_id);
DB::table('hashtags')->where('id', $hashtag->hashtag_id)->increment('cached_count');
}
/**
@ -45,6 +47,7 @@ class StatusHashtagObserver
public function deleted(StatusHashtag $hashtag)
{
StatusHashtagService::del($hashtag->hashtag_id, $hashtag->status_id);
DB::table('hashtags')->where('id', $hashtag->hashtag_id)->decrement('cached_count');
}
/**

View file

@ -96,16 +96,9 @@ class SearchApiV2Service
$query = substr($rawQuery, 1) . '%';
}
$banned = InstanceService::getBannedDomains();
$results = Profile::select('profiles.*', 'followers.profile_id', 'followers.created_at')
->whereNull('status')
->leftJoin('followers', function($join) use($user) {
return $join->on('profiles.id', '=', 'followers.following_id')
->where('followers.profile_id', $user->profile_id);
})
$results = Profile::select('username', 'id', 'followers_count', 'domain')
->where('username', 'like', $query)
->orderBy('domain')
->orderByDesc('profiles.followers_count')
->orderByDesc('followers.created_at')
->offset($offset)
->limit($limit)
->get()
@ -131,7 +124,7 @@ class SearchApiV2Service
$limit = $this->query->input('limit') ?? 20;
$offset = $this->query->input('offset') ?? 0;
$query = '%' . $this->query->input('q') . '%';
return Hashtag::whereIsBanned(false)
return Hashtag::where('can_search', true)
->where('name', 'like', $query)
->offset($offset)
->limit($limit)

View file

@ -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());
}
}

View file

@ -12,6 +12,7 @@ namespace App\Util\Lexer;
use Illuminate\Support\Str;
use App\Status;
use App\Services\AutolinkService;
use App\Services\TrendingHashtagService;
/**
* Twitter Extractor Class.
@ -267,6 +268,8 @@ class Extractor extends Regex
return [];
}
$bannedTags = config('app.env') === 'production' ? TrendingHashtagService::getBannedHashtagNames() : [];
preg_match_all(self::$patterns['valid_hashtag'], $tweet, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE);
$tags = [];
@ -278,7 +281,12 @@ class Extractor extends Regex
if (preg_match(self::$patterns['end_hashtag_match'], $outer[0])) {
continue;
}
if(mb_strlen($hashtag[0]) > 124) {
if (count($bannedTags)) {
if(in_array(strtolower($hashtag[0]), array_map('strtolower', $bannedTags))) {
continue;
}
}
if (mb_strlen($hashtag[0]) > 124) {
continue;
}
$tags[] = [

9
config/trending.php Normal file
View file

@ -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)
]
];

View file

@ -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');
});
}
};

View file

@ -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()
{
//
}
};

BIN
public/css/admin.css vendored

Binary file not shown.

BIN
public/js/admin.js vendored

Binary file not shown.

BIN
public/js/vendor.js vendored

Binary file not shown.

View file

@ -49,7 +49,7 @@
*/
/*!
* Pusher JavaScript Library v7.5.0
* Pusher JavaScript Library v7.6.0
* https://pusher.com/
*
* Copyright 2020, Pusher
@ -65,14 +65,14 @@
*/
/*!
* Sizzle CSS Selector Engine v2.3.6
* Sizzle CSS Selector Engine v2.3.8
* https://sizzlejs.com/
*
* Copyright JS Foundation and other contributors
* Released under the MIT license
* https://js.foundation/
*
* Date: 2021-02-16
* Date: 2022-11-16
*/
/*!
@ -82,7 +82,7 @@
*/
/*!
* jQuery JavaScript Library v3.6.1
* jQuery JavaScript Library v3.6.2
* https://jquery.com/
*
* Includes Sizzle.js
@ -92,7 +92,7 @@
* Released under the MIT license
* https://jquery.org/license
*
* Date: 2022-08-26T17:52Z
* Date: 2022-12-13T14:56Z
*/
/*!

Binary file not shown.

View file

@ -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>

View file

@ -20,3 +20,13 @@ Chart.defaults.global.defaultFontFamily = "-apple-system,BlinkMacSystemFont,Sego
Array.from(document.querySelectorAll('.pagination .page-link'))
.filter(el => el.textContent === '« Previous' || el.textContent === 'Next »')
.forEach(el => el.textContent = (el.textContent === 'Next »' ? '' :''));
Vue.component(
'admin-directory',
require('./../components/admin/AdminDirectory.vue').default
);
Vue.component(
'hashtag-component',
require('./../components/admin/AdminHashtags.vue').default
);

View file

@ -22193,7 +22193,7 @@ textarea[resize='horizontal']
.sidenav
{
z-index: 1050;
z-index: 1040;
transition: all .4s ease;
}

View file

@ -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

View file

@ -35,7 +35,7 @@
<a class="nav-link pr-0" href="#" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<div class="media align-items-center">
<span class="avatar avatar-sm rounded-circle">
<img alt="avatar" src="{{request()->user()->profile->avatarUrl()}}">
<img alt="avatar" src="{{request()->user()->profile->avatarUrl()}}" onerror="this.onerror=null;this.src='/storage/avatars/default.png?v=0';">
</span>
<div class="media-body ml-2 d-none d-lg-block">
<span class="mb-0 text-sm font-weight-bold">{{request()->user()->username}}</span>

View file

@ -108,6 +108,11 @@ Route::domain(config('pixelfed.domain.admin'))->prefix('i/admin')->group(functio
Route::post('directory/testimonial/save', 'AdminController@directorySaveTestimonial');
Route::post('directory/testimonial/delete', 'AdminController@directoryDeleteTestimonial');
Route::post('directory/testimonial/update', 'AdminController@directoryUpdateTestimonial');
Route::get('hashtags/stats', 'AdminController@hashtagsStats');
Route::get('hashtags/query', 'AdminController@hashtagsApi');
Route::get('hashtags/get', 'AdminController@hashtagsGet');
Route::post('hashtags/update', 'AdminController@hashtagsUpdate');
Route::post('hashtags/clear-trending-cache', 'AdminController@hashtagsClearTrendingCache');
});
});