mirror of
https://github.com/pixelfed/pixelfed.git
synced 2024-11-26 16:23:16 +00:00
commit
093012a809
27 changed files with 854 additions and 93 deletions
|
@ -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)
|
||||
|
|
102
app/Http/Controllers/Admin/AdminHashtagsController.php
Normal file
102
app/Http/Controllers/Admin/AdminHashtagsController.php
Normal 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 [];
|
||||
}
|
||||
|
||||
}
|
|
@ -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()) {
|
||||
|
|
29
app/Http/Resources/AdminHashtag.php
Normal file
29
app/Http/Resources/AdminHashtag.php
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
103
app/Services/TrendingHashtagService.php
Normal file
103
app/Services/TrendingHashtagService.php
Normal 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());
|
||||
}
|
||||
}
|
|
@ -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
9
config/trending.php
Normal 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)
|
||||
]
|
||||
];
|
|
@ -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()
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
BIN
public/css/admin.css
vendored
BIN
public/css/admin.css
vendored
Binary file not shown.
BIN
public/js/admin.js
vendored
BIN
public/js/admin.js
vendored
Binary file not shown.
BIN
public/js/vendor.js
vendored
BIN
public/js/vendor.js
vendored
Binary file not shown.
|
@ -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.
462
resources/assets/components/admin/AdminHashtags.vue
Normal file
462
resources/assets/components/admin/AdminHashtags.vue
Normal 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>
|
10
resources/assets/js/admin.js
vendored
10
resources/assets/js/admin.js
vendored
|
@ -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
|
||||
);
|
||||
|
|
2
resources/assets/sass/lib/argon.css
vendored
2
resources/assets/sass/lib/argon.css
vendored
|
@ -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…
Reference in a new issue