Merge pull request #4021 from pixelfed/staging

Staging
pull/4026/head
daniel 2 years ago committed by GitHub
commit 093012a809
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

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

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

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

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

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

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

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

@ -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)
], [

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

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

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

@ -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[] = [

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

@ -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
*/
/*!

@ -16,7 +16,7 @@
"/js/profile-directory.js": "/js/profile-directory.js?id=62b575734ca1d8e8b780b5dbcde82680",
"/js/story-compose.js": "/js/story-compose.js?id=9d606ec8de7ba57ed1402c531a3937ed",
"/js/direct.js": "/js/direct.js?id=83f62237dcbdcd3c3b0dd97ebb8cf4aa",
"/js/admin.js": "/js/admin.js?id=145e57a8fe4986cf8fce7378284e8c1f",
"/js/admin.js": "/js/admin.js?id=09ff5d52a465c7c7e9e04209eeb76df6",
"/js/rempro.js": "/js/rempro.js?id=61bb49ccfe70d28ed788750f9c6279b2",
"/js/rempos.js": "/js/rempos.js?id=da10eddc2edd1d3a29d8ffcd75d239dc",
"/js/live-player.js": "/js/live-player.js?id=674d2b72d4cf417d9d7a3953c55f37ca",
@ -43,8 +43,8 @@
"/css/appdark.css": "/css/appdark.css?id=de85ecce91d9ed7afa7714547eb1e26c",
"/css/app.css": "/css/app.css?id=88a0a931d5b0e24b0d9355f548414768",
"/css/portfolio.css": "/css/portfolio.css?id=db2c9929a56d83f9ff2aaf2161d29d36",
"/css/admin.css": "/css/admin.css?id=c39d4fbc91a140c22cf5afe5d9faa827",
"/css/admin.css": "/css/admin.css?id=619b6c6613a24e232048856e72110862",
"/css/landing.css": "/css/landing.css?id=e852a642699916fc9ff8208d7e06daa8",
"/css/spa.css": "/css/spa.css?id=602c4f74ce800b7bf45a8d8a4d8cb6e5",
"/js/vendor.js": "/js/vendor.js?id=cedafb53a2de5dd37758d3009b4b21c1"
"/js/vendor.js": "/js/vendor.js?id=be64338fb941b8e58b836490ef0e96be"
}

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

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

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

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

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

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

Loading…
Cancel
Save