1
0
Эх сурвалжийг харах

Merge pull request #5928 from pixelfed/staging

Add custom filters
daniel 2 сар өмнө
parent
commit
3d6348225b
67 өөрчлөгдсөн 4078 нэмэгдсэн , 652 устгасан
  1. 2 0
      CHANGELOG.md
  2. 112 2
      app/Http/Controllers/Api/ApiV1Controller.php
  3. 9 4
      app/Http/Controllers/ComposeController.php
  4. 503 0
      app/Http/Controllers/CustomFilterController.php
  5. 10 0
      app/Http/Controllers/CustomFilterKeywordController.php
  6. 10 0
      app/Http/Controllers/CustomFilterStatusController.php
  7. 5 0
      app/Http/Controllers/SettingsController.php
  8. 412 0
      app/Models/CustomFilter.php
  9. 37 0
      app/Models/CustomFilterKeyword.php
  10. 23 0
      app/Models/CustomFilterStatus.php
  11. 61 0
      app/Policies/CustomFilterPolicy.php
  12. 3 1
      app/Providers/AuthServiceProvider.php
  13. 62 0
      app/Rules/Webfinger.php
  14. 16 0
      app/Services/WebfingerService.php
  15. 74 0
      config/instance.php
  16. 32 0
      database/migrations/2025_04_08_102711_create_custom_filters_table.php
  17. 30 0
      database/migrations/2025_04_08_103425_create_custom_filter_keywords_table.php
  18. 29 0
      database/migrations/2025_04_08_103433_create_custom_filter_statuses_table.php
  19. 228 227
      package-lock.json
  20. BIN
      public/js/custom_filters.js
  21. BIN
      public/js/daci.chunk.4eaae509ed4a084c.js
  22. BIN
      public/js/daci.chunk.8cf1cb07ac8a9100.js
  23. BIN
      public/js/discover~findfriends.chunk.2ccaf3c586ba03fc.js
  24. BIN
      public/js/discover~findfriends.chunk.bf787612b58e5473.js
  25. BIN
      public/js/discover~hashtag.bundle.9e342ac5d1df33af.js
  26. BIN
      public/js/discover~hashtag.bundle.fffb7ab6f02db6fe.js
  27. BIN
      public/js/discover~memories.chunk.8ea5b8e37111f15f.js
  28. BIN
      public/js/discover~memories.chunk.9621c5ecf4482f0a.js
  29. BIN
      public/js/discover~myhashtags.chunk.03a9fc477579fd24.js
  30. BIN
      public/js/discover~myhashtags.chunk.57eeb9257cb300fd.js
  31. BIN
      public/js/discover~serverfeed.chunk.4e135dd1c07c17dd.js
  32. BIN
      public/js/discover~serverfeed.chunk.b7e1082a3be6ef4c.js
  33. BIN
      public/js/discover~settings.chunk.295935b63f9c0971.js
  34. BIN
      public/js/discover~settings.chunk.edeee5803151d4eb.js
  35. BIN
      public/js/group-status.js
  36. BIN
      public/js/group-topic-feed.js
  37. BIN
      public/js/groups.js
  38. BIN
      public/js/home.chunk.7b3c50ff0f7828a4.js
  39. 0 0
      public/js/home.chunk.7b3c50ff0f7828a4.js.LICENSE.txt
  40. BIN
      public/js/home.chunk.abfb6c7049f7833d.js
  41. BIN
      public/js/landing.js
  42. BIN
      public/js/manifest.js
  43. BIN
      public/js/post.chunk.192819f7b133173e.js
  44. BIN
      public/js/post.chunk.d0c8b400a930b92a.js
  45. 0 0
      public/js/post.chunk.d0c8b400a930b92a.js.LICENSE.txt
  46. BIN
      public/js/profile.chunk.25876d18c9eeb7c6.js
  47. BIN
      public/js/profile.chunk.5d560ecb7d4a57ce.js
  48. BIN
      public/js/profile.js
  49. BIN
      public/js/spa.js
  50. BIN
      public/js/status.js
  51. BIN
      public/js/timeline.js
  52. BIN
      public/js/vendor.js
  53. 1 1
      public/js/vendor.js.LICENSE.txt
  54. BIN
      public/mix-manifest.json
  55. 27 4
      resources/assets/components/Hashtag.vue
  56. 155 89
      resources/assets/components/partials/TimelineStatus.vue
  57. 250 181
      resources/assets/components/partials/post/PostContent.vue
  58. 150 143
      resources/assets/components/presenter/PhotoPresenter.vue
  59. 143 0
      resources/assets/js/components/filters/FilterCard.vue
  60. 1391 0
      resources/assets/js/components/filters/FilterModal.vue
  61. 264 0
      resources/assets/js/components/filters/FiltersList.vue
  62. 14 0
      resources/assets/js/custom_filters.js
  63. 11 0
      resources/views/settings/filters/home.blade.php
  64. 3 0
      resources/views/settings/partial/sidebar.blade.php
  65. 6 0
      routes/api.php
  66. 4 0
      routes/web.php
  67. 1 0
      webpack.mix.js

+ 2 - 0
CHANGELOG.md

@@ -4,6 +4,7 @@
 
 
 ### Added
 ### Added
 - Pinned Posts ([2f655d000](https://github.com/pixelfed/pixelfed/commit/2f655d000))
 - Pinned Posts ([2f655d000](https://github.com/pixelfed/pixelfed/commit/2f655d000))
+- Custom Filters ([#5928](https://github.com/pixelfed/pixelfed/pull/5928)) ([437d742ac](https://github.com/pixelfed/pixelfed/commit/437d742ac))
 
 
 ### Updates
 ### Updates
 - Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state ([5ddb6d842](https://github.com/pixelfed/pixelfed/commit/5ddb6d842))
 - Update PublicApiController, use pixelfed entities for /api/pixelfed/v1/accounts/id/statuses with bookmarked state ([5ddb6d842](https://github.com/pixelfed/pixelfed/commit/5ddb6d842))
@@ -18,6 +19,7 @@
 - Update DiscoverController, improve public hashtag feed. Fixes #5866 ([32fc3180c](https://github.com/pixelfed/pixelfed/commit/32fc3180c))
 - Update DiscoverController, improve public hashtag feed. Fixes #5866 ([32fc3180c](https://github.com/pixelfed/pixelfed/commit/32fc3180c))
 - Update report views, fix missing forms ([475d1d627](https://github.com/pixelfed/pixelfed/commit/475d1d627))
 - Update report views, fix missing forms ([475d1d627](https://github.com/pixelfed/pixelfed/commit/475d1d627))
 - Update private settings, change "Private Account" to "Manually Review Follow Requests" ([31dd1ab35](https://github.com/pixelfed/pixelfed/commit/31dd1ab35))
 - Update private settings, change "Private Account" to "Manually Review Follow Requests" ([31dd1ab35](https://github.com/pixelfed/pixelfed/commit/31dd1ab35))
+- Update ReportController, fix type validation ([ccc7f2fc6](https://github.com/pixelfed/pixelfed/commit/ccc7f2fc6))
 -  ([](https://github.com/pixelfed/pixelfed/commit/))
 -  ([](https://github.com/pixelfed/pixelfed/commit/))
 
 
 ## [v0.12.5 (2025-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)
 ## [v0.12.5 (2025-03-23)](https://github.com/pixelfed/pixelfed/compare/v0.12.5...dev)

+ 112 - 2
app/Http/Controllers/Api/ApiV1Controller.php

@@ -35,6 +35,7 @@ use App\Jobs\VideoPipeline\VideoThumbnail;
 use App\Like;
 use App\Like;
 use App\Media;
 use App\Media;
 use App\Models\Conversation;
 use App\Models\Conversation;
+use App\Models\CustomFilter;
 use App\Notification;
 use App\Notification;
 use App\Profile;
 use App\Profile;
 use App\Services\AccountService;
 use App\Services\AccountService;
@@ -2514,6 +2515,14 @@ class ApiV1Controller extends Controller
         ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
         ['photo', 'photo:album', 'video', 'video:album', 'photo:video:album'];
         AccountService::setLastActive($request->user()->id);
         AccountService::setLastActive($request->user()->id);
 
 
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $homeFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('home', $filter->context);
+        });
+
         if (config('exp.cached_home_timeline')) {
         if (config('exp.cached_home_timeline')) {
             $paddedLimit = $includeReblogs ? $limit + 10 : $limit + 50;
             $paddedLimit = $includeReblogs ? $limit + 10 : $limit + 50;
             if ($min || $max) {
             if ($min || $max) {
@@ -2550,6 +2559,23 @@ class ApiV1Controller extends Controller
                 ->filter(function ($s) use ($includeReblogs) {
                 ->filter(function ($s) use ($includeReblogs) {
                     return $includeReblogs ? true : $s['reblog'] == null;
                     return $includeReblogs ? true : $s['reblog'] == null;
                 })
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->take($limit)
                 ->map(function ($status) use ($pid) {
                 ->map(function ($status) use ($pid) {
                     if ($pid) {
                     if ($pid) {
@@ -2658,6 +2684,23 @@ class ApiV1Controller extends Controller
 
 
                     return $status;
                     return $status;
                 })
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->take($limit)
                 ->values();
                 ->values();
         } else {
         } else {
@@ -2712,6 +2755,23 @@ class ApiV1Controller extends Controller
 
 
                     return $status;
                     return $status;
                 })
                 })
+                ->map(function ($status) use ($homeFilters) {
+                    $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                    if (! empty($filterResults)) {
+                        $status['filtered'] = $filterResults;
+                        $shouldHide = collect($filterResults)->contains(function ($result) {
+                            return $result['filter']['filter_action'] === 'hide';
+                        });
+
+                        if ($shouldHide) {
+                            return null;
+                        }
+                    }
+
+                    return $status;
+                })
+                ->filter()
                 ->take($limit)
                 ->take($limit)
                 ->values();
                 ->values();
         }
         }
@@ -2773,7 +2833,7 @@ class ApiV1Controller extends Controller
             $limit = 40;
             $limit = 40;
         }
         }
         $user = $request->user();
         $user = $request->user();
-
+        $pid = $user->profile_id;
         $remote = $request->has('remote') && $request->boolean('remote');
         $remote = $request->has('remote') && $request->boolean('remote');
         $local = $request->boolean('local');
         $local = $request->boolean('local');
         $userRoleKey = $remote ? 'can-view-network-feed' : 'can-view-public-feed';
         $userRoleKey = $remote ? 'can-view-network-feed' : 'can-view-public-feed';
@@ -2786,6 +2846,14 @@ class ApiV1Controller extends Controller
         $hideNsfw = config('instance.hide_nsfw_on_public_feeds');
         $hideNsfw = config('instance.hide_nsfw_on_public_feeds');
         $amin = SnowflakeService::byDate(now()->subDays(config('federation.network_timeline_days_falloff')));
         $amin = SnowflakeService::byDate(now()->subDays(config('federation.network_timeline_days_falloff')));
         $asf = AdminShadowFilterService::getHideFromPublicFeedsList();
         $asf = AdminShadowFilterService::getHideFromPublicFeedsList();
+
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $homeFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('public', $filter->context);
+        });
         if ($local && $remote) {
         if ($local && $remote) {
             $feed = Status::select(
             $feed = Status::select(
                 'id',
                 'id',
@@ -2976,6 +3044,23 @@ class ApiV1Controller extends Controller
 
 
                 return true;
                 return true;
             })
             })
+            ->map(function ($status) use ($homeFilters) {
+                $filterResults = CustomFilter::applyCachedFilters($homeFilters, $status);
+
+                if (! empty($filterResults)) {
+                    $status['filtered'] = $filterResults;
+                    $shouldHide = collect($filterResults)->contains(function ($result) {
+                        return $result['filter']['filter_action'] === 'hide';
+                    });
+
+                    if ($shouldHide) {
+                        return null;
+                    }
+                }
+
+                return $status;
+            })
+            ->filter()
             ->take($limit)
             ->take($limit)
             ->values();
             ->values();
 
 
@@ -3919,8 +4004,16 @@ class ApiV1Controller extends Controller
         $pe = $request->has(self::PF_API_ENTITY_KEY);
         $pe = $request->has(self::PF_API_ENTITY_KEY);
         $pid = $request->user()->profile_id;
         $pid = $request->user()->profile_id;
 
 
+        $cachedFilters = CustomFilter::getCachedFiltersForAccount($pid);
+
+        $tagFilters = array_filter($cachedFilters, function ($item) {
+            [$filter, $rules] = $item;
+
+            return in_array('tags', $filter->context);
+        });
+
         if ($min || $max) {
         if ($min || $max) {
-            $minMax = SnowflakeService::byDate(now()->subMonths(6));
+            $minMax = SnowflakeService::byDate(now()->subMonths(9));
             if ($min && intval($min) < $minMax) {
             if ($min && intval($min) < $minMax) {
                 return [];
                 return [];
             }
             }
@@ -3975,6 +4068,23 @@ class ApiV1Controller extends Controller
 
 
                 return ! in_array($i['account']['id'], $filters) && ! in_array($domain, $domainBlocks);
                 return ! in_array($i['account']['id'], $filters) && ! in_array($domain, $domainBlocks);
             })
             })
+            ->map(function ($status) use ($tagFilters) {
+                $filterResults = CustomFilter::applyCachedFilters($tagFilters, $status);
+
+                if (! empty($filterResults)) {
+                    $status['filtered'] = $filterResults;
+                    $shouldHide = collect($filterResults)->contains(function ($result) {
+                        return $result['filter']['filter_action'] === 'hide';
+                    });
+
+                    if ($shouldHide) {
+                        return null;
+                    }
+                }
+
+                return $status;
+            })
+            ->filter()
             ->take($limit)
             ->take($limit)
             ->values()
             ->values()
             ->toArray();
             ->toArray();

+ 9 - 4
app/Http/Controllers/ComposeController.php

@@ -30,7 +30,6 @@ use App\Util\Media\License;
 use Auth;
 use Auth;
 use Cache;
 use Cache;
 use DB;
 use DB;
-use Purify;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
 use Illuminate\Support\Str;
 use Illuminate\Support\Str;
 use League\Fractal;
 use League\Fractal;
@@ -240,7 +239,13 @@ class ComposeController extends Controller
         abort_if(! $request->user(), 403);
         abort_if(! $request->user(), 403);
 
 
         $this->validate($request, [
         $this->validate($request, [
-            'q' => 'required|string|min:1|max:50',
+            'q' => [
+                'required',
+                'string',
+                'min:1',
+                'max:300',
+                new \App\Rules\WebFinger,
+            ],
         ]);
         ]);
 
 
         $q = $request->input('q');
         $q = $request->input('q');
@@ -571,7 +576,7 @@ class ComposeController extends Controller
             $status->cw_summary = $request->input('spoiler_text');
             $status->cw_summary = $request->input('spoiler_text');
         }
         }
 
 
-        $defaultCaption = "";
+        $defaultCaption = '';
         $status->caption = strip_tags($request->input('caption')) ?? $defaultCaption;
         $status->caption = strip_tags($request->input('caption')) ?? $defaultCaption;
         $status->rendered = $defaultCaption;
         $status->rendered = $defaultCaption;
         $status->scope = 'draft';
         $status->scope = 'draft';
@@ -677,7 +682,7 @@ class ComposeController extends Controller
         $place = $request->input('place');
         $place = $request->input('place');
         $cw = $request->input('cw');
         $cw = $request->input('cw');
         $tagged = $request->input('tagged');
         $tagged = $request->input('tagged');
-        $defaultCaption = config_cache('database.default') === 'mysql' ? null : "";
+        $defaultCaption = config_cache('database.default') === 'mysql' ? null : '';
 
 
         if ($place && is_array($place)) {
         if ($place && is_array($place)) {
             $status->place_id = $place['id'];
             $status->place_id = $place['id'];

+ 503 - 0
app/Http/Controllers/CustomFilterController.php

@@ -0,0 +1,503 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use App\Models\CustomFilter;
+use App\Models\CustomFilterKeyword;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Gate;
+use Illuminate\Validation\Rule;
+
+class CustomFilterController extends Controller
+{
+    // const ACTIVE_TYPES = ['home', 'public', 'tags', 'notifications', 'thread', 'profile', 'groups'];
+    const ACTIVE_TYPES = ['home', 'public', 'tags'];
+
+    public function index(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $filters = CustomFilter::where('profile_id', $request->user()->profile_id)
+            ->unexpired()
+            ->with(['keywords'])
+            ->orderByDesc('updated_at')
+            ->get()
+            ->map(function ($filter) {
+                return [
+                    'id' => $filter->id,
+                    'title' => $filter->title,
+                    'context' => $filter->context,
+                    'expires_at' => $filter->expires_at,
+                    'filter_action' => $filter->filterAction,
+                    'keywords' => $filter->keywords->map(function ($keyword) {
+                        return [
+                            'id' => $keyword->id,
+                            'keyword' => $keyword->keyword,
+                            'whole_word' => (bool) $keyword->whole_word,
+                        ];
+                    }),
+                    'statuses' => [],
+                ];
+            });
+
+        return response()->json($filters);
+    }
+
+    public function show(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('read'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        Gate::authorize('view', $filter);
+
+        $filter->load(['keywords']);
+
+        $res = [
+            'id' => $filter->id,
+            'title' => $filter->title,
+            'context' => $filter->context,
+            'expires_at' => $filter->expires_at,
+            'filter_action' => $filter->filterAction,
+            'keywords' => $filter->keywords->map(function ($keyword) {
+                return [
+                    'id' => $keyword->id,
+                    'keyword' => $keyword->keyword,
+                    'whole_word' => (bool) $keyword->whole_word,
+                ];
+            }),
+            'statuses' => [],
+        ];
+
+        return response()->json($res);
+    }
+
+    public function store(Request $request)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        Gate::authorize('create', CustomFilter::class);
+
+        $validatedData = $request->validate([
+            'title' => 'required|string|max:100',
+            'context' => 'required|array',
+            'context.*' => [
+                'string',
+                Rule::in(self::ACTIVE_TYPES),
+            ],
+            'filter_action' => 'string|in:warn,hide,blur',
+            'expires_in' => 'nullable|integer|min:0|max:63072000',
+            'keywords_attributes' => 'required|array|min:1|max:'.CustomFilter::getMaxKeywordsPerFilter(),
+            'keywords_attributes.*.keyword' => [
+                'required',
+                'string',
+                'min:1',
+                'max:'.CustomFilter::getMaxKeywordLength(),
+                'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
+                function ($attribute, $value, $fail) {
+                    if (preg_match('/(.)\1{20,}/', $value)) {
+                        $fail('The keyword contains excessive character repetition.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.whole_word' => 'boolean',
+        ]);
+        $profile_id = $request->user()->profile_id;
+        $userFilterCount = CustomFilter::where('profile_id', $profile_id)->count();
+        $maxFiltersPerUser = CustomFilter::getMaxFiltersPerUser();
+
+        if (! $request->user()->is_admin && $userFilterCount >= $maxFiltersPerUser) {
+            return response()->json([
+                'error' => 'Filter limit exceeded',
+                'message' => 'You can only have '.$maxFiltersPerUser.' filters at a time.',
+            ], 422);
+        }
+
+        $rateKey = 'filters_created:'.$request->user()->id;
+        $maxFiltersPerHour = CustomFilter::getMaxCreatePerHour();
+        $currentCount = Cache::get($rateKey, 0);
+
+        if (! $request->user()->is_admin && $currentCount >= $maxFiltersPerHour) {
+            return response()->json([
+                'error' => 'Rate limit exceeded',
+                'message' => 'You can only create '.$maxFiltersPerHour.' filters per hour.',
+            ], 429);
+        }
+
+        DB::beginTransaction();
+
+        try {
+
+            $requestedKeywords = array_map(function ($item) {
+                return mb_strtolower(trim($item['keyword']));
+            }, $validatedData['keywords_attributes']);
+
+            $existingKeywords = DB::table('custom_filter_keywords')
+                ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
+                ->where('custom_filters.profile_id', $profile_id)
+                ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
+                ->pluck('custom_filter_keywords.keyword')
+                ->toArray();
+
+            if (! empty($existingKeywords)) {
+                return response()->json([
+                    'error' => 'Duplicate keywords found',
+                    'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
+                ], 422);
+            }
+
+            $expiresAt = null;
+            if (isset($validatedData['expires_in']) && $validatedData['expires_in'] > 0) {
+                $expiresAt = now()->addSeconds($validatedData['expires_in']);
+            }
+
+            $action = CustomFilter::ACTION_WARN;
+            if (isset($validatedData['filter_action'])) {
+                $action = $this->filterActionToAction($validatedData['filter_action']);
+            }
+
+            $filter = CustomFilter::create([
+                'title' => $validatedData['title'],
+                'context' => $validatedData['context'],
+                'action' => $action,
+                'expires_at' => $expiresAt,
+                'profile_id' => $request->user()->profile_id,
+            ]);
+
+            if (isset($validatedData['keywords_attributes'])) {
+                foreach ($validatedData['keywords_attributes'] as $keywordData) {
+                    $keyword = trim($keywordData['keyword']);
+
+                    $filter->keywords()->create([
+                        'keyword' => $keyword,
+                        'whole_word' => (bool) $keywordData['whole_word'] ?? true,
+                    ]);
+                }
+            }
+
+            Cache::increment($rateKey);
+            if (! Cache::has($rateKey)) {
+                Cache::put($rateKey, 1, 3600);
+            }
+
+            Cache::forget("filters:v3:{$profile_id}");
+
+            DB::commit();
+
+            $filter->load(['keywords', 'statuses']);
+
+            $res = [
+                'id' => $filter->id,
+                'title' => $filter->title,
+                'context' => $filter->context,
+                'expires_at' => $filter->expires_at,
+                'filter_action' => $filter->filterAction,
+                'keywords' => $filter->keywords->map(function ($keyword) {
+                    return [
+                        'id' => $keyword->id,
+                        'keyword' => $keyword->keyword,
+                        'whole_word' => (bool) $keyword->whole_word,
+                    ];
+                }),
+                'statuses' => $filter->statuses->map(function ($status) {
+                    return [
+                        'id' => $status->id,
+                        'status_id' => $status->status_id,
+                    ];
+                }),
+            ];
+
+            return response()->json($res, 200);
+
+        } catch (\Exception $e) {
+            DB::rollBack();
+
+            return response()->json([
+                'error' => 'Failed to create filter',
+                'message' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    /**
+     * Convert Mastodon filter_action string to internal action value
+     *
+     * @param  string  $filterAction
+     * @return int
+     */
+    private function filterActionToAction($filterAction)
+    {
+        switch ($filterAction) {
+            case 'warn':
+                return CustomFilter::ACTION_WARN;
+            case 'hide':
+                return CustomFilter::ACTION_HIDE;
+            case 'blur':
+                return CustomFilter::ACTION_BLUR;
+            default:
+                return CustomFilter::ACTION_WARN;
+        }
+    }
+
+    public function update(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        $pid = $request->user()->profile_id;
+        if ($filter->profile_id !== $pid) {
+            return response()->json(['error' => 'This action is unauthorized'], 401);
+        }
+        Gate::authorize('update', $filter);
+
+        $validatedData = $request->validate([
+            'title' => 'string|max:100',
+            'context' => 'array|max:10',
+            'context.*' => 'string|in:home,notifications,public,thread,account,tags,groups',
+            'context.*' => [
+                'string',
+                Rule::in(self::ACTIVE_TYPES),
+            ],
+            'filter_action' => 'string|in:warn,hide,blur',
+            'expires_in' => 'nullable|integer|min:0|max:63072000',
+            'keywords_attributes' => [
+                'required',
+                'array',
+                'min:1',
+                function ($attribute, $value, $fail) {
+                    $activeKeywords = collect($value)->filter(function ($keyword) {
+                        return ! isset($keyword['_destroy']) || $keyword['_destroy'] !== true;
+                    })->count();
+
+                    if ($activeKeywords > CustomFilter::getMaxKeywordsPerFilter()) {
+                        $fail('You may not have more than '.CustomFilter::getMaxKeywordsPerFilter().' active keywords.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.id' => 'nullable|integer|exists:custom_filter_keywords,id',
+            'keywords_attributes.*.keyword' => [
+                'required_without:keywords_attributes.*.id',
+                'string',
+                'min:1',
+                'max:'.CustomFilter::getMaxKeywordLength(),
+                'regex:/^[\p{L}\p{N}\p{Zs}\p{P}\p{M}]+$/u',
+                function ($attribute, $value, $fail) {
+                    if (preg_match('/(.)\1{20,}/', $value)) {
+                        $fail('The keyword contains excessive character repetition.');
+                    }
+                },
+            ],
+            'keywords_attributes.*.whole_word' => 'boolean',
+            'keywords_attributes.*._destroy' => 'boolean',
+        ]);
+
+        $rateKey = 'filters_updated:'.$request->user()->id;
+        $maxUpdatesPerHour = CustomFilter::getMaxUpdatesPerHour();
+        $currentCount = Cache::get($rateKey, 0);
+
+        if (! $request->user()->is_admin && $currentCount >= $maxUpdatesPerHour) {
+            return response()->json([
+                'error' => 'Rate limit exceeded',
+                'message' => 'You can only update filters '.$maxUpdatesPerHour.' times per hour.',
+            ], 429);
+        }
+
+        DB::beginTransaction();
+
+        try {
+
+            $keywordIds = collect($validatedData['keywords_attributes'])->pluck('id')->filter()->toArray();
+            if (count($keywordIds) && ! CustomFilterKeyword::whereCustomFilterId($filter->id)->whereIn('id', $keywordIds)->count()) {
+                return response()->json([
+                    'error' => 'Record not found',
+                ], 404);
+            }
+
+            $requestedKeywords = [];
+            foreach ($validatedData['keywords_attributes'] as $item) {
+                if (isset($item['keyword']) && (! isset($item['_destroy']) || ! $item['_destroy'])) {
+                    $requestedKeywords[] = mb_strtolower(trim($item['keyword']));
+                }
+            }
+
+            if (! empty($requestedKeywords)) {
+                $existingKeywords = DB::table('custom_filter_keywords')
+                    ->join('custom_filters', 'custom_filter_keywords.custom_filter_id', '=', 'custom_filters.id')
+                    ->where('custom_filters.profile_id', $pid)
+                    ->whereIn('custom_filter_keywords.keyword', $requestedKeywords)
+                    ->where('custom_filter_keywords.custom_filter_id', '!=', $id)
+                    ->pluck('custom_filter_keywords.keyword')
+                    ->toArray();
+
+                if (! empty($existingKeywords)) {
+                    return response()->json([
+                        'error' => 'Duplicate keywords found',
+                        'message' => 'The following keywords already exist: '.implode(', ', $existingKeywords),
+                    ], 422);
+                }
+            }
+
+            if (isset($validatedData['expires_in'])) {
+                if ($validatedData['expires_in'] > 0) {
+                    $filter->expires_at = now()->addSeconds($validatedData['expires_in']);
+                } else {
+                    $filter->expires_at = null;
+                }
+            }
+
+            if (isset($validatedData['title'])) {
+                $filter->title = $validatedData['title'];
+            }
+
+            if (isset($validatedData['context'])) {
+                $filter->context = $validatedData['context'];
+            }
+
+            if (isset($validatedData['filter_action'])) {
+                $filter->action = $this->filterActionToAction($validatedData['filter_action']);
+            }
+
+            $filter->save();
+
+            if (isset($validatedData['keywords_attributes'])) {
+                $existingKeywords = $filter->keywords()->pluck('id')->toArray();
+
+                $processedIds = [];
+
+                foreach ($validatedData['keywords_attributes'] as $keywordData) {
+                    // Case 1: Explicit deletion with _destroy flag
+                    if (isset($keywordData['id']) && isset($keywordData['_destroy']) && (bool) $keywordData['_destroy']) {
+                        // Verify this ID belongs to this filter before deletion
+                        $kwf = CustomFilterKeyword::where('custom_filter_id', $filter->id)
+                            ->where('id', $keywordData['id'])
+                            ->first();
+
+                        if ($kwf) {
+                            $kwf->delete();
+                            $processedIds[] = $keywordData['id'];
+                        }
+                    }
+                    // Case 2: Update existing keyword
+                    elseif (isset($keywordData['id'])) {
+                        // Skip if we've already processed this ID
+                        if (in_array($keywordData['id'], $processedIds)) {
+                            continue;
+                        }
+
+                        // Verify this ID belongs to this filter before updating
+                        $keyword = CustomFilterKeyword::where('custom_filter_id', $filter->id)
+                            ->where('id', $keywordData['id'])
+                            ->first();
+
+                        if (! isset($keywordData['_destroy']) && $filter->keywords()->pluck('id')->search($keywordData['id']) === false) {
+                            return response()->json([
+                                'error' => 'Duplicate keywords found',
+                                'message' => 'The following keywords already exist: '.$keywordData['keyword'],
+                            ], 422);
+                        }
+
+                        if ($keyword) {
+                            $updateData = [];
+
+                            if (isset($keywordData['keyword'])) {
+                                $updateData['keyword'] = trim($keywordData['keyword']);
+                            }
+
+                            if (isset($keywordData['whole_word'])) {
+                                $updateData['whole_word'] = (bool) $keywordData['whole_word'];
+                            }
+
+                            if (! empty($updateData)) {
+                                $keyword->update($updateData);
+                            }
+
+                            $processedIds[] = $keywordData['id'];
+                        }
+                    }
+                    // Case 3: Create new keyword
+                    elseif (isset($keywordData['keyword'])) {
+                        // Check if we're about to exceed the keyword limit
+                        $existingKeywordCount = $filter->keywords()->count();
+                        $maxKeywordsPerFilter = CustomFilter::getMaxKeywordsPerFilter();
+
+                        if ($existingKeywordCount >= $maxKeywordsPerFilter) {
+                            return response()->json([
+                                'error' => 'Keyword limit exceeded',
+                                'message' => 'A filter can have a maximum of '.$maxKeywordsPerFilter.' keywords.',
+                            ], 422);
+                        }
+
+                        // Skip existing case-insensitive keywords
+                        if ($filter->keywords()->pluck('keyword')->search(mb_strtolower(trim($keywordData['keyword']))) !== false) {
+                            continue;
+                        }
+
+                        $filter->keywords()->create([
+                            'keyword' => trim($keywordData['keyword']),
+                            'whole_word' => (bool) ($keywordData['whole_word'] ?? true),
+                        ]);
+                    }
+                }
+            }
+
+            Cache::increment($rateKey);
+            if (! Cache::has($rateKey)) {
+                Cache::put($rateKey, 1, 3600);
+            }
+
+            Cache::forget("filters:v3:{$pid}");
+
+            DB::commit();
+
+            $filter->load(['keywords', 'statuses']);
+
+            $res = [
+                'id' => $filter->id,
+                'title' => $filter->title,
+                'context' => $filter->context,
+                'expires_at' => $filter->expires_at,
+                'filter_action' => $filter->filterAction,
+                'keywords' => $filter->keywords->map(function ($keyword) {
+                    return [
+                        'id' => $keyword->id,
+                        'keyword' => $keyword->keyword,
+                        'whole_word' => (bool) $keyword->whole_word,
+                    ];
+                }),
+                'statuses' => $filter->statuses->map(function ($status) {
+                    return [
+                        'id' => $status->id,
+                        'status_id' => $status->status_id,
+                    ];
+                }),
+            ];
+
+            return response()->json($res);
+
+        } catch (\Exception $e) {
+            DB::rollBack();
+
+            return response()->json([
+                'error' => 'Failed to update filter',
+                'message' => $e->getMessage(),
+            ], 500);
+        }
+    }
+
+    public function delete(Request $request, $id)
+    {
+        abort_if(! $request->user() || ! $request->user()->token(), 403);
+        abort_unless($request->user()->tokenCan('write'), 403);
+
+        $filter = CustomFilter::findOrFail($id);
+        Gate::authorize('delete', $filter);
+        $filter->delete();
+
+        return response()->json((object) [], 200);
+    }
+}

+ 10 - 0
app/Http/Controllers/CustomFilterKeywordController.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class CustomFilterKeywordController extends Controller
+{
+    //
+}

+ 10 - 0
app/Http/Controllers/CustomFilterStatusController.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Http\Controllers;
+
+use Illuminate\Http\Request;
+
+class CustomFilterStatusController extends Controller
+{
+    //
+}

+ 5 - 0
app/Http/Controllers/SettingsController.php

@@ -350,4 +350,9 @@ class SettingsController extends Controller
 
 
         return redirect(route('settings'))->with('status', 'Media settings successfully updated!');
         return redirect(route('settings'))->with('status', 'Media settings successfully updated!');
     }
     }
+
+    public function filtersHome(Request $request)
+    {
+        return view('settings.filters.home');
+    }
 }
 }

+ 412 - 0
app/Models/CustomFilter.php

@@ -0,0 +1,412 @@
+<?php
+
+namespace App\Models;
+
+use App\Profile;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Facades\Cache;
+
+class CustomFilter extends Model
+{
+    public $shouldInvalidateCache = false;
+
+    protected $fillable = [
+        'title', 'phrase', 'context', 'expires_at', 'action', 'profile_id',
+    ];
+
+    protected $casts = [
+        'id' => 'string',
+        'context' => 'array',
+        'expires_at' => 'datetime',
+        'action' => 'integer',
+    ];
+
+    protected $guarded = ['shouldInvalidateCache'];
+
+    const VALID_CONTEXTS = [
+        'home',
+        'notifications',
+        'public',
+        'thread',
+        'account',
+    ];
+
+    const MAX_STATUSES_PER_FILTER = 10;
+
+    const EXPIRATION_DURATIONS = [
+        1800,   // 30 minutes
+        3600,   // 1 hour
+        21600,  // 6 hours
+        43200,  // 12 hours
+        86400,  // 1 day
+        604800, // 1 week
+    ];
+
+    const ACTION_WARN = 0;
+
+    const ACTION_HIDE = 1;
+
+    const ACTION_BLUR = 2;
+
+    protected static ?int $maxContentScanLimit = null;
+
+    protected static ?int $maxFiltersPerUser = null;
+
+    protected static ?int $maxKeywordsPerFilter = null;
+
+    protected static ?int $maxKeywordsLength = null;
+
+    protected static ?int $maxPatternLength = null;
+
+    protected static ?int $maxCreatePerHour = null;
+
+    protected static ?int $maxUpdatesPerHour = null;
+
+    public function account()
+    {
+        return $this->belongsTo(Profile::class, 'profile_id');
+    }
+
+    public function keywords()
+    {
+        return $this->hasMany(CustomFilterKeyword::class);
+    }
+
+    public function statuses()
+    {
+        return $this->hasMany(CustomFilterStatus::class);
+    }
+
+    public function toFilterArray()
+    {
+        return [
+            'id' => $this->id,
+            'title' => $this->title,
+            'context' => $this->context,
+            'expires_at' => $this->expires_at,
+            'filter_action' => $this->filterAction,
+        ];
+    }
+
+    public function getFilterActionAttribute()
+    {
+        switch ($this->action) {
+            case 0:
+                return 'warn';
+                break;
+
+            case 1:
+                return 'hide';
+                break;
+
+            case 2:
+                return 'blur';
+                break;
+        }
+    }
+
+    public function getTitleAttribute()
+    {
+        return $this->phrase;
+    }
+
+    public function setTitleAttribute($value)
+    {
+        $this->attributes['phrase'] = $value;
+    }
+
+    public function setFilterActionAttribute($value)
+    {
+        $this->attributes['action'] = $value;
+    }
+
+    public function setIrreversibleAttribute($value)
+    {
+        $this->attributes['action'] = $value ? self::ACTION_HIDE : self::ACTION_WARN;
+    }
+
+    public function getIrreversibleAttribute()
+    {
+        return $this->action === self::ACTION_HIDE;
+    }
+
+    public function getExpiresInAttribute()
+    {
+        if ($this->expires_at === null) {
+            return null;
+        }
+
+        $now = now();
+        foreach (self::EXPIRATION_DURATIONS as $duration) {
+            if ($now->addSeconds($duration)->gte($this->expires_at)) {
+                return $duration;
+            }
+        }
+
+        return null;
+    }
+
+    public function scopeUnexpired($query)
+    {
+        return $query->where(function ($q) {
+            $q->whereNull('expires_at')
+                ->orWhere('expires_at', '>', now());
+        });
+    }
+
+    public function isExpired()
+    {
+        return $this->expires_at !== null && $this->expires_at->isPast();
+    }
+
+    protected static function boot()
+    {
+        parent::boot();
+
+        static::saving(function ($model) {
+            $model->prepareContextForStorage();
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::updating(function ($model) {
+            $model->prepareContextForStorage();
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::deleting(function ($model) {
+            $model->shouldInvalidateCache = true;
+        });
+
+        static::saved(function ($model) {
+            $model->invalidateCache();
+        });
+
+        static::deleted(function ($model) {
+            $model->invalidateCache();
+        });
+    }
+
+    protected function prepareContextForStorage()
+    {
+        if (is_array($this->context)) {
+            $this->context = array_values(array_filter(array_map('trim', $this->context)));
+        }
+    }
+
+    protected function invalidateCache()
+    {
+        if (! isset($this->shouldInvalidateCache) || ! $this->shouldInvalidateCache) {
+            return;
+        }
+
+        $this->shouldInvalidateCache = false;
+
+        Cache::forget("filters:v3:{$this->profile_id}");
+    }
+
+    public static function getMaxContentScanLimit(): int
+    {
+        if (self::$maxContentScanLimit === null) {
+            self::$maxContentScanLimit = config('instance.custom_filters.max_content_scan_limit', 2500);
+        }
+
+        return self::$maxContentScanLimit;
+    }
+
+    public static function getMaxFiltersPerUser(): int
+    {
+        if (self::$maxFiltersPerUser === null) {
+            self::$maxFiltersPerUser = config('instance.custom_filters.max_filters_per_user', 20);
+        }
+
+        return self::$maxFiltersPerUser;
+    }
+
+    public static function getMaxKeywordsPerFilter(): int
+    {
+        if (self::$maxKeywordsPerFilter === null) {
+            self::$maxKeywordsPerFilter = config('instance.custom_filters.max_keywords_per_filter', 10);
+        }
+
+        return self::$maxKeywordsPerFilter;
+    }
+
+    public static function getMaxKeywordLength(): int
+    {
+        if (self::$maxKeywordsLength === null) {
+            self::$maxKeywordsLength = config('instance.custom_filters.max_keyword_length', 40);
+        }
+
+        return self::$maxKeywordsLength;
+    }
+
+    public static function getMaxPatternLength(): int
+    {
+        if (self::$maxPatternLength === null) {
+            self::$maxPatternLength = config('instance.custom_filters.max_pattern_length', 10000);
+        }
+
+        return self::$maxPatternLength;
+    }
+
+    public static function getMaxCreatePerHour(): int
+    {
+        if (self::$maxCreatePerHour === null) {
+            self::$maxCreatePerHour = config('instance.custom_filters.max_create_per_hour', 20);
+        }
+
+        return self::$maxCreatePerHour;
+    }
+
+    public static function getMaxUpdatesPerHour(): int
+    {
+        if (self::$maxUpdatesPerHour === null) {
+            self::$maxUpdatesPerHour = config('instance.custom_filters.max_updates_per_hour', 40);
+        }
+
+        return self::$maxUpdatesPerHour;
+    }
+
+    /**
+     * Get cached filters for an account with simplified, secure approach
+     *
+     * @param  int  $profileId  The profile ID
+     * @return Collection The collection of filters
+     */
+    public static function getCachedFiltersForAccount($profileId)
+    {
+        $activeFilters = Cache::remember("filters:v3:{$profileId}", 3600, function () use ($profileId) {
+            $filtersHash = [];
+
+            $keywordFilters = CustomFilterKeyword::with(['customFilter' => function ($query) use ($profileId) {
+                $query->unexpired()->where('profile_id', $profileId);
+            }])->get();
+
+            $keywordFilters->groupBy('custom_filter_id')->each(function ($keywords, $filterId) use (&$filtersHash) {
+                $filter = $keywords->first()->customFilter;
+
+                if (! $filter) {
+                    return;
+                }
+
+                $maxPatternsPerFilter = self::getMaxFiltersPerUser();
+                $keywordsToProcess = $keywords->take($maxPatternsPerFilter);
+
+                $regexPatterns = $keywordsToProcess->map(function ($keyword) {
+                    $pattern = preg_quote($keyword->keyword, '/');
+
+                    if ($keyword->whole_word) {
+                        $pattern = '\b'.$pattern.'\b';
+                    }
+
+                    return $pattern;
+                })->toArray();
+
+                if (empty($regexPatterns)) {
+                    return;
+                }
+
+                $combinedPattern = implode('|', $regexPatterns);
+                $maxPatternLength = self::getMaxPatternLength();
+                if (strlen($combinedPattern) > $maxPatternLength) {
+                    $combinedPattern = substr($combinedPattern, 0, $maxPatternLength);
+                }
+
+                $filtersHash[$filterId] = [
+                    'keywords' => '/'.$combinedPattern.'/i',
+                    'filter' => $filter,
+                ];
+            });
+
+            // $statusFilters = CustomFilterStatus::with(['customFilter' => function ($query) use ($profileId) {
+            //     $query->unexpired()->where('profile_id', $profileId);
+            // }])->get();
+
+            // $statusFilters->groupBy('custom_filter_id')->each(function ($statuses, $filterId) use (&$filtersHash) {
+            //     $filter = $statuses->first()->customFilter;
+
+            //     if (! $filter) {
+            //         return;
+            //     }
+
+            //     if (! isset($filtersHash[$filterId])) {
+            //         $filtersHash[$filterId] = ['filter' => $filter];
+            //     }
+
+            //     $maxStatusIds = self::MAX_STATUSES_PER_FILTER;
+            //     $filtersHash[$filterId]['status_ids'] = $statuses->take($maxStatusIds)->pluck('status_id')->toArray();
+            // });
+
+            return array_map(function ($item) {
+                $filter = $item['filter'];
+                unset($item['filter']);
+
+                return [$filter, $item];
+            }, $filtersHash);
+        });
+
+        return collect($activeFilters)->reject(function ($item) {
+            [$filter, $rules] = $item;
+
+            return $filter->isExpired();
+        })->toArray();
+    }
+
+    /**
+     * Apply cached filters to a status with reasonable safety measures
+     *
+     * @param  array  $cachedFilters  The cached filters
+     * @param  mixed  $status  The status to check
+     * @return array The filter matches
+     */
+    public static function applyCachedFilters($cachedFilters, $status)
+    {
+        $results = [];
+
+        foreach ($cachedFilters as [$filter, $rules]) {
+            $keywordMatches = [];
+            $statusMatches = null;
+
+            if (isset($rules['keywords'])) {
+                $text = strip_tags($status['content']);
+
+                $maxContentLength = self::getMaxContentScanLimit();
+                if (mb_strlen($text) > $maxContentLength) {
+                    $text = mb_substr($text, 0, $maxContentLength);
+                }
+
+                try {
+                    preg_match_all($rules['keywords'], $text, $matches, PREG_PATTERN_ORDER, 0);
+                    if (! empty($matches[0])) {
+                        $maxReportedMatches = (int) config('instance.custom_filters.max_reported_matches', 10);
+                        $keywordMatches = array_slice($matches[0], 0, $maxReportedMatches);
+                    }
+                } catch (\Throwable $e) {
+                    \Log::error('Filter regex error: '.$e->getMessage(), [
+                        'filter_id' => $filter->id,
+                    ]);
+                }
+            }
+
+            // if (isset($rules['status_ids'])) {
+            //     $statusId = $status->id;
+            //     $reblogId = $status->reblog_of_id ?? null;
+
+            //     $matchingIds = array_intersect($rules['status_ids'], array_filter([$statusId, $reblogId]));
+            //     if (! empty($matchingIds)) {
+            //         $statusMatches = $matchingIds;
+            //     }
+            // }
+
+            if (! empty($keywordMatches) || ! empty($statusMatches)) {
+                $results[] = [
+                    'filter' => $filter->toFilterArray(),
+                    'keyword_matches' => $keywordMatches ?: null,
+                    'status_matches' => ! empty($statusMatches) ? $statusMatches : null,
+                ];
+            }
+        }
+
+        return $results;
+    }
+}

+ 37 - 0
app/Models/CustomFilterKeyword.php

@@ -0,0 +1,37 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class CustomFilterKeyword extends Model
+{
+    protected $fillable = [
+        'keyword', 'whole_word', 'custom_filter_id',
+    ];
+
+    protected $casts = [
+        'whole_word' => 'boolean',
+    ];
+
+    public function customFilter()
+    {
+        return $this->belongsTo(CustomFilter::class);
+    }
+
+    public function setKeywordAttribute($value)
+    {
+        $this->attributes['keyword'] = mb_strtolower(trim($value));
+    }
+
+    public function toRegex()
+    {
+        $pattern = preg_quote($this->keyword, '/');
+
+        if ($this->whole_word) {
+            $pattern = '\b'.$pattern.'\b';
+        }
+
+        return '/'.$pattern.'/i';
+    }
+}

+ 23 - 0
app/Models/CustomFilterStatus.php

@@ -0,0 +1,23 @@
+<?php
+
+namespace App\Models;
+
+use App\Status;
+use Illuminate\Database\Eloquent\Model;
+
+class CustomFilterStatus extends Model
+{
+    protected $fillable = [
+        'custom_filter_id', 'status_id',
+    ];
+
+    public function customFilter()
+    {
+        return $this->belongsTo(CustomFilter::class);
+    }
+
+    public function status()
+    {
+        return $this->belongsTo(Status::class);
+    }
+}

+ 61 - 0
app/Policies/CustomFilterPolicy.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Policies;
+
+use App\Models\CustomFilter;
+use App\User;
+
+class CustomFilterPolicy
+{
+    /**
+     * Determine whether the user can view any models.
+     */
+    public function viewAny(User $user): bool
+    {
+        return false;
+    }
+
+    /**
+     * Determine whether the user can view the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function view(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+
+    /**
+     * Determine whether the user can create models.
+     */
+    public function create(User $user): bool
+    {
+        return CustomFilter::whereProfileId($user->profile_id)->count() <= 100;
+    }
+
+    /**
+     * Determine whether the user can update the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function update(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+
+    /**
+     * Determine whether the user can delete the custom filter.
+     *
+     * @param  \App\User  $user
+     * @param  \App\Models\CustomFilter  $filter
+     * @return bool
+     */
+    public function delete(User $user, CustomFilter $filter)
+    {
+        return $user->profile_id === $filter->profile_id;
+    }
+}

+ 3 - 1
app/Providers/AuthServiceProvider.php

@@ -5,6 +5,8 @@ namespace App\Providers;
 use Gate;
 use Gate;
 use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
 use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
 use Laravel\Passport\Passport;
 use Laravel\Passport\Passport;
+use App\Models\CustomFilter;
+use App\Policies\CustomFilterPolicy;
 
 
 class AuthServiceProvider extends ServiceProvider
 class AuthServiceProvider extends ServiceProvider
 {
 {
@@ -14,7 +16,7 @@ class AuthServiceProvider extends ServiceProvider
      * @var array
      * @var array
      */
      */
     protected $policies = [
     protected $policies = [
-        // 'App\Model' => 'App\Policies\ModelPolicy',
+        CustomFilter::class => CustomFilterPolicy::class,
     ];
     ];
 
 
     /**
     /**

+ 62 - 0
app/Rules/Webfinger.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace App\Rules;
+
+use Illuminate\Contracts\Validation\Rule;
+
+class WebFinger implements Rule
+{
+    /**
+     * Determine if the validation rule passes.
+     *
+     * @param  string  $attribute
+     * @param  mixed  $value
+     * @return bool
+     */
+    public function passes($attribute, $value)
+    {
+        if (! is_string($value)) {
+            return false;
+        }
+
+        $mention = $value;
+        if (str_starts_with($mention, '@')) {
+            $mention = substr($mention, 1);
+        }
+
+        $parts = explode('@', $mention);
+        if (count($parts) !== 2) {
+            return false;
+        }
+
+        [$username, $domain] = $parts;
+
+        if (empty($username) ||
+            ! preg_match('/^[a-zA-Z0-9_.-]+$/', $username) ||
+            strlen($username) >= 80) {
+            return false;
+        }
+
+        if (empty($domain) ||
+            ! str_contains($domain, '.') ||
+            ! preg_match('/^[a-zA-Z0-9.-]+$/', $domain) ||
+            strlen($domain) >= 255) {
+            return false;
+        }
+
+        // Optional: Check if domain resolves (can be enabled for stricter validation)
+        // return checkdnsrr($domain, 'A') || checkdnsrr($domain, 'AAAA') || checkdnsrr($domain, 'MX');
+
+        return true;
+    }
+
+    /**
+     * Get the validation error message.
+     *
+     * @return string
+     */
+    public function message()
+    {
+        return 'The :attribute must be a valid WebFinger address (username@domain.tld or @username@domain.tld)';
+    }
+}

+ 16 - 0
app/Services/WebfingerService.php

@@ -11,10 +11,26 @@ class WebfingerService
 {
 {
     public static function rawGet($url)
     public static function rawGet($url)
     {
     {
+        if (empty($url)) {
+            return false;
+        }
+
         $n = WebfingerUrl::get($url);
         $n = WebfingerUrl::get($url);
+
         if (! $n) {
         if (! $n) {
             return false;
             return false;
         }
         }
+        if (empty($n) || ! str_starts_with($n, 'https://')) {
+            return false;
+        }
+        $host = parse_url($n, PHP_URL_HOST);
+        if (! $host) {
+            return false;
+        }
+
+        if (in_array($host, InstanceService::getBannedDomains())) {
+            return false;
+        }
         $webfinger = FetchCacheService::getJson($n);
         $webfinger = FetchCacheService::getJson($n);
         if (! $webfinger) {
         if (! $webfinger) {
             return false;
             return false;

+ 74 - 0
config/instance.php

@@ -190,4 +190,78 @@ return [
     'allow_new_account_dms' => env('INSTANCE_ALLOW_NEW_DMS', true),
     'allow_new_account_dms' => env('INSTANCE_ALLOW_NEW_DMS', true),
 
 
     'total_count_estimate' => env('INSTANCE_TOTAL_POSTS_COUNT_ESTIMATE', false),
     'total_count_estimate' => env('INSTANCE_TOTAL_POSTS_COUNT_ESTIMATE', false),
+
+    'custom_filters' => [
+        /*
+         * The maximum number of characters from a status that will be scanned
+         * for filter matching. Scanning too many characters can hurt performance,
+         * so this limit ensures that only the most relevant portion of a status is processed.
+         *
+         * For remote statuses, you might want to increase this value if you expect
+         * important content to appear later in long posts.
+         */
+        'max_content_scan_limit' => env('PF_CF_CONTENT_SCAN_LIMIT', 2500),
+
+        /*
+         * The maximum number of filters a single user can create.
+         * Limiting the number of filters per user helps prevent abuse and
+         * ensures that the filtering system remains performant.
+         */
+        'max_filters_per_user' => env('PF_CF_MAX_FILTERS_PER_USER', 20),
+
+        /*
+         * The maximum number of keywords that can be associated with a single filter.
+         * This limit helps control the complexity of the generated regular expressions
+         * and protects against potential performance issues during content scanning.
+         */
+        'max_keywords_per_filter' => env('PF_CF_MAX_KEYWORDS_PER_FILTER', 10),
+
+        /*
+         * The maximum length allowed for each keyword in a filter.
+         * Limiting keyword length not only curtails the size of the regex patterns created,
+         * but also guards against potential abuse where excessively long keywords might
+         * negatively impact matching performance or lead to unintended behavior.
+         */
+        'max_keyword_length' => env('PF_CF_MAX_KEYWORD_LENGTH', 40),
+
+        /*
+         * The maximum allowed length for the combined regex pattern.
+         * When constructing a regex that matches multiple filter keywords, each keyword
+         * (after escaping and adding boundaries) contributes to the total pattern length.
+         *
+         * This value is set to 10000 by default. If you increase either the number of keywords
+         * per filter or the maximum length allowed for each keyword, consider increasing this
+         * limit accordingly so that the final regex pattern can accommodate the additional length
+         * without being truncated or causing performance issues.
+         */
+        'max_pattern_length' => env('PF_CF_MAX_PATTERN_LENGTH', 10000),
+
+        /*
+         * The maximum number of keyword matches to report for a given status.
+         * When a filter is applied to a status, the matching process may find multiple occurrences
+         * of a keyword. This value limits the number of matches that are reported back,
+         * which helps manage output volume and processing overhead.
+         *
+         * The default is set to 10, but you can adjust this value through your environment configuration.
+         */
+        'max_reported_matches' => env('PF_CF_MAX_REPORTED_MATCHES', 10),
+
+        /*
+         * The maximum number of filter creation operations allowed per hour for a non-admin user.
+         * This rate limit prevents abuse by restricting how many filters a normal user can create
+         * within one hour. Admin users are exempt from this limit.
+         *
+         * Default is 20 creations per hour.
+         */
+        'max_create_per_hour' => env('PF_CF_MAX_CREATE_PER_HOUR', 20),
+
+        /*
+         * The maximum number of filter update operations allowed per hour for a non-admin user.
+         * This rate limit is designed to prevent abuse by limiting how many times a normal user
+         * can update their filters within one hour. Admin users are not subject to these limits.
+         *
+         * Default is 40 updates per hour.
+         */
+        'max_updates_per_hour' => env('PF_CF_MAX_UPDATES_PER_HOUR', 40),
+    ],
 ];
 ];

+ 32 - 0
database/migrations/2025_04_08_102711_create_custom_filters_table.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('custom_filters', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('profile_id')->constrained()->onDelete('cascade');
+            $table->text('phrase')->default('')->nullable(false);
+            $table->integer('action')->default(0)->nullable(false); // 0=warn, 1=hide, 2=blur
+            $table->json('context')->nullable(true);
+            $table->timestamp('expires_at')->nullable();
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('custom_filters');
+    }
+};

+ 30 - 0
database/migrations/2025_04_08_103425_create_custom_filter_keywords_table.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('custom_filter_keywords', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('custom_filter_id')->constrained()->onDelete('cascade');
+            $table->string('keyword', 255)->nullable(false);
+            $table->boolean('whole_word')->default(true);
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('custom_filter_keywords');
+    }
+};

+ 29 - 0
database/migrations/2025_04_08_103433_create_custom_filter_statuses_table.php

@@ -0,0 +1,29 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('custom_filter_statuses', function (Blueprint $table) {
+            $table->id();
+            $table->foreignId('custom_filter_id')->constrained()->onDelete('cascade');
+            $table->foreignId('status_id')->constrained()->onDelete('cascade');
+            $table->timestamps();
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     */
+    public function down(): void
+    {
+        Schema::dropIfExists('custom_filter_statuses');
+    }
+};

+ 228 - 227
package-lock.json

@@ -144,12 +144,12 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/generator": {
 		"node_modules/@babel/generator": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.10.tgz",
-			"integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz",
+			"integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==",
 			"dependencies": {
 			"dependencies": {
-				"@babel/parser": "^7.26.10",
-				"@babel/types": "^7.26.10",
+				"@babel/parser": "^7.27.0",
+				"@babel/types": "^7.27.0",
 				"@jridgewell/gen-mapping": "^0.3.5",
 				"@jridgewell/gen-mapping": "^0.3.5",
 				"@jridgewell/trace-mapping": "^0.3.25",
 				"@jridgewell/trace-mapping": "^0.3.25",
 				"jsesc": "^3.0.2"
 				"jsesc": "^3.0.2"
@@ -170,11 +170,11 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/helper-compilation-targets": {
 		"node_modules/@babel/helper-compilation-targets": {
-			"version": "7.26.5",
-			"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz",
-			"integrity": "sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz",
+			"integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==",
 			"dependencies": {
 			"dependencies": {
-				"@babel/compat-data": "^7.26.5",
+				"@babel/compat-data": "^7.26.8",
 				"@babel/helper-validator-option": "^7.25.9",
 				"@babel/helper-validator-option": "^7.25.9",
 				"browserslist": "^4.24.0",
 				"browserslist": "^4.24.0",
 				"lru-cache": "^5.1.1",
 				"lru-cache": "^5.1.1",
@@ -193,16 +193,16 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/helper-create-class-features-plugin": {
 		"node_modules/@babel/helper-create-class-features-plugin": {
-			"version": "7.26.9",
-			"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.26.9.tgz",
-			"integrity": "sha512-ubbUqCofvxPRurw5L8WTsCLSkQiVpov4Qx0WMA+jUN+nXBK8ADPlJO1grkFw5CWKC5+sZSOfuGMdX1aI1iT9Sg==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz",
+			"integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==",
 			"dependencies": {
 			"dependencies": {
 				"@babel/helper-annotate-as-pure": "^7.25.9",
 				"@babel/helper-annotate-as-pure": "^7.25.9",
 				"@babel/helper-member-expression-to-functions": "^7.25.9",
 				"@babel/helper-member-expression-to-functions": "^7.25.9",
 				"@babel/helper-optimise-call-expression": "^7.25.9",
 				"@babel/helper-optimise-call-expression": "^7.25.9",
 				"@babel/helper-replace-supers": "^7.26.5",
 				"@babel/helper-replace-supers": "^7.26.5",
 				"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
 				"@babel/helper-skip-transparent-expression-wrappers": "^7.25.9",
-				"@babel/traverse": "^7.26.9",
+				"@babel/traverse": "^7.27.0",
 				"semver": "^6.3.1"
 				"semver": "^6.3.1"
 			},
 			},
 			"engines": {
 			"engines": {
@@ -221,9 +221,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/helper-create-regexp-features-plugin": {
 		"node_modules/@babel/helper-create-regexp-features-plugin": {
-			"version": "7.26.3",
-			"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.26.3.tgz",
-			"integrity": "sha512-G7ZRb40uUgdKOQqPLjfD12ZmGA54PzqDFUv2BKImnC9QIfGhIHKvVML0oN8IUiDq4iRqpq74ABpvOaerfWdong==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz",
+			"integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==",
 			"dependencies": {
 			"dependencies": {
 				"@babel/helper-annotate-as-pure": "^7.25.9",
 				"@babel/helper-annotate-as-pure": "^7.25.9",
 				"regexpu-core": "^6.2.0",
 				"regexpu-core": "^6.2.0",
@@ -245,9 +245,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/helper-define-polyfill-provider": {
 		"node_modules/@babel/helper-define-polyfill-provider": {
-			"version": "0.6.3",
-			"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz",
-			"integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==",
+			"version": "0.6.4",
+			"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz",
+			"integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==",
 			"dependencies": {
 			"dependencies": {
 				"@babel/helper-compilation-targets": "^7.22.6",
 				"@babel/helper-compilation-targets": "^7.22.6",
 				"@babel/helper-plugin-utils": "^7.22.5",
 				"@babel/helper-plugin-utils": "^7.22.5",
@@ -400,23 +400,23 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/helpers": {
 		"node_modules/@babel/helpers": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.10.tgz",
-			"integrity": "sha512-UPYc3SauzZ3JGgj87GgZ89JVdC5dj0AoetR5Bw6wj4niittNyFh6+eOGonYvJ1ao6B8lEa3Q3klS7ADZ53bc5g==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz",
+			"integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==",
 			"dependencies": {
 			"dependencies": {
-				"@babel/template": "^7.26.9",
-				"@babel/types": "^7.26.10"
+				"@babel/template": "^7.27.0",
+				"@babel/types": "^7.27.0"
 			},
 			},
 			"engines": {
 			"engines": {
 				"node": ">=6.9.0"
 				"node": ">=6.9.0"
 			}
 			}
 		},
 		},
 		"node_modules/@babel/parser": {
 		"node_modules/@babel/parser": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.10.tgz",
-			"integrity": "sha512-6aQR2zGE/QFi8JpDLjUZEPYOs7+mhKXm86VaKFiLP35JQwQb6bwUE+XbvkH0EptsYhbNBSUGaUBLKqxH1xSgsA==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz",
+			"integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==",
 			"dependencies": {
 			"dependencies": {
-				"@babel/types": "^7.26.10"
+				"@babel/types": "^7.27.0"
 			},
 			},
 			"bin": {
 			"bin": {
 				"parser": "bin/babel-parser.js"
 				"parser": "bin/babel-parser.js"
@@ -655,11 +655,11 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/plugin-transform-block-scoping": {
 		"node_modules/@babel/plugin-transform-block-scoping": {
-			"version": "7.25.9",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz",
-			"integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz",
+			"integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==",
 			"dependencies": {
 			"dependencies": {
-				"@babel/helper-plugin-utils": "^7.25.9"
+				"@babel/helper-plugin-utils": "^7.26.5"
 			},
 			},
 			"engines": {
 			"engines": {
 				"node": ">=6.9.0"
 				"node": ">=6.9.0"
@@ -1158,11 +1158,11 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/plugin-transform-regenerator": {
 		"node_modules/@babel/plugin-transform-regenerator": {
-			"version": "7.25.9",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz",
-			"integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz",
+			"integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==",
 			"dependencies": {
 			"dependencies": {
-				"@babel/helper-plugin-utils": "^7.25.9",
+				"@babel/helper-plugin-utils": "^7.26.5",
 				"regenerator-transform": "^0.15.2"
 				"regenerator-transform": "^0.15.2"
 			},
 			},
 			"engines": {
 			"engines": {
@@ -1286,9 +1286,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/plugin-transform-typeof-symbol": {
 		"node_modules/@babel/plugin-transform-typeof-symbol": {
-			"version": "7.26.7",
-			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.26.7.tgz",
-			"integrity": "sha512-jfoTXXZTgGg36BmhqT3cAYK5qkmqvJpvNrPhaK/52Vgjhw4Rq29s9UqpWWV0D6yuRmgiFH/BUVlkl96zJWqnaw==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz",
+			"integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==",
 			"dependencies": {
 			"dependencies": {
 				"@babel/helper-plugin-utils": "^7.26.5"
 				"@babel/helper-plugin-utils": "^7.26.5"
 			},
 			},
@@ -1462,9 +1462,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/runtime": {
 		"node_modules/@babel/runtime": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
-			"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
+			"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
 			"dependencies": {
 			"dependencies": {
 				"regenerator-runtime": "^0.14.0"
 				"regenerator-runtime": "^0.14.0"
 			},
 			},
@@ -1473,28 +1473,28 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/template": {
 		"node_modules/@babel/template": {
-			"version": "7.26.9",
-			"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.26.9.tgz",
-			"integrity": "sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz",
+			"integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==",
 			"dependencies": {
 			"dependencies": {
 				"@babel/code-frame": "^7.26.2",
 				"@babel/code-frame": "^7.26.2",
-				"@babel/parser": "^7.26.9",
-				"@babel/types": "^7.26.9"
+				"@babel/parser": "^7.27.0",
+				"@babel/types": "^7.27.0"
 			},
 			},
 			"engines": {
 			"engines": {
 				"node": ">=6.9.0"
 				"node": ">=6.9.0"
 			}
 			}
 		},
 		},
 		"node_modules/@babel/traverse": {
 		"node_modules/@babel/traverse": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.26.10.tgz",
-			"integrity": "sha512-k8NuDrxr0WrPH5Aupqb2LCVURP/S0vBEn5mK6iH+GIYob66U5EtoZvcdudR2jQ4cmTwhEwW1DLB+Yyas9zjF6A==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz",
+			"integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==",
 			"dependencies": {
 			"dependencies": {
 				"@babel/code-frame": "^7.26.2",
 				"@babel/code-frame": "^7.26.2",
-				"@babel/generator": "^7.26.10",
-				"@babel/parser": "^7.26.10",
-				"@babel/template": "^7.26.9",
-				"@babel/types": "^7.26.10",
+				"@babel/generator": "^7.27.0",
+				"@babel/parser": "^7.27.0",
+				"@babel/template": "^7.27.0",
+				"@babel/types": "^7.27.0",
 				"debug": "^4.3.1",
 				"debug": "^4.3.1",
 				"globals": "^11.1.0"
 				"globals": "^11.1.0"
 			},
 			},
@@ -1503,9 +1503,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/types": {
 		"node_modules/@babel/types": {
-			"version": "7.26.10",
-			"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.10.tgz",
-			"integrity": "sha512-emqcG3vHrpxUKTrxcblR36dcrcoRDvKmnL/dCL6ZsHaShW80qxCAcNhzQZrpeM765VzEos+xOi4s+r4IXzTwdQ==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz",
+			"integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==",
 			"dependencies": {
 			"dependencies": {
 				"@babel/helper-string-parser": "^7.25.9",
 				"@babel/helper-string-parser": "^7.25.9",
 				"@babel/helper-validator-identifier": "^7.25.9"
 				"@babel/helper-validator-identifier": "^7.25.9"
@@ -1532,9 +1532,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/aix-ppc64": {
 		"node_modules/@esbuild/aix-ppc64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.1.tgz",
-			"integrity": "sha512-kfYGy8IdzTGy+z0vFGvExZtxkFlA4zAxgKEahG9KE1ScBjpQnFsNOX8KTU5ojNru5ed5CVoJYXFtoxaq5nFbjQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz",
+			"integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==",
 			"cpu": [
 			"cpu": [
 				"ppc64"
 				"ppc64"
 			],
 			],
@@ -1548,9 +1548,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/android-arm": {
 		"node_modules/@esbuild/android-arm": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.1.tgz",
-			"integrity": "sha512-dp+MshLYux6j/JjdqVLnMglQlFu+MuVeNrmT5nk6q07wNhCdSnB7QZj+7G8VMUGh1q+vj2Bq8kRsuyA00I/k+Q==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz",
+			"integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==",
 			"cpu": [
 			"cpu": [
 				"arm"
 				"arm"
 			],
 			],
@@ -1564,9 +1564,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/android-arm64": {
 		"node_modules/@esbuild/android-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.1.tgz",
-			"integrity": "sha512-50tM0zCJW5kGqgG7fQ7IHvQOcAn9TKiVRuQ/lN0xR+T2lzEFvAi1ZcS8DiksFcEpf1t/GYOeOfCAgDHFpkiSmA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz",
+			"integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==",
 			"cpu": [
 			"cpu": [
 				"arm64"
 				"arm64"
 			],
 			],
@@ -1580,9 +1580,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/android-x64": {
 		"node_modules/@esbuild/android-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.1.tgz",
-			"integrity": "sha512-GCj6WfUtNldqUzYkN/ITtlhwQqGWu9S45vUXs7EIYf+7rCiiqH9bCloatO9VhxsL0Pji+PF4Lz2XXCES+Q8hDw==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz",
+			"integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==",
 			"cpu": [
 			"cpu": [
 				"x64"
 				"x64"
 			],
 			],
@@ -1596,9 +1596,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/darwin-arm64": {
 		"node_modules/@esbuild/darwin-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.1.tgz",
-			"integrity": "sha512-5hEZKPf+nQjYoSr/elb62U19/l1mZDdqidGfmFutVUjjUZrOazAtwK+Kr+3y0C/oeJfLlxo9fXb1w7L+P7E4FQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz",
+			"integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==",
 			"cpu": [
 			"cpu": [
 				"arm64"
 				"arm64"
 			],
 			],
@@ -1612,9 +1612,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/darwin-x64": {
 		"node_modules/@esbuild/darwin-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.1.tgz",
-			"integrity": "sha512-hxVnwL2Dqs3fM1IWq8Iezh0cX7ZGdVhbTfnOy5uURtao5OIVCEyj9xIzemDi7sRvKsuSdtCAhMKarxqtlyVyfA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz",
+			"integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==",
 			"cpu": [
 			"cpu": [
 				"x64"
 				"x64"
 			],
 			],
@@ -1628,9 +1628,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/freebsd-arm64": {
 		"node_modules/@esbuild/freebsd-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.1.tgz",
-			"integrity": "sha512-1MrCZs0fZa2g8E+FUo2ipw6jw5qqQiH+tERoS5fAfKnRx6NXH31tXBKI3VpmLijLH6yriMZsxJtaXUyFt/8Y4A==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz",
+			"integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==",
 			"cpu": [
 			"cpu": [
 				"arm64"
 				"arm64"
 			],
 			],
@@ -1644,9 +1644,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/freebsd-x64": {
 		"node_modules/@esbuild/freebsd-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.1.tgz",
-			"integrity": "sha512-0IZWLiTyz7nm0xuIs0q1Y3QWJC52R8aSXxe40VUxm6BB1RNmkODtW6LHvWRrGiICulcX7ZvyH6h5fqdLu4gkww==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz",
+			"integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==",
 			"cpu": [
 			"cpu": [
 				"x64"
 				"x64"
 			],
 			],
@@ -1660,9 +1660,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/linux-arm": {
 		"node_modules/@esbuild/linux-arm": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.1.tgz",
-			"integrity": "sha512-NdKOhS4u7JhDKw9G3cY6sWqFcnLITn6SqivVArbzIaf3cemShqfLGHYMx8Xlm/lBit3/5d7kXvriTUGa5YViuQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz",
+			"integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==",
 			"cpu": [
 			"cpu": [
 				"arm"
 				"arm"
 			],
 			],
@@ -1676,9 +1676,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/linux-arm64": {
 		"node_modules/@esbuild/linux-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.1.tgz",
-			"integrity": "sha512-jaN3dHi0/DDPelk0nLcXRm1q7DNJpjXy7yWaWvbfkPvI+7XNSc/lDOnCLN7gzsyzgu6qSAmgSvP9oXAhP973uQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz",
+			"integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==",
 			"cpu": [
 			"cpu": [
 				"arm64"
 				"arm64"
 			],
 			],
@@ -1692,9 +1692,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/linux-ia32": {
 		"node_modules/@esbuild/linux-ia32": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.1.tgz",
-			"integrity": "sha512-OJykPaF4v8JidKNGz8c/q1lBO44sQNUQtq1KktJXdBLn1hPod5rE/Hko5ugKKZd+D2+o1a9MFGUEIUwO2YfgkQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz",
+			"integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==",
 			"cpu": [
 			"cpu": [
 				"ia32"
 				"ia32"
 			],
 			],
@@ -1708,9 +1708,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/linux-loong64": {
 		"node_modules/@esbuild/linux-loong64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.1.tgz",
-			"integrity": "sha512-nGfornQj4dzcq5Vp835oM/o21UMlXzn79KobKlcs3Wz9smwiifknLy4xDCLUU0BWp7b/houtdrgUz7nOGnfIYg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz",
+			"integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==",
 			"cpu": [
 			"cpu": [
 				"loong64"
 				"loong64"
 			],
 			],
@@ -1724,9 +1724,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/linux-mips64el": {
 		"node_modules/@esbuild/linux-mips64el": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.1.tgz",
-			"integrity": "sha512-1osBbPEFYwIE5IVB/0g2X6i1qInZa1aIoj1TdL4AaAb55xIIgbg8Doq6a5BzYWgr+tEcDzYH67XVnTmUzL+nXg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz",
+			"integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==",
 			"cpu": [
 			"cpu": [
 				"mips64el"
 				"mips64el"
 			],
 			],
@@ -1740,9 +1740,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/linux-ppc64": {
 		"node_modules/@esbuild/linux-ppc64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.1.tgz",
-			"integrity": "sha512-/6VBJOwUf3TdTvJZ82qF3tbLuWsscd7/1w+D9LH0W/SqUgM5/JJD0lrJ1fVIfZsqB6RFmLCe0Xz3fmZc3WtyVg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz",
+			"integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==",
 			"cpu": [
 			"cpu": [
 				"ppc64"
 				"ppc64"
 			],
 			],
@@ -1756,9 +1756,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/linux-riscv64": {
 		"node_modules/@esbuild/linux-riscv64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.1.tgz",
-			"integrity": "sha512-nSut/Mx5gnilhcq2yIMLMe3Wl4FK5wx/o0QuuCLMtmJn+WeWYoEGDN1ipcN72g1WHsnIbxGXd4i/MF0gTcuAjQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz",
+			"integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==",
 			"cpu": [
 			"cpu": [
 				"riscv64"
 				"riscv64"
 			],
 			],
@@ -1772,9 +1772,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/linux-s390x": {
 		"node_modules/@esbuild/linux-s390x": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.1.tgz",
-			"integrity": "sha512-cEECeLlJNfT8kZHqLarDBQso9a27o2Zd2AQ8USAEoGtejOrCYHNtKP8XQhMDJMtthdF4GBmjR2au3x1udADQQQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz",
+			"integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==",
 			"cpu": [
 			"cpu": [
 				"s390x"
 				"s390x"
 			],
 			],
@@ -1788,9 +1788,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/linux-x64": {
 		"node_modules/@esbuild/linux-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.1.tgz",
-			"integrity": "sha512-xbfUhu/gnvSEg+EGovRc+kjBAkrvtk38RlerAzQxvMzlB4fXpCFCeUAYzJvrnhFtdeyVCDANSjJvOvGYoeKzFA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz",
+			"integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==",
 			"cpu": [
 			"cpu": [
 				"x64"
 				"x64"
 			],
 			],
@@ -1804,9 +1804,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/netbsd-arm64": {
 		"node_modules/@esbuild/netbsd-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.1.tgz",
-			"integrity": "sha512-O96poM2XGhLtpTh+s4+nP7YCCAfb4tJNRVZHfIE7dgmax+yMP2WgMd2OecBuaATHKTHsLWHQeuaxMRnCsH8+5g==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz",
+			"integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==",
 			"cpu": [
 			"cpu": [
 				"arm64"
 				"arm64"
 			],
 			],
@@ -1820,9 +1820,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/netbsd-x64": {
 		"node_modules/@esbuild/netbsd-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.1.tgz",
-			"integrity": "sha512-X53z6uXip6KFXBQ+Krbx25XHV/NCbzryM6ehOAeAil7X7oa4XIq+394PWGnwaSQ2WRA0KI6PUO6hTO5zeF5ijA==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz",
+			"integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==",
 			"cpu": [
 			"cpu": [
 				"x64"
 				"x64"
 			],
 			],
@@ -1836,9 +1836,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/openbsd-arm64": {
 		"node_modules/@esbuild/openbsd-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.1.tgz",
-			"integrity": "sha512-Na9T3szbXezdzM/Kfs3GcRQNjHzM6GzFBeU1/6IV/npKP5ORtp9zbQjvkDJ47s6BCgaAZnnnu/cY1x342+MvZg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz",
+			"integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==",
 			"cpu": [
 			"cpu": [
 				"arm64"
 				"arm64"
 			],
 			],
@@ -1852,9 +1852,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/openbsd-x64": {
 		"node_modules/@esbuild/openbsd-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.1.tgz",
-			"integrity": "sha512-T3H78X2h1tszfRSf+txbt5aOp/e7TAz3ptVKu9Oyir3IAOFPGV6O9c2naym5TOriy1l0nNf6a4X5UXRZSGX/dw==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz",
+			"integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==",
 			"cpu": [
 			"cpu": [
 				"x64"
 				"x64"
 			],
 			],
@@ -1868,9 +1868,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/sunos-x64": {
 		"node_modules/@esbuild/sunos-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.1.tgz",
-			"integrity": "sha512-2H3RUvcmULO7dIE5EWJH8eubZAI4xw54H1ilJnRNZdeo8dTADEZ21w6J22XBkXqGJbe0+wnNJtw3UXRoLJnFEg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz",
+			"integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==",
 			"cpu": [
 			"cpu": [
 				"x64"
 				"x64"
 			],
 			],
@@ -1884,9 +1884,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/win32-arm64": {
 		"node_modules/@esbuild/win32-arm64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.1.tgz",
-			"integrity": "sha512-GE7XvrdOzrb+yVKB9KsRMq+7a2U/K5Cf/8grVFRAGJmfADr/e/ODQ134RK2/eeHqYV5eQRFxb1hY7Nr15fv1NQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz",
+			"integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==",
 			"cpu": [
 			"cpu": [
 				"arm64"
 				"arm64"
 			],
 			],
@@ -1900,9 +1900,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/win32-ia32": {
 		"node_modules/@esbuild/win32-ia32": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.1.tgz",
-			"integrity": "sha512-uOxSJCIcavSiT6UnBhBzE8wy3n0hOkJsBOzy7HDAuTDE++1DJMRRVCPGisULScHL+a/ZwdXPpXD3IyFKjA7K8A==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz",
+			"integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==",
 			"cpu": [
 			"cpu": [
 				"ia32"
 				"ia32"
 			],
 			],
@@ -1916,9 +1916,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@esbuild/win32-x64": {
 		"node_modules/@esbuild/win32-x64": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.1.tgz",
-			"integrity": "sha512-Y1EQdcfwMSeQN/ujR5VayLOJ1BHaK+ssyk0AEzPjC+t1lITgsnccPqFjb6V+LsTp/9Iov4ysfjxLaGJ9RPtkVg==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz",
+			"integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==",
 			"cpu": [
 			"cpu": [
 				"x64"
 				"x64"
 			],
 			],
@@ -3198,9 +3198,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@types/babel__generator": {
 		"node_modules/@types/babel__generator": {
-			"version": "7.6.8",
-			"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz",
-			"integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==",
+			"version": "7.27.0",
+			"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+			"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
 			"dependencies": {
 			"dependencies": {
 				"@babel/types": "^7.0.0"
 				"@babel/types": "^7.0.0"
 			}
 			}
@@ -3215,9 +3215,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@types/babel__traverse": {
 		"node_modules/@types/babel__traverse": {
-			"version": "7.20.6",
-			"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz",
-			"integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==",
+			"version": "7.20.7",
+			"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz",
+			"integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==",
 			"dependencies": {
 			"dependencies": {
 				"@babel/types": "^7.20.7"
 				"@babel/types": "^7.20.7"
 			}
 			}
@@ -3284,9 +3284,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@types/estree": {
 		"node_modules/@types/estree": {
-			"version": "1.0.6",
-			"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz",
-			"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw=="
+			"version": "1.0.7",
+			"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
+			"integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
 		},
 		},
 		"node_modules/@types/express": {
 		"node_modules/@types/express": {
 			"version": "4.17.21",
 			"version": "4.17.21",
@@ -3399,11 +3399,11 @@
 			"integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA=="
 			"integrity": "sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA=="
 		},
 		},
 		"node_modules/@types/node": {
 		"node_modules/@types/node": {
-			"version": "22.13.10",
-			"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.10.tgz",
-			"integrity": "sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==",
+			"version": "22.14.1",
+			"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
+			"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
 			"dependencies": {
 			"dependencies": {
-				"undici-types": "~6.20.0"
+				"undici-types": "~6.21.0"
 			}
 			}
 		},
 		},
 		"node_modules/@types/node-forge": {
 		"node_modules/@types/node-forge": {
@@ -3475,9 +3475,9 @@
 			"integrity": "sha512-AZU7vQcy/4WFEuwnwsNsJnFwupIpbllH1++LXScN6uxT1Z4zPzdrWG97w4/I7eFKFTvfy/bHFStWjdBAg2Vjug=="
 			"integrity": "sha512-AZU7vQcy/4WFEuwnwsNsJnFwupIpbllH1++LXScN6uxT1Z4zPzdrWG97w4/I7eFKFTvfy/bHFStWjdBAg2Vjug=="
 		},
 		},
 		"node_modules/@types/ws": {
 		"node_modules/@types/ws": {
-			"version": "8.18.0",
-			"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz",
-			"integrity": "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==",
+			"version": "8.18.1",
+			"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+			"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
 			"dependencies": {
 			"dependencies": {
 				"@types/node": "*"
 				"@types/node": "*"
 			}
 			}
@@ -3747,9 +3747,9 @@
 			"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
 			"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ=="
 		},
 		},
 		"node_modules/@zip.js/zip.js": {
 		"node_modules/@zip.js/zip.js": {
-			"version": "2.7.57",
-			"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.57.tgz",
-			"integrity": "sha512-BtonQ1/jDnGiMed6OkV6rZYW78gLmLswkHOzyMrMb+CAR7CZO8phOHO6c2qw6qb1g1betN7kwEHhhZk30dv+NA==",
+			"version": "2.7.60",
+			"resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.7.60.tgz",
+			"integrity": "sha512-vA3rLyqdxBrVo1FWSsbyoecaqWTV+vgPRf0QKeM7kVDG0r+lHUqd7zQDv1TO9k4BcAoNzNDSNrrel24Mk6addA==",
 			"engines": {
 			"engines": {
 				"bun": ">=0.7.0",
 				"bun": ">=0.7.0",
 				"deno": ">=1.0.0",
 				"deno": ">=1.0.0",
@@ -4034,9 +4034,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/axios": {
 		"node_modules/axios": {
-			"version": "1.8.2",
-			"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
-			"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
+			"version": "1.8.4",
+			"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
+			"integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
 			"dev": true,
 			"dev": true,
 			"dependencies": {
 			"dependencies": {
 				"follow-redirects": "^1.15.6",
 				"follow-redirects": "^1.15.6",
@@ -4073,12 +4073,12 @@
 			}
 			}
 		},
 		},
 		"node_modules/babel-plugin-polyfill-corejs2": {
 		"node_modules/babel-plugin-polyfill-corejs2": {
-			"version": "0.4.12",
-			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz",
-			"integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==",
+			"version": "0.4.13",
+			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz",
+			"integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==",
 			"dependencies": {
 			"dependencies": {
 				"@babel/compat-data": "^7.22.6",
 				"@babel/compat-data": "^7.22.6",
-				"@babel/helper-define-polyfill-provider": "^0.6.3",
+				"@babel/helper-define-polyfill-provider": "^0.6.4",
 				"semver": "^6.3.1"
 				"semver": "^6.3.1"
 			},
 			},
 			"peerDependencies": {
 			"peerDependencies": {
@@ -4106,11 +4106,11 @@
 			}
 			}
 		},
 		},
 		"node_modules/babel-plugin-polyfill-regenerator": {
 		"node_modules/babel-plugin-polyfill-regenerator": {
-			"version": "0.6.3",
-			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz",
-			"integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==",
+			"version": "0.6.4",
+			"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz",
+			"integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==",
 			"dependencies": {
 			"dependencies": {
-				"@babel/helper-define-polyfill-provider": "^0.6.3"
+				"@babel/helper-define-polyfill-provider": "^0.6.4"
 			},
 			},
 			"peerDependencies": {
 			"peerDependencies": {
 				"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
 				"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
@@ -4673,9 +4673,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/caniuse-lite": {
 		"node_modules/caniuse-lite": {
-			"version": "1.0.30001703",
-			"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001703.tgz",
-			"integrity": "sha512-kRlAGTRWgPsOj7oARC9m1okJEXdL/8fekFVcxA8Hl7GH4r/sN4OJn/i6Flde373T50KS7Y37oFbMwlE8+F42kQ==",
+			"version": "1.0.30001713",
+			"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz",
+			"integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==",
 			"funding": [
 			"funding": [
 				{
 				{
 					"type": "opencollective",
 					"type": "opencollective",
@@ -5836,9 +5836,9 @@
 			"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
 			"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
 		},
 		},
 		"node_modules/electron-to-chromium": {
 		"node_modules/electron-to-chromium": {
-			"version": "1.5.114",
-			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.114.tgz",
-			"integrity": "sha512-DFptFef3iktoKlFQK/afbo274/XNWD00Am0xa7M8FZUepHlHT8PEuiNBoRfFHbH1okqN58AlhbJ4QTkcnXorjA=="
+			"version": "1.5.136",
+			"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.136.tgz",
+			"integrity": "sha512-kL4+wUTD7RSA5FHx5YwWtjDnEEkIIikFgWHR4P6fqjw1PPLlqYkxeOb++wAauAssat0YClCy8Y3C5SxgSkjibQ=="
 		},
 		},
 		"node_modules/elliptic": {
 		"node_modules/elliptic": {
 			"version": "6.6.1",
 			"version": "6.6.1",
@@ -5985,9 +5985,9 @@
 			"integrity": "sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw=="
 			"integrity": "sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw=="
 		},
 		},
 		"node_modules/esbuild": {
 		"node_modules/esbuild": {
-			"version": "0.25.1",
-			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.1.tgz",
-			"integrity": "sha512-BGO5LtrGC7vxnqucAe/rmvKdJllfGaYWdyABvyMoXQlfYMb2bbRuReWR5tEGE//4LcNJj9XrkovTqNYRFZHAMQ==",
+			"version": "0.25.2",
+			"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
+			"integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
 			"dev": true,
 			"dev": true,
 			"hasInstallScript": true,
 			"hasInstallScript": true,
 			"bin": {
 			"bin": {
@@ -5997,31 +5997,31 @@
 				"node": ">=18"
 				"node": ">=18"
 			},
 			},
 			"optionalDependencies": {
 			"optionalDependencies": {
-				"@esbuild/aix-ppc64": "0.25.1",
-				"@esbuild/android-arm": "0.25.1",
-				"@esbuild/android-arm64": "0.25.1",
-				"@esbuild/android-x64": "0.25.1",
-				"@esbuild/darwin-arm64": "0.25.1",
-				"@esbuild/darwin-x64": "0.25.1",
-				"@esbuild/freebsd-arm64": "0.25.1",
-				"@esbuild/freebsd-x64": "0.25.1",
-				"@esbuild/linux-arm": "0.25.1",
-				"@esbuild/linux-arm64": "0.25.1",
-				"@esbuild/linux-ia32": "0.25.1",
-				"@esbuild/linux-loong64": "0.25.1",
-				"@esbuild/linux-mips64el": "0.25.1",
-				"@esbuild/linux-ppc64": "0.25.1",
-				"@esbuild/linux-riscv64": "0.25.1",
-				"@esbuild/linux-s390x": "0.25.1",
-				"@esbuild/linux-x64": "0.25.1",
-				"@esbuild/netbsd-arm64": "0.25.1",
-				"@esbuild/netbsd-x64": "0.25.1",
-				"@esbuild/openbsd-arm64": "0.25.1",
-				"@esbuild/openbsd-x64": "0.25.1",
-				"@esbuild/sunos-x64": "0.25.1",
-				"@esbuild/win32-arm64": "0.25.1",
-				"@esbuild/win32-ia32": "0.25.1",
-				"@esbuild/win32-x64": "0.25.1"
+				"@esbuild/aix-ppc64": "0.25.2",
+				"@esbuild/android-arm": "0.25.2",
+				"@esbuild/android-arm64": "0.25.2",
+				"@esbuild/android-x64": "0.25.2",
+				"@esbuild/darwin-arm64": "0.25.2",
+				"@esbuild/darwin-x64": "0.25.2",
+				"@esbuild/freebsd-arm64": "0.25.2",
+				"@esbuild/freebsd-x64": "0.25.2",
+				"@esbuild/linux-arm": "0.25.2",
+				"@esbuild/linux-arm64": "0.25.2",
+				"@esbuild/linux-ia32": "0.25.2",
+				"@esbuild/linux-loong64": "0.25.2",
+				"@esbuild/linux-mips64el": "0.25.2",
+				"@esbuild/linux-ppc64": "0.25.2",
+				"@esbuild/linux-riscv64": "0.25.2",
+				"@esbuild/linux-s390x": "0.25.2",
+				"@esbuild/linux-x64": "0.25.2",
+				"@esbuild/netbsd-arm64": "0.25.2",
+				"@esbuild/netbsd-x64": "0.25.2",
+				"@esbuild/openbsd-arm64": "0.25.2",
+				"@esbuild/openbsd-x64": "0.25.2",
+				"@esbuild/sunos-x64": "0.25.2",
+				"@esbuild/win32-arm64": "0.25.2",
+				"@esbuild/win32-ia32": "0.25.2",
+				"@esbuild/win32-x64": "0.25.2"
 			}
 			}
 		},
 		},
 		"node_modules/escalade": {
 		"node_modules/escalade": {
@@ -6909,9 +6909,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/hls.js": {
 		"node_modules/hls.js": {
-			"version": "1.5.20",
-			"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.20.tgz",
-			"integrity": "sha512-uu0VXUK52JhihhnN/MVVo1lvqNNuhoxkonqgO3IpjvQiGpJBdIXMGkofjQb/j9zvV7a1SW8U9g1FslWx/1HOiQ=="
+			"version": "1.6.2",
+			"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.2.tgz",
+			"integrity": "sha512-rx+pETSCJEDThm/JCm8CuadcAC410cVjb1XVXFNDKFuylaayHk1+tFxhkjvnMDAfqsJHxZXDAJ3Uc2d5xQyWlQ=="
 		},
 		},
 		"node_modules/hmac-drbg": {
 		"node_modules/hmac-drbg": {
 			"version": "1.0.1",
 			"version": "1.0.1",
@@ -6940,9 +6940,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/html-entities": {
 		"node_modules/html-entities": {
-			"version": "2.5.2",
-			"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz",
-			"integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==",
+			"version": "2.6.0",
+			"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
+			"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
 			"funding": [
 			"funding": [
 				{
 				{
 					"type": "github",
 					"type": "github",
@@ -7084,9 +7084,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/http-parser-js": {
 		"node_modules/http-parser-js": {
-			"version": "0.5.9",
-			"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.9.tgz",
-			"integrity": "sha512-n1XsPy3rXVxlqxVioEWdC+0+M+SQw0DpJynwtOPo1X+ZlvdzTLtDBIJJlDQTnwZIFJrZSzSGmIOUdP8tu+SgLw=="
+			"version": "0.5.10",
+			"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
+			"integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA=="
 		},
 		},
 		"node_modules/http-proxy": {
 		"node_modules/http-proxy": {
 			"version": "1.18.1",
 			"version": "1.18.1",
@@ -7102,9 +7102,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/http-proxy-middleware": {
 		"node_modules/http-proxy-middleware": {
-			"version": "2.0.7",
-			"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz",
-			"integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==",
+			"version": "2.0.9",
+			"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
+			"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
 			"dependencies": {
 			"dependencies": {
 				"@types/http-proxy": "^1.17.8",
 				"@types/http-proxy": "^1.17.8",
 				"http-proxy": "^1.18.1",
 				"http-proxy": "^1.18.1",
@@ -7242,9 +7242,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/immutable": {
 		"node_modules/immutable": {
-			"version": "5.0.3",
-			"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
-			"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
+			"version": "5.1.1",
+			"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
+			"integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
 			"dev": true
 			"dev": true
 		},
 		},
 		"node_modules/import-fresh": {
 		"node_modules/import-fresh": {
@@ -8198,9 +8198,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/nanoid": {
 		"node_modules/nanoid": {
-			"version": "3.3.9",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.9.tgz",
-			"integrity": "sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==",
+			"version": "3.3.11",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+			"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
 			"funding": [
 			"funding": [
 				{
 				{
 					"type": "github",
 					"type": "github",
@@ -10028,9 +10028,9 @@
 			"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
 			"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
 		},
 		},
 		"node_modules/sass": {
 		"node_modules/sass": {
-			"version": "1.85.1",
-			"resolved": "https://registry.npmjs.org/sass/-/sass-1.85.1.tgz",
-			"integrity": "sha512-Uk8WpxM5v+0cMR0XjX9KfRIacmSG86RH4DCCZjLU2rFh5tyutt9siAXJ7G+YfxQ99Q6wrRMbMlVl6KqUms71ag==",
+			"version": "1.86.3",
+			"resolved": "https://registry.npmjs.org/sass/-/sass-1.86.3.tgz",
+			"integrity": "sha512-iGtg8kus4GrsGLRDLRBRHY9dNVA78ZaS7xr01cWnS7PEMQyFtTqBiyCrfpTYTZXRWM94akzckYjh8oADfFNTzw==",
 			"dev": true,
 			"dev": true,
 			"dependencies": {
 			"dependencies": {
 				"chokidar": "^4.0.0",
 				"chokidar": "^4.0.0",
@@ -10689,9 +10689,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/std-env": {
 		"node_modules/std-env": {
-			"version": "3.8.1",
-			"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.1.tgz",
-			"integrity": "sha512-vj5lIj3Mwf9D79hBkltk5qmkFI+biIKWS2IBxEyEU3AX1tUf7AoL8nSazCOiiqQsGKIq01SClsKEzweu34uwvA=="
+			"version": "3.9.0",
+			"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
+			"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw=="
 		},
 		},
 		"node_modules/stream-browserify": {
 		"node_modules/stream-browserify": {
 			"version": "2.0.2",
 			"version": "2.0.2",
@@ -11171,9 +11171,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/undici-types": {
 		"node_modules/undici-types": {
-			"version": "6.20.0",
-			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
-			"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="
+			"version": "6.21.0",
+			"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+			"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
 		},
 		},
 		"node_modules/unicode-canonical-property-names-ecmascript": {
 		"node_modules/unicode-canonical-property-names-ecmascript": {
 			"version": "2.0.1",
 			"version": "2.0.1",
@@ -11422,7 +11422,8 @@
 		"node_modules/vue-i18n": {
 		"node_modules/vue-i18n": {
 			"version": "8.28.2",
 			"version": "8.28.2",
 			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.28.2.tgz",
 			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-8.28.2.tgz",
-			"integrity": "sha512-C5GZjs1tYlAqjwymaaCPDjCyGo10ajUphiwA922jKt9n7KPpqR7oM1PCwYzhB/E7+nT3wfdG3oRre5raIT1rKA=="
+			"integrity": "sha512-C5GZjs1tYlAqjwymaaCPDjCyGo10ajUphiwA922jKt9n7KPpqR7oM1PCwYzhB/E7+nT3wfdG3oRre5raIT1rKA==",
+			"deprecated": "Vue I18n v8.x has reached EOL and is no longer actively maintained. About maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html"
 		},
 		},
 		"node_modules/vue-infinite-loading": {
 		"node_modules/vue-infinite-loading": {
 			"version": "2.4.5",
 			"version": "2.4.5",
@@ -11657,9 +11658,9 @@
 			"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
 			"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
 		},
 		},
 		"node_modules/webpack": {
 		"node_modules/webpack": {
-			"version": "5.98.0",
-			"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.98.0.tgz",
-			"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
+			"version": "5.99.5",
+			"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.99.5.tgz",
+			"integrity": "sha512-q+vHBa6H9qwBLUlHL4Y7L0L1/LlyBKZtS9FHNCQmtayxjI5RKC9yD8gpvLeqGv5lCQp1Re04yi0MF40pf30Pvg==",
 			"dependencies": {
 			"dependencies": {
 				"@types/eslint-scope": "^3.7.7",
 				"@types/eslint-scope": "^3.7.7",
 				"@types/estree": "^1.0.6",
 				"@types/estree": "^1.0.6",

BIN
public/js/custom_filters.js


BIN
public/js/daci.chunk.4eaae509ed4a084c.js


BIN
public/js/daci.chunk.8cf1cb07ac8a9100.js


BIN
public/js/discover~findfriends.chunk.2ccaf3c586ba03fc.js


BIN
public/js/discover~findfriends.chunk.bf787612b58e5473.js


BIN
public/js/discover~hashtag.bundle.9e342ac5d1df33af.js


BIN
public/js/discover~hashtag.bundle.fffb7ab6f02db6fe.js


BIN
public/js/discover~memories.chunk.8ea5b8e37111f15f.js


BIN
public/js/discover~memories.chunk.9621c5ecf4482f0a.js


BIN
public/js/discover~myhashtags.chunk.03a9fc477579fd24.js


BIN
public/js/discover~myhashtags.chunk.57eeb9257cb300fd.js


BIN
public/js/discover~serverfeed.chunk.4e135dd1c07c17dd.js


BIN
public/js/discover~serverfeed.chunk.b7e1082a3be6ef4c.js


BIN
public/js/discover~settings.chunk.295935b63f9c0971.js


BIN
public/js/discover~settings.chunk.edeee5803151d4eb.js


BIN
public/js/group-status.js


BIN
public/js/group-topic-feed.js


BIN
public/js/groups.js


BIN
public/js/home.chunk.7b3c50ff0f7828a4.js


+ 0 - 0
public/js/home.chunk.abfb6c7049f7833d.js.LICENSE.txt → public/js/home.chunk.7b3c50ff0f7828a4.js.LICENSE.txt


BIN
public/js/home.chunk.abfb6c7049f7833d.js


BIN
public/js/landing.js


BIN
public/js/manifest.js


BIN
public/js/post.chunk.192819f7b133173e.js


BIN
public/js/post.chunk.d0c8b400a930b92a.js


+ 0 - 0
public/js/post.chunk.192819f7b133173e.js.LICENSE.txt → public/js/post.chunk.d0c8b400a930b92a.js.LICENSE.txt


BIN
public/js/profile.chunk.25876d18c9eeb7c6.js


BIN
public/js/profile.chunk.5d560ecb7d4a57ce.js


BIN
public/js/profile.js


BIN
public/js/spa.js


BIN
public/js/status.js


BIN
public/js/timeline.js


BIN
public/js/vendor.js


+ 1 - 1
public/js/vendor.js.LICENSE.txt

@@ -158,7 +158,7 @@ See the Apache Version 2.0 License for specific language governing permissions
 and limitations under the License.
 and limitations under the License.
 ***************************************************************************** */
 ***************************************************************************** */
 
 
-/*! Axios v1.8.2 Copyright (c) 2025 Matt Zabriskie and contributors */
+/*! Axios v1.8.4 Copyright (c) 2025 Matt Zabriskie and contributors */
 
 
 /*! https://mths.be/punycode v1.4.1 by @mathias */
 /*! https://mths.be/punycode v1.4.1 by @mathias */
 
 

BIN
public/mix-manifest.json


+ 27 - 4
resources/assets/components/Hashtag.vue

@@ -56,7 +56,11 @@
                                     <div v-if="status.sensitive" class="square-content">
                                     <div v-if="status.sensitive" class="square-content">
                                         <div class="info-overlay-text-label">
                                         <div class="info-overlay-text-label">
                                             <h5 class="text-white m-auto font-weight-bold">
                                             <h5 class="text-white m-auto font-weight-bold">
-                                                <span>
+                                                <span v-if="status.hasOwnProperty('filtered')">
+                                                    <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
+                                                    <span>Filtered</span>
+                                                </span>
+                                                <span v-else>
                                                     <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
                                                     <span class="far fa-eye-slash fa-lg p-2 d-flex-inline"></span>
                                                 </span>
                                                 </span>
                                             </h5>
                                             </h5>
@@ -79,7 +83,15 @@
                                     <span v-if="status.pf_type == 'video'" class="float-right mr-3 post-icon"><i class="fas fa-video fa-2x"></i></span>
                                     <span v-if="status.pf_type == 'video'" class="float-right mr-3 post-icon"><i class="fas fa-video fa-2x"></i></span>
                                     <span v-if="status.pf_type == 'video:album'" class="float-right mr-3 post-icon"><i class="fas fa-film fa-2x"></i></span>
                                     <span v-if="status.pf_type == 'video:album'" class="float-right mr-3 post-icon"><i class="fas fa-film fa-2x"></i></span>
                                     <div class="info-overlay-text">
                                     <div class="info-overlay-text">
-                                        <h5 class="text-white m-auto font-weight-bold">
+                                        <h5 class="text-white m-auto font-weight-bold d-flex flex-column" style="gap:.5rem;">
+                                            <span>
+                                                <span class="far fa-heart fa-lg p-2 d-flex-inline"></span>
+                                                <span class="d-flex-inline">{{formatCount(status.favourites_count)}}</span>
+                                            </span>
+                                            <span>
+                                                <span class="far fa-retweet fa-lg p-2 d-flex-inline"></span>
+                                                <span class="d-flex-inline">{{formatCount(status.reblogs_count)}}</span>
+                                            </span>
                                             <span>
                                             <span>
                                                 <span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
                                                 <span class="far fa-comment fa-lg p-2 d-flex-inline"></span>
                                                 <span class="d-flex-inline">{{formatCount(status.reply_count)}}</span>
                                                 <span class="d-flex-inline">{{formatCount(status.reply_count)}}</span>
@@ -197,7 +209,12 @@
                 })
                 })
                 .then(res => {
                 .then(res => {
                     if(res.data && res.data.length) {
                     if(res.data && res.data.length) {
-                        this.feed = res.data;
+                        this.feed = res.data.map(s => {
+                            if(s.filtered) {
+                                s.sensitive = true;
+                            }
+                            return s;
+                        });
                         this.maxId = res.data[res.data.length - 1].id;
                         this.maxId = res.data[res.data.length - 1].id;
                         this.canLoadMore = true;
                         this.canLoadMore = true;
                     } else {
                     } else {
@@ -233,7 +250,13 @@
                 })
                 })
                 .then(res => {
                 .then(res => {
                     if(res.data && res.data.length) {
                     if(res.data && res.data.length) {
-                        this.feed.push(...res.data);
+                        const data = res.data.map(s => {
+                            if(s.filtered) {
+                                s.sensitive = true;
+                            }
+                            return s;
+                        });
+                        this.feed.push(...data);
                         this.maxId = res.data[res.data.length - 1].id;
                         this.maxId = res.data[res.data.length - 1].id;
                         this.canLoadMore = true;
                         this.canLoadMore = true;
                     } else {
                     } else {

+ 155 - 89
resources/assets/components/partials/TimelineStatus.vue

@@ -10,46 +10,77 @@
                 @follow="follow"
                 @follow="follow"
                 @unfollow="unfollow" />
                 @unfollow="unfollow" />
 
 
-            <post-content
-                :profile="profile"
-                :status="shadowStatus" />
+            <template v-if="!isFiltered || (isFiltered && filterType === 'blur')">
+                <post-content
+                    :profile="profile"
+                    :status="shadowStatus"
+                    :is-filtered="isFiltered"
+                    :filters="filters"
+                />
 
 
-            <post-reactions
-            	v-if="reactionBar"
-                :status="shadowStatus"
-                :profile="profile"
-                :admin="admin"
-                v-on:like="like"
-                v-on:unlike="unlike"
-                v-on:share="shareStatus"
-                v-on:unshare="unshareStatus"
-                v-on:likes-modal="showLikes"
-                v-on:shares-modal="showShares"
-                v-on:toggle-comments="showComments"
-                v-on:bookmark="handleBookmark"
-                v-on:mod-tools="openModTools" />
-
-            <div v-if="showCommentDrawer" class="card-footer rounded-bottom border-0" style="background: rgba(0,0,0,0.02);z-index: 3;">
-                <comment-drawer
+                <post-reactions
+                	v-if="reactionBar"
                     :status="shadowStatus"
                     :status="shadowStatus"
                     :profile="profile"
                     :profile="profile"
-                    v-on:handle-report="handleReport"
-                    v-on:counter-change="counterChange"
-                    v-on:show-likes="showCommentLikes"
-                    v-on:follow="follow"
-                    v-on:unfollow="unfollow" />
-            </div>
+                    :admin="admin"
+                    @like="like"
+                    @unlike="unlike"
+                    @share="shareStatus"
+                    @unshare="unshareStatus"
+                    @likes-modal="showLikes"
+                    @shares-modal="showShares"
+                    @toggle-comments="showComments"
+                    @bookmark="handleBookmark"
+                    @mod-tools="openModTools" />
+
+                <div v-if="showCommentDrawer" class="card-footer rounded-bottom border-0" style="background: rgba(0,0,0,0.02);z-index: 3;">
+                    <comment-drawer
+                        :status="shadowStatus"
+                        :profile="profile"
+                        @handle-report="handleReport"
+                        @counter-change="counterChange"
+                        @show-likes="showCommentLikes"
+                        @follow="follow"
+                        @unfollow="unfollow" />
+                </div>
+            </template>
+
+            <template v-else>
+                <div class="card shadow-none mt-n2 mx-3 border-0">
+                  <div class="card-body bg-warning-light p-3 ft-std">
+                    <div class="badge badge-warning p-2" style="border-radius: 10px;">
+                      <i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
+                      <span>Warning</span>
+                    </div>
+                    <p class="card-text mt-3" style="word-break:break-all;">
+                      This post contains the following filtered keyword{{ filteredTerms?.length > 1 ? 's' : ''}}:
+                      <span v-for="(term, idx) in filteredTerms" class="font-weight-bold">{{ term }}{{filteredTerms?.length === (idx + 1) ? '' : ', '}}</span>
+                    </p>
+                    <button class="btn btn-outline-primary font-weight-bold" @click="showHiddenStatus()" style="border-radius: 10px;">
+                      Show Content
+                    </button>
+                  </div>
+                </div>
+
+            </template>
         </div>
         </div>
     </div>
     </div>
 </template>
 </template>
 
 
 <script type="text/javascript">
 <script type="text/javascript">
-    import CommentDrawer from './post/CommentDrawer.vue';
-    import PostHeader from './post/PostHeader.vue';
-    import PostContent from './post/PostContent.vue';
-    import PostReactions from './post/PostReactions.vue';
+    import CommentDrawer from "./post/CommentDrawer.vue";
+    import PostHeader from "./post/PostHeader.vue";
+    import PostContent from "./post/PostContent.vue";
+    import PostReactions from "./post/PostReactions.vue";
 
 
     export default {
     export default {
+
+        components: {
+            "comment-drawer": CommentDrawer,
+            "post-content": PostContent,
+            "post-header": PostHeader,
+            "post-reactions": PostReactions
+        },
         props: {
         props: {
             status: {
             status: {
                 type: Object
                 type: Object
@@ -60,8 +91,8 @@
             },
             },
 
 
             reactionBar: {
             reactionBar: {
-            	type: Boolean,
-            	default: true
+                type: Boolean,
+                default: true
             },
             },
 
 
             useDropdownMenu: {
             useDropdownMenu: {
@@ -70,13 +101,6 @@
             }
             }
         },
         },
 
 
-        components: {
-            "comment-drawer": CommentDrawer,
-            "post-content": PostContent,
-            "post-header": PostHeader,
-            "post-reactions": PostReactions
-        },
-
         data() {
         data() {
             return {
             return {
                 key: 1,
                 key: 1,
@@ -87,23 +111,12 @@
                 isBookmarking: false,
                 isBookmarking: false,
                 owner: false,
                 owner: false,
                 admin: false,
                 admin: false,
-                license: false
-            }
-        },
-
-        mounted() {
-            this.license = this.shadowStatus.media_attachments && this.shadowStatus.media_attachments.length ?
-                this.shadowStatus
-                .media_attachments
-                .filter(m => m.hasOwnProperty('license') && m.license && m.license.hasOwnProperty('id'))
-                .map(m => m.license)[0] : false;
-            this.admin = window._sharedData.user.is_admin;
-            this.owner = this.shadowStatus.account.id == window._sharedData.user.id;
-            if(this.shadowStatus.reply_count && this.autoloadComments && this.shadowStatus.comments_disabled === false) {
-                setTimeout(() => {
-                    this.showCommentDrawer = true;
-                }, 1000);
-            }
+                license: false,
+                isFiltered: false,
+                filterType: undefined,
+                filters: [],
+                filteredTerms: []
+            };
         },
         },
 
 
         computed: {
         computed: {
@@ -128,7 +141,7 @@
             newReactions: {
             newReactions: {
                 get() {
                 get() {
                     return this.$store.state.newReactions;
                     return this.$store.state.newReactions;
-                },
+                }
             },
             },
 
 
             isReblog: {
             isReblog: {
@@ -150,35 +163,25 @@
             }
             }
         },
         },
 
 
-        watch: {
-            status: {
-                deep: true,
-                immediate: true,
-                handler: function(o, n) {
-                    this.isBookmarking = false;
-                }
-            },
-        },
-
         methods: {
         methods: {
             openMenu() {
             openMenu() {
-                this.$emit('menu');
+                this.$emit("menu");
             },
             },
 
 
             like() {
             like() {
-                this.$emit('like');
+                this.$emit("like");
             },
             },
 
 
             unlike() {
             unlike() {
-                this.$emit('unlike');
+                this.$emit("unlike");
             },
             },
 
 
             showLikes() {
             showLikes() {
-                this.$emit('likes-modal');
+                this.$emit("likes-modal");
             },
             },
 
 
             showShares() {
             showShares() {
-                this.$emit('shares-modal');
+                this.$emit("shares-modal");
             },
             },
 
 
             showComments() {
             showComments() {
@@ -195,47 +198,47 @@
                     navigator.share({
                     navigator.share({
                         url: this.status.url
                         url: this.status.url
                     })
                     })
-                    .then(() => console.log('Share was successful.'))
-                    .catch((error) => console.log('Sharing failed', error));
+                        .then(() => console.log("Share was successful."))
+                        .catch((error) => console.log("Sharing failed", error));
                 } else {
                 } else {
-                    swal('Not supported', 'Your current device does not support native sharing.', 'error');
+                    swal("Not supported", "Your current device does not support native sharing.", "error");
                 }
                 }
             },
             },
 
 
             counterChange(type) {
             counterChange(type) {
-                this.$emit('counter-change', type);
+                this.$emit("counter-change", type);
             },
             },
 
 
             showCommentLikes(post) {
             showCommentLikes(post) {
-                this.$emit('comment-likes-modal', post);
+                this.$emit("comment-likes-modal", post);
             },
             },
 
 
             shareStatus() {
             shareStatus() {
-                this.$emit('share');
+                this.$emit("share");
             },
             },
 
 
             unshareStatus() {
             unshareStatus() {
-                this.$emit('unshare');
+                this.$emit("unshare");
             },
             },
 
 
             handleReport(post) {
             handleReport(post) {
-                this.$emit('handle-report', post);
+                this.$emit("handle-report", post);
             },
             },
 
 
             follow() {
             follow() {
-                this.$emit('follow');
+                this.$emit("follow");
             },
             },
 
 
             unfollow() {
             unfollow() {
-                this.$emit('unfollow');
+                this.$emit("unfollow");
             },
             },
 
 
             handleReblog() {
             handleReblog() {
                 this.isReblogging = true;
                 this.isReblogging = true;
-                if(this.status.reblogged) {
-                    this.$emit('unshare');
+                if (this.status.reblogged) {
+                    this.$emit("unshare");
                 } else {
                 } else {
-                    this.$emit('share');
+                    this.$emit("share");
                 }
                 }
 
 
                 setTimeout(() => {
                 setTimeout(() => {
@@ -246,7 +249,7 @@
             handleBookmark() {
             handleBookmark() {
                 event.currentTarget.blur();
                 event.currentTarget.blur();
                 this.isBookmarking = true;
                 this.isBookmarking = true;
-                this.$emit('bookmark');
+                this.$emit("bookmark");
 
 
                 setTimeout(() => {
                 setTimeout(() => {
                     this.isBookmarking = false;
                     this.isBookmarking = false;
@@ -254,7 +257,7 @@
             },
             },
 
 
             getStatusAvatar() {
             getStatusAvatar() {
-                if(window._sharedData.user.id == this.status.account.id) {
+                if (window._sharedData.user.id == this.status.account.id) {
                     return window._sharedData.user.avatar;
                     return window._sharedData.user.avatar;
                 }
                 }
 
 
@@ -262,10 +265,73 @@
             },
             },
 
 
             openModTools() {
             openModTools() {
-                this.$emit('mod-tools');
+                this.$emit("mod-tools");
+            },
+
+            applyStatusFilters() {
+                const filterTypes = this.status.filtered.map(f => f.filter.filter_action);
+
+                if (filterTypes.includes("warn")) {
+                    this.applyWarnStatusFilter();
+                    return;
+                }
+                if (filterTypes.includes("blur")) {
+                    this.applyBlurStatusFilter();
+                    return;
+                }
+            },
+
+            applyWarnStatusFilter() {
+                this.isFiltered = true;
+                this.filterType = "warn";
+                this.filters = this.status.filtered;
+                this.filteredTerms = this.status.filtered.map(f => f.keyword_matches).flat(1);
+            },
+
+            applyBlurStatusFilter() {
+                this.isFiltered = true;
+                this.filterType = "blur";
+                this.filters = this.status.filtered;
+                this.filteredTerms = this.status.filtered.map(f => f.keyword_matches).flat(1);
+            },
+
+            showHiddenStatus() {
+                this.isFiltered = false;
+                this.filterType = null;
+                this.filters = [];
+                this.filteredTerms = [];
+            }
+        },
+
+        mounted() {
+            this.license = this.shadowStatus.media_attachments && this.shadowStatus.media_attachments.length ?
+                this.shadowStatus
+                    .media_attachments
+                    .filter(m => m.hasOwnProperty("license") && m.license && m.license.hasOwnProperty("id"))
+                    .map(m => m.license)[0] : false;
+            this.admin = window._sharedData.user.is_admin;
+            this.owner = this.shadowStatus.account.id == window._sharedData.user.id;
+            if (this.shadowStatus.reply_count && this.autoloadComments && this.shadowStatus.comments_disabled === false) {
+                setTimeout(() => {
+                    this.showCommentDrawer = true;
+                }, 1000);
+            }
+
+            if (this.status.filtered && this.status.filtered.length) {
+                this.applyStatusFilters();
+            }
+        },
+
+        watch: {
+            status: {
+                deep: true,
+                immediate: true,
+                handler: function(o, n) {
+                    this.isBookmarking = false;
+                }
             }
             }
         }
         }
-    }
+    };
 </script>
 </script>
 
 
 <style lang="scss">
 <style lang="scss">

+ 250 - 181
resources/assets/components/partials/post/PostContent.vue

@@ -1,128 +1,129 @@
 <template>
 <template>
-	<div class="timeline-status-component-content">
-		<div v-if="status.pf_type === 'poll'" class="postPresenterContainer" style="background: #000;">
-		</div>
-
-		<div v-else-if="!fixedHeight" class="postPresenterContainer" style="background: #000;">
-			<div v-if="status.pf_type === 'photo'" class="w-100">
-				<photo-presenter
-					:status="status"
-					v-on:lightbox="toggleLightbox"
-					v-on:togglecw="status.sensitive = false"/>
-			</div>
-
-			<div v-else-if="status.pf_type === 'video'" class="w-100">
-                <video-player :status="status" :fixedHeight="fixedHeight" v-on:togglecw="status.sensitive = false" />
-			</div>
-
-			<div v-else-if="status.pf_type === 'photo:album'" class="w-100">
-				<photo-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="status.sensitive = false"></photo-album-presenter>
-			</div>
-
-			<div v-else-if="status.pf_type === 'video:album'" class="w-100">
-				<video-album-presenter :status="status" v-on:togglecw="status.sensitive = false"></video-album-presenter>
-			</div>
-
-			<div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
-				<mixed-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="status.sensitive = false"></mixed-album-presenter>
-			</div>
-		</div>
-
-		<div v-else class="card-body p-0">
-			<div v-if="status.pf_type === 'photo'" :class="{ fixedHeight: fixedHeight }">
-				<div v-if="status.sensitive == true" class="content-label-wrapper">
-					<div class="text-light content-label">
-						<p class="text-center">
-							<i class="far fa-eye-slash fa-2x"></i>
-						</p>
-						<p class="h4 font-weight-bold text-center">
-							{{ $t('common.sensitiveContent') }}
-						</p>
-						<p class="text-center py-2 content-label-text">
-							{{ status.spoiler_text ? status.spoiler_text : $t('common.sensitiveContentWarning') }}
-						</p>
-						<p class="mb-0">
-							<button class="btn btn-outline-light btn-block btn-sm font-weight-bold" @click="toggleContentWarning()">See Post</button>
-						</p>
-					</div>
-
-					<blur-hash-image
-						width="32"
-						height="32"
-						:punch="1"
-						class="blurhash-wrapper"
-						:hash="status.media_attachments[0].blurhash"
-						/>
-				</div>
-				<div
-					v-else
-					@click.prevent="toggleLightbox"
-					class="content-label-wrapper"
-					style="position: relative;width:100%;height: 400px;overflow: hidden;z-index:1"
-					>
-
-					<img
+    <div class="timeline-status-component-content">
+        <div v-if="status.pf_type === 'poll'" class="postPresenterContainer" style="background: #000;">
+        </div>
+
+        <div v-else-if="!fixedHeight" class="postPresenterContainer" style="background: #000;">
+            <div v-if="status.pf_type === 'photo'" class="w-100">
+                <photo-presenter
+                    :status="status"
+                    :is-filtered="isFiltered"
+                    @lightbox="toggleLightbox"
+                    @togglecw="toggleContentWarning" />
+            </div>
+
+            <div v-else-if="status.pf_type === 'video'" class="w-100">
+                <video-player
+                    :status="statusRender"
+                    :fixed-height="fixedHeight"
+                    @togglecw="toggleContentWarning" />
+            </div>
+
+            <div v-else-if="status.pf_type === 'photo:album'" class="w-100">
+                <photo-album-presenter
+                    :status="status"
+                    @lightbox="toggleLightbox"
+                    @togglecw="toggleContentWarning" />
+            </div>
+
+            <div v-else-if="status.pf_type === 'video:album'" class="w-100">
+                <video-album-presenter
+                    :status="status"
+                    @togglecw="toggleContentWarning" />
+            </div>
+
+            <div v-else-if="status.pf_type === 'photo:video:album'" class="w-100">
+                <mixed-album-presenter
+                    :status="status"
+                    @lightbox="toggleLightbox"
+                    @togglecw="toggleContentWarning" />
+            </div>
+        </div>
+
+        <div v-else class="card-body p-0">
+            <div v-if="status.pf_type === 'photo'" :class="{ fixedHeight: fixedHeight }">
+                <div v-if="statusRender.sensitive == true" class="content-label-wrapper">
+                    <div class="text-light content-label">
+                        <p class="text-center">
+                            <i class="far fa-eye-slash fa-2x"></i>
+                        </p>
+                        <p class="h4 font-weight-bold text-center">
+                            {{ isFiltered ? 'Filtered Content' : $t('common.sensitiveContent') }}
+                        </p>
+                        <p class="text-center py-2 content-label-text">
+                            {{ status.spoiler_text ? status.spoiler_text : $t('common.sensitiveContentWarning') }}
+                        </p>
+                        <p class="mb-0">
+                            <button class="btn btn-outline-light btn-block btn-sm font-weight-bold" @click="toggleContentWarning()">See Post</button>
+                        </p>
+                    </div>
+
+                    <blur-hash-image
+                        width="32"
+                        height="32"
+                        :punch="1"
+                        class="blurhash-wrapper"
+                        :hash="status.media_attachments[0].blurhash"
+                        />
+                </div>
+                <div
+                    v-else
+                    class="content-label-wrapper"
+                    @click.prevent="toggleLightbox"
+                    >
+
+                    <img
+                        :src="status.media_attachments[0].url"
+                        class="content-label-wrapper-img" />
+
+                    <blur-hash-image
+                        :key="key"
+                        width="32"
+                        height="32"
+                        :punch="1"
+                        :hash="status.media_attachments[0].blurhash"
                         :src="status.media_attachments[0].url"
                         :src="status.media_attachments[0].url"
-                        style="position: absolute;width: 105%;height: 410px;object-fit: cover;z-index: 1;top:0;left:0;filter: brightness(0.35) blur(6px);margin:-5px;">
-
-					<!-- <blur-hash-canvas
-						v-if="status.media_attachments[0].blurhash && status.media_attachments[0].blurhash != 'U4Rfzst8?bt7ogayj[j[~pfQ9Goe%Mj[WBay'"
-						:key="key"
-						width="32"
-						height="32"
-						:punch="1"
-						:hash="status.media_attachments[0].blurhash"
-						style="position: absolute;width: 105%;height: 410px;object-fit: cover;z-index: 1;top:0;left:0;filter: brightness(0.35);"
-						/> -->
-
-					<blur-hash-image
-						:key="key"
-						width="32"
-						height="32"
-						:punch="1"
-						:hash="status.media_attachments[0].blurhash"
-						:src="status.media_attachments[0].url"
-						class="blurhash-wrapper"
+                        class="blurhash-wrapper"
                         :alt="status.media_attachments[0].description"
                         :alt="status.media_attachments[0].description"
                         :title="status.media_attachments[0].description"
                         :title="status.media_attachments[0].description"
-						style="width: 100%;position: absolute;z-index:9;top:0:left:0"
-						/>
-
-					<p v-if="!status.sensitive && sensitive"
-						@click="status.sensitive = true"
-						style="
-						margin-top: 0;
-						padding: 10px;
-						color: #000;
-						font-size: 10px;
-						text-align: right;
-						position: absolute;
-						top: 0;
-						right: 0;
-						border-radius: 11px;
-						cursor: pointer;
-						background: rgba(255, 255, 255,.5);
-					">
-						<i class="fas fa-eye-slash fa-lg"></i>
-					</p>
-				</div>
-			</div>
+                        style="width: 100%;position: absolute;z-index:9;top:0:left:0"
+                        />
+
+                    <p
+                        v-if="!status.sensitive && sensitive"
+                        class="sensitive-curtain"
+                        @click="status.sensitive = true">
+                        <i class="fas fa-eye-slash fa-lg"></i>
+                    </p>
+                </div>
+            </div>
 
 
             <video-player
             <video-player
                 v-else-if="status.pf_type === 'video'"
                 v-else-if="status.pf_type === 'video'"
                 :status="status"
                 :status="status"
-                :fixedHeight="fixedHeight"
+                :fixed-height="fixedHeight"
             />
             />
 
 
-			<div v-else-if="status.pf_type === 'photo:album'" class="card-img-top shadow" style="border-radius: 15px;">
-				<photo-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="toggleContentWarning()" style="border-radius:15px !important;object-fit: contain;background-color: #000;overflow: hidden;" :class="{ fixedHeight: fixedHeight }"/>
-			</div>
+            <div v-else-if="status.pf_type === 'photo:album'" class="card-img-top shadow" style="border-radius: 15px;">
+                <photo-album-presenter
+                    :status="status"
+                    class="photo-presenter"
+                    :class="{ fixedHeight: fixedHeight }"
+                    @lightbox="toggleLightbox"
+                    @togglecw="toggleContentWarning()" />
+            </div>
+
+            <div v-else-if="status.pf_type === 'photo:video:album'" class="card-img-top shadow" style="border-radius: 15px;">
+                <mixed-album-presenter
+                    :status="status"
+                    class="mixed-presenter"
+                    :class="{ fixedHeight: fixedHeight }"
+                    @lightbox="toggleLightbox"
+                    @togglecw="status.sensitive = false" />
 
 
-			<div v-else-if="status.pf_type === 'photo:video:album'" class="card-img-top shadow" style="border-radius: 15px;">
-				<mixed-album-presenter :status="status" v-on:lightbox="toggleLightbox" v-on:togglecw="status.sensitive = false" style="border-radius:15px !important;object-fit: contain;background-color: #000;overflow: hidden;align-items:center" :class="{ fixedHeight: fixedHeight }"></mixed-album-presenter>
-			</div>
+            </div>
 
 
-			<div v-else-if="status.pf_type === 'text'">
+            <div v-else-if="status.pf_type === 'text'">
                 <div v-if="status.sensitive" class="border m-3 p-5 rounded-lg">
                 <div v-if="status.sensitive" class="border m-3 p-5 rounded-lg">
                     <p class="text-center">
                     <p class="text-center">
                         <i class="far fa-eye-slash fa-2x"></i>
                         <i class="far fa-eye-slash fa-2x"></i>
@@ -135,85 +136,153 @@
                 </div>
                 </div>
             </div>
             </div>
 
 
-			<div v-else class="bg-light rounded-lg d-flex align-items-center justify-content-center" style="height: 400px;">
-				<div>
-					<p class="text-center">
-						<i class="fas fa-exclamation-triangle fa-4x"></i>
-					</p>
-
-					<p class="lead text-center mb-0">
-						Cannot display post
-					</p>
-
-					<p class="small text-center mb-0">
-						<!-- <a class="font-weight-bold primary" href="#">Report issue</a> -->
-						{{status.pf_type}}:{{status.id}}
-					</p>
-				</div>
-			</div>
-		</div>
-
-		<div
-			v-if="status.content && !status.sensitive"
-			class="card-body status-text"
-			:class="[ status.pf_type === 'text' ? 'py-0' : 'pb-0']">
-			<p>
-				<read-more :status="status" :cursor-limit="300"/>
-			</p>
-			<!-- <p v-html="status.content_text || status.content">
-			</p> -->
-		</div>
-	</div>
+            <div v-else class="bg-light rounded-lg d-flex align-items-center justify-content-center" style="height: 400px;">
+                <div>
+                    <p class="text-center">
+                        <i class="fas fa-exclamation-triangle fa-4x"></i>
+                    </p>
+
+                    <p class="lead text-center mb-0">
+                        Cannot display post
+                    </p>
+
+                    <p class="small text-center mb-0">
+                        {{ status.pf_type }}:{{ status.id }}
+                    </p>
+                </div>
+            </div>
+        </div>
+
+        <div
+            v-if="status.content && !status.sensitive"
+            class="card-body status-text"
+            :class="[ status.pf_type === 'text' ? 'py-0' : 'pb-0']">
+            <p>
+                <read-more :status="status" :cursor-limit="300" />
+            </p>
+        </div>
+    </div>
 </template>
 </template>
 
 
 <script type="text/javascript">
 <script type="text/javascript">
-	import BigPicture from 'bigpicture';
-	import ReadMore from './ReadMore.vue';
-    import VideoPlayer from '@/presenter/VideoPlayer.vue';
+    import BigPicture from "bigpicture";
+    import ReadMore from "./ReadMore.vue";
+    import VideoPlayer from "@/presenter/VideoPlayer.vue";
 
 
-	export default {
-		props: ['status'],
+    export default {
 
 
-		components: {
-			"read-more": ReadMore,
+        components: {
+            "read-more": ReadMore,
             "video-player": VideoPlayer
             "video-player": VideoPlayer
-		},
-
-		data() {
-			return {
-				key: 1,
-				sensitive: false,
-			};
-		},
-
-		computed: {
-			fixedHeight: {
-				get() {
-					return this.$store.state.fixedHeight == true;
-				}
-			}
-		},
-
-		methods: {
-			toggleLightbox(e) {
-				BigPicture({
-					el: e.target
-				})
-			},
-
-			toggleContentWarning() {
-				this.key++;
-				this.sensitive = true;
-				this.status.sensitive = !this.status.sensitive;
-			},
+        },
+        props: {
+
+            status: {
+                type: Object
+            },
+            isFiltered: {
+                type: Boolean
+            },
+            filters: {
+                type: Array
+            }
+        },
+
+        data() {
+            return {
+                key: 1,
+                sensitive: false
+            };
+        },
+
+        computed: {
+            statusRender: {
+                get() {
+                    if (this.isFiltered) {
+                        this.status.spoiler_text = "Filtered because it contains the following keywords: " + this.status.filtered.map(f => f.keyword_matches).flat(1).join(", ");
+                        this.status.sensitive = true;
+                    }
+                    return this.status;
+                }
+            },
+            fixedHeight: {
+                get() {
+                    return this.$store.state.fixedHeight == true;
+                }
+            }
+        },
+
+        methods: {
+            toggleLightbox(e) {
+                BigPicture({
+                    el: e.target
+                });
+            },
+
+            toggleContentWarning() {
+                this.key++;
+                this.sensitive = true;
+                this.status.sensitive = !this.status.sensitive;
+            },
 
 
             getPoster(status) {
             getPoster(status) {
                 let url = status.media_attachments[0].preview_url;
                 let url = status.media_attachments[0].preview_url;
-                if(url.endsWith('no-preview.jpg') || url.endsWith('no-preview.png')) {
+
+                if (url.endsWith("no-preview.jpg") || url.endsWith("no-preview.png")) {
                     return;
                     return;
                 }
                 }
                 return url;
                 return url;
             }
             }
-		}
-	}
+        }
+    };
 </script>
 </script>
+
+<style scoped>
+    .sensitive-curtain {
+        margin-top: 0;
+        padding: 10px;
+        color: #000;
+        font-size: 10px;
+        text-align: right;
+        position: absolute;
+        top: 0;
+        right: 0;
+        border-radius: 11px;
+        cursor: pointer;
+        background: rgba(255, 255, 255,.5);
+    }
+
+    .content-label-wrapper {
+        position: relative;
+        width:100%;
+        height: 400px;
+        overflow: hidden;
+        z-index:1
+    }
+
+    .content-label-wrapper-img {
+        position: absolute;
+        width: 105%;height: 410px;
+        object-fit: cover;
+        z-index: 1;
+        top:0;
+        left:0;
+        filter: brightness(0.35) blur(6px);
+        margin:-5px;
+    }
+
+    .photo-presenter {
+        border-radius:15px !important;
+        object-fit: contain;
+        background-color: #000;
+        overflow: hidden;
+    }
+
+    .mixed-presenter {
+        border-radius:15px !important;
+        object-fit: contain;
+        background-color: #000;
+        overflow: hidden;
+        align-items:center;
+    }
+</style>

+ 150 - 143
resources/assets/components/presenter/PhotoPresenter.vue

@@ -1,160 +1,167 @@
 <template>
 <template>
-	<div v-if="status.sensitive == true" class="content-label-wrapper">
-		<div class="text-light content-label">
-			<p class="text-center">
-				<i class="far fa-eye-slash fa-2x"></i>
-			</p>
-			<p class="h4 font-weight-bold text-center">
-				Sensitive Content
-			</p>
-			<p class="text-center py-2 content-label-text">
-				{{ status.spoiler_text ? status.spoiler_text : 'This post may contain sensitive content.'}}
-			</p>
-			<p class="mb-0">
-				<button @click="toggleContentWarning()" class="btn btn-outline-light btn-block btn-sm font-weight-bold">See Post</button>
-			</p>
-		</div>
-		<blur-hash-image
-			width="32"
-			height="32"
-			:punch="1"
-			:hash="status.media_attachments[0].blurhash"
-			:alt="altText(status)"/>
-	</div>
-	<div v-else>
-		<div :title="status.media_attachments[0].description" style="position: relative;">
-			<img class="card-img-top"
-				:src="status.media_attachments[0].url"
-				loading="lazy"
-				:alt="altText(status)"
-				:width="width()"
-				:height="height()"
-				onerror="this.onerror=null;this.src='/storage/no-preview.png'"
-				@click.prevent="toggleLightbox">
+    <div v-if="status.sensitive == true" class="content-label-wrapper">
+        <div class="text-light content-label">
+            <p class="text-center">
+                <i class="far fa-eye-slash fa-2x"></i>
+            </p>
+            <p class="h4 font-weight-bold text-center">
+                {{ isFiltered ? 'Filtered Content' : 'Sensitive Content' }}
+            </p>
+            <p class="text-center py-2 content-label-text">
+                {{ status.spoiler_text ? status.spoiler_text : 'This post may contain sensitive content.' }}
+            </p>
+            <p class="mb-0">
+                <button class="btn btn-outline-light btn-block btn-sm font-weight-bold" @click="toggleContentWarning()">See Post</button>
+            </p>
+        </div>
+        <blur-hash-image
+            width="32"
+            height="32"
+            :punch="1"
+            :hash="status.media_attachments[0].blurhash"
+            :alt="altText(status)"
+        />
+    </div>
+    <div v-else>
+        <div
+            :title="status.media_attachments[0].description"
+            style="position: relative;">
 
 
-				<!-- <blur-hash-image
-					class="card-img-top"
-					width="32"
-					height="32"
-					:punch="1"
-					:hash="status.media_attachments[0].blurhash"
-					:src="status.media_attachments[0].url"
-					:alt="altText(status)"
-					@click.prevent="toggleLightbox"/> -->
+            <img
+                class="card-img-top"
+                :src="status.media_attachments[0].url"
+                loading="lazy"
+                :alt="altText(status)"
+                :width="width()"
+                :height="height()"
+                onerror="this.onerror=null;this.src='/storage/no-preview.png'"
+                @click.prevent="toggleLightbox"
+            />
 
 
-				<p v-if="!status.sensitive && sensitive"
-					@click="status.sensitive = true"
-					style="
-					margin-top: 0;
-					padding: 10px;
-					color: #fff;
-					font-size: 10px;
-					text-align: right;
-					position: absolute;
-					top: 0;
-					right: 0;
-					border-top-left-radius: 5px;
-					cursor: pointer;
-					background: linear-gradient(0deg, rgba(0,0,0,0.5), rgba(0,0,0,0.5));
-				">
-					<i class="fas fa-eye-slash fa-lg"></i>
-				</p>
+            <p
+                v-if="!status.sensitive && sensitive"
+                class="sensitive-curtain"
+                @click="status.sensitive = true">
+                <i class="fas fa-eye-slash fa-lg"></i>
+            </p>
 
 
-				<p
-					v-if="status.media_attachments[0].license"
-					style="
-					margin-bottom: 0;
-					padding: 0 5px;
-					color: #fff;
-					font-size: 10px;
-					text-align: right;
-					position: absolute;
-					bottom: 0;
-					right: 0;
-					border-top-left-radius: 5px;
-					background: linear-gradient(0deg, rgba(0,0,0,0.5), rgba(0,0,0,0.5));
-				"><a :href="status.url" class="font-weight-bold text-light">Photo</a> by <a :href="status.account.url" class="font-weight-bold text-light">&commat;{{status.account.username}}</a> licensed under <a :href="status.media_attachments[0].license.url" class="font-weight-bold text-light">{{status.media_attachments[0].license.title}}</a></p>
-		</div>
-	</div>
+            <p
+                v-if="status.media_attachments[0].license"
+                class="photo-license">
+                <a :href="status.url" class="font-weight-bold text-light">Photo</a> by <a :href="status.account.url" class="font-weight-bold text-light">&commat;{{ status.account.username }}</a> licensed under <a :href="status.media_attachments[0].license.url" class="font-weight-bold text-light">{{ status.media_attachments[0].license.title }}</a>
+            </p>
+        </div>
+    </div>
 </template>
 </template>
 
 
-<style type="text/css" scoped>
-  .card-img-top {
-    border-top-left-radius: 0 !important;
-    border-top-right-radius: 0 !important;
-  }
-  .content-label-wrapper {
-  	position: relative;
-  }
-  .content-label {
-  	margin: 0;
-  	position: absolute;
-  	top:50%;
-  	left:50%;
-  	transform: translate(-50%, -50%);
-  	display: flex;
-  	flex-direction: column;
-  	align-items: center;
-  	justify-content: center;
-  	width: 100%;
-  	height: 100%;
-  	z-index: 2;
-  	background: rgba(0, 0, 0, 0.2)
-  }
-</style>
-
 <script type="text/javascript">
 <script type="text/javascript">
-	import BigPicture from 'bigpicture';
+    import BigPicture from "bigpicture";
 
 
-	export default {
-		props: ['status'],
+    export default {
+        props: {
+            status: {
+                type: Object
+            },
+            isFiltered: {
+                type: Boolean,
+                default: false
+            }
+        },
 
 
-		data() {
-			return {
-				sensitive: this.status.sensitive
-			}
-		},
+        data() {
+            return {
+                sensitive: this.status.sensitive
+            };
+        },
 
 
-		mounted() {
-		},
+        methods: {
+            altText(status) {
+              let desc = status.media_attachments[0].description;
 
 
-		methods: {
-			altText(status) {
-				let desc = status.media_attachments[0].description;
-				if(desc) {
-					return desc;
-				}
+              if (desc) {
+                return desc;
+            }
 
 
-				return 'Photo was not tagged with any alt text.';
-			},
+            return "Photo was not tagged with any alt text.";
+            },
 
 
-			toggleContentWarning(status) {
-				this.$emit('togglecw');
-			},
+            toggleContentWarning(status) {
+                this.$emit("togglecw");
+            },
 
 
-			toggleLightbox(e) {
-				BigPicture({
-					el: e.target
-				})
-			},
+            toggleLightbox(e) {
+                BigPicture({
+                    el: e.target
+                });
+            },
 
 
-			width() {
-				if( !this.status.media_attachments[0].meta ||
-					!this.status.media_attachments[0].meta.original ||
-					!this.status.media_attachments[0].meta.original.width ) {
-					return;
-				}
-				return this.status.media_attachments[0].meta.original.width;
-			},
+            width() {
+                if (!this.status.media_attachments[0].meta ||
+                    !this.status.media_attachments[0].meta.original ||
+                    !this.status.media_attachments[0].meta.original.width) {
+                    return;
+                }
+                return this.status.media_attachments[0].meta.original.width;
+            },
 
 
-			height() {
-				if( !this.status.media_attachments[0].meta ||
-					!this.status.media_attachments[0].meta.original ||
-					!this.status.media_attachments[0].meta.original.height ) {
-					return;
-				}
-				return this.status.media_attachments[0].meta.original.height;
-			}
-		}
-	}
+            height() {
+                if (!this.status.media_attachments[0].meta ||
+                    !this.status.media_attachments[0].meta.original ||
+                    !this.status.media_attachments[0].meta.original.height) {
+                    return;
+                }
+                return this.status.media_attachments[0].meta.original.height;
+            }
+        }
+    };
 </script>
 </script>
+
+<style type="text/css" scoped>
+.card-img-top {
+    border-top-left-radius: 0 !important;
+    border-top-right-radius: 0 !important;
+}
+.content-label-wrapper {
+    position: relative;
+}
+.content-label {
+    margin: 0;
+    position: absolute;
+    top:50%;
+    left:50%;
+    transform: translate(-50%, -50%);
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: 100%;
+    z-index: 2;
+    background: rgba(0, 0, 0, 0.2)
+}
+.sensitive-curtain {
+    margin-top: 0;
+    padding: 10px;
+    color: #fff;
+    font-size: 10px;
+    text-align: right;
+    position: absolute;
+    top: 0;
+    right: 0;
+    border-top-left-radius: 5px;
+    cursor: pointer;
+    background: linear-gradient(0deg, rgba(0,0,0,0.5), rgba(0,0,0,0.5));
+}
+.photo-license {
+    margin-bottom: 0;
+    padding: 0 5px;
+    color: #fff;
+    font-size: 10px;
+    text-align: right;
+    position: absolute;
+    bottom: 0;
+    right: 0;
+    border-top-left-radius: 5px;
+    background: linear-gradient(0deg, rgba(0,0,0,0.5), rgba(0,0,0,0.5));
+}
+</style>

+ 143 - 0
resources/assets/js/components/filters/FilterCard.vue

@@ -0,0 +1,143 @@
+<template>
+    <div class="list-group-item">
+        <div class="d-flex justify-content-between align-items-center">
+            <div class="filter-card-info cursor-pointer" @click="$emit('edit', filter)">
+                <div class="d-flex align-items-center" style="gap:0.5rem;">
+                    <div class="d-flex align-items-center" style="gap:5px;">
+                        <div class="font-weight-bold">{{ filter.title }}</div>
+                        <div class="small text-muted">({{ filter.keywords?.length ?? 0 }})</div>
+                    </div>
+                    <div class="text-muted">·</div>
+                    <div v-if="filter.expires_at" class="small text-muted">
+                        Expires: {{ formatExpiry(filter.expires_at) }}
+                    </div>
+                    <div v-else class="small text-muted">
+                        Never expires
+                    </div>
+                </div>
+                <div>
+                    <div class="text-muted small">{{ formatAction(filter.filter_action) }} on {{ formatContexts() }}</div>
+                </div>
+            </div>
+
+            <div class="custom-control custom-switch">
+                <input type="checkbox" class="custom-control-input" id="customSwitch1" v-model="checked" @click="$emit('delete', filter.id)">
+                <label class="custom-control-label" for="customSwitch1"></label>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        name: 'FilterCard',
+        props: {
+            filter: {
+                type: Object,
+                required: true
+            }
+        },
+        data() {
+            return {
+                checked: true,
+            }
+        },
+        computed: {
+            actionBadgeClass() {
+                const classes = {
+                    'warn': 'badge-warning',
+                    'hide': 'badge-danger',
+                    'blur': 'badge-light'
+                };
+                return classes[this.filter.filter_action] || 'badge-secondary';
+            }
+        },
+        watch: {
+            checked: {
+                deep: true,
+                handler: function(val, old) {
+                    console.log(val, old)
+                    setTimeout(() => {
+                        this.checked = true;
+                    }, 1000);
+                },
+            },
+        },
+        methods: {
+            formatContext(context) {
+                const contexts = {
+                    'home': 'Home feed',
+                    'notifications': 'Notifications',
+                    'public': 'Public feeds',
+                    'thread': 'Conversations',
+                    'tags': 'Hashtags',
+                    'groups': 'Groups'
+                };
+                return contexts[context] || context;
+            },
+            formatExpiry(dateString) {
+                const date = new Date(dateString);
+                return date.toLocaleDateString(undefined, {
+                    year: 'numeric',
+                    month: 'short',
+                    day: 'numeric'
+                });
+            },
+            formatContexts() {
+                if (!this.filter.context?.length) return '';
+
+                const hasHome = this.filter.context.includes('home');
+                const hasPublic = this.filter.context.includes('public');
+
+                if (hasHome && hasPublic) {
+                    const otherContexts = this.filter.context
+                    .filter(c => c !== 'home' && c !== 'public')
+                    .map(c => this.formatContext(c));
+
+                    return ['Feeds', ...otherContexts].join(', ');
+                } else {
+                    return this.filter.context.map(c => this.formatContext(c)).join(', ');
+                }
+            },
+            formatAction(action) {
+                const actions = {
+                    'warn': 'Warning',
+                    'hide': 'Hidden',
+                    'block': 'Blocked'
+                };
+                return actions[action] || action.charAt(0).toUpperCase() + action.slice(1);
+            },
+            renderActionDescription() {
+                console.log(this.filter)
+                if(this.filter.filter_action === 'warn') {
+                    return `<div><i class="fas fa-exclamation-triangle text-warning mr-1"></i> <span class="font-weight-light text-muted">Warn</span></div>`
+                }
+                else if(this.filter.filter_action === 'blur') {
+                    return `<div><i class="fas fa-tint mr-1 text-info"></i> <span class="font-weight-light text-muted">Blur</span></div>`
+                }
+                else if(this.filter.filter_action === 'hide') {
+                    return `<div><i class="fas fa-eye-slash mr-1 text-danger"></i> <span class="font-weight-light text-muted">Hide</span></div>`
+                }
+            }
+        }
+    }
+</script>
+
+<style scoped>
+.filter-card {
+    overflow: hidden;
+    border-radius: 20px;
+}
+
+.filter-card:hover {
+    box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1) !important;
+}
+
+.badge-pill {
+    padding: 0.35em 0.7em;
+}
+
+.card-header {
+    border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+}
+</style>

+ 1391 - 0
resources/assets/js/components/filters/FilterModal.vue

@@ -0,0 +1,1391 @@
+<template>
+    <div>
+        <div class="modal-backdrop fade show"></div>
+        <div class="modal fade show" data-backdrop="static" data-keyboard="false" tabindex="-1" style="display: block;" aria-hidden="true">
+            <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-lg">
+                <div class="modal-content border-0 shadow">
+                    <div class="modal-header bg-light d-flex align-items-center">
+                        <h5 class="modal-title font-weight-bold">
+                            <i class="fal fa-filter text-dark mr-2"></i>
+                            {{ isEditing ? 'Edit Filter' : 'Create Filter' }}
+                        </h5>
+                        <div class="ml-auto d-flex align-items-center">
+                            <div class="custom-control custom-switch mr-3">
+                                <input
+                                type="checkbox"
+                                class="custom-control-input"
+                                id="wizard-toggle"
+                                :checked="wizardMode"
+                                @change="toggleWizardMode($event)"
+                                >
+                                <label class="custom-control-label" for="wizard-toggle">
+                                    <small>{{ !wizardMode ? 'Advanced Mode' : 'Simple Mode' }}</small>
+                                </label>
+                            </div>
+                            <button type="button" class="close" @click="closeModal()">
+                                <span class="text-muted"><i class="fal fa-times"></i></span>
+                            </button>
+                        </div>
+                    </div>
+
+                    <form v-if="!wizardMode" @submit.prevent="saveFilter" class="simple-wizard">
+                        <div class="modal-body px-4">
+                            <div class="form-group">
+                                <label for="title" class="label">Filter title</label>
+                                <input
+                                    v-model="formData.title"
+                                    type="text"
+                                    id="title"
+                                    class="form-control form-control-lg form-control-mat"
+                                    placeholder="Enter filter title"
+                                    required
+                                />
+                            </div>
+
+                            <div class="form-group">
+                                <div class="d-flex justify-content-between align-items-center">
+                                    <div class="flex-grow-1">
+                                        <label class="label">Keywords</label>
+                                    </div>
+                                    <div class="d-flex justify-content-between align-items-center" style="gap: 1rem;">
+                                        <p class="small text-muted mb-0">Legend</p>
+                                        <button
+                                            type="button"
+                                            class="btn btn-xs rounded-pill keyword-tag keyword-tag-whole py-1 px-3"
+                                            @click="showWholeWordExplanation()"
+                                            >
+                                            <i class="far fa-info-circle mr-1"></i>
+                                            Whole word
+                                        </button>
+                                        <button
+                                            type="button"
+                                            class="btn btn-xs rounded-pill keyword-tag keyword-tag-partial py-1 px-3"
+                                            @click="showPartialPhraseExplanation()"
+                                            >
+                                            <i class="far fa-info-circle mr-1"></i>
+                                            Partial word
+                                        </button>
+                                    </div>
+                                </div>
+                                <div class="keyword-tags p-2">
+                                    <div class="d-flex flex-wrap">
+                                        <div
+                                            v-for="(keyword, index) in formData.keywords"
+                                            :key="index"
+                                            class="keyword-tag rounded-pill px-3 py-1 mr-2 mb-2 d-flex align-items-center"
+                                            :class="{'keyword-tag-whole': keyword.whole_word, 'keyword-tag-partial': !keyword.whole_word}"
+                                            >
+                                            <div
+                                                class="cursor-pointer"
+                                                @click="toggleWholeWord(index)"
+                                                >
+                                                {{ keyword.keyword }}
+                                            </div>
+                                            <button
+                                                type="button"
+                                                class="btn btn-sm p-0 ml-2"
+                                                :class="{'keyword-tag-whole-times': keyword.whole_word, 'keyword-tag-partial-times': !keyword.whole_word}"
+                                                @click="removeKeyword(keyword)"
+                                                >
+                                                <i class="fas fa-times"></i>
+                                            </button>
+                                        </div>
+
+                                        <input
+                                            v-if="canAddMoreKeywordsWithoutDuplicate"
+                                            v-model="newKeyword"
+                                            type="text"
+                                            :maxlength="40"
+                                            class="form-control border-0 bg-transparent rounded-pill flex-grow-1 mb-2"
+                                            placeholder="Add a keyword..."
+                                            @keydown.enter.prevent="addKeywordFromInput"
+                                            style="min-width: 150px;"
+                                            />
+                                    </div>
+                                </div>
+                                <div v-if="isDuplicateError" class="alert alert-warning rounded-lg mt-2 p-2 small">
+                                    <i class="fas fa-exclamation-triangle mr-1"></i>
+                                    Duplicate keywords are not allowed
+                                </div>
+                            </div>
+
+                            <div class="form-group">
+                                <label class="label">Filter Action</label>
+                                <div class="filter-action-options">
+                                    <div class="custom-control custom-radio mb-2">
+                                        <input
+                                        type="radio"
+                                        id="action-blur"
+                                        name="filter_action"
+                                        class="custom-control-input"
+                                        value="blur"
+                                        v-model="formData.filter_action"
+                                        />
+                                        <label class="custom-control-label d-flex align-items-center" for="action-blur">
+                                            <span class="badge badge-primary mr-2">Blur</span>
+                                            Hide media behind a blurbash
+                                        </label>
+                                    </div>
+                                    <div class="custom-control custom-radio mb-2">
+                                        <input
+                                        type="radio"
+                                        id="action-warn"
+                                        name="filter_action"
+                                        class="custom-control-input"
+                                        value="warn"
+                                        v-model="formData.filter_action"
+                                        />
+                                        <label class="custom-control-label d-flex align-items-center" for="action-warn">
+                                            <span class="badge badge-warning mr-2">Warning</span>
+                                            Show warning before displaying content
+                                        </label>
+                                    </div>
+                                    <div class="custom-control custom-radio mb-2">
+                                        <input
+                                        type="radio"
+                                        id="action-hide"
+                                        name="filter_action"
+                                        class="custom-control-input"
+                                        value="hide"
+                                        v-model="formData.filter_action"
+                                        />
+                                        <label class="custom-control-label d-flex align-items-center" for="action-hide">
+                                            <span class="badge badge-danger mr-2">Hidden</span>
+                                            Hide content completely
+                                        </label>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div class="form-group">
+                                <label class="label">Apply filters to</label>
+                                <div class="row">
+                                    <div v-if="contextItemKeys.includes('home')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-home"
+                                            value="home"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-home">
+                                                Home timeline
+                                            </label>
+                                        </div>
+                                    </div>
+                                    <div v-if="contextItemKeys.includes('notifications')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-notifications"
+                                            value="notifications"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-notifications">
+                                                Notifications
+                                            </label>
+                                        </div>
+                                    </div>
+                                    <div v-if="contextItemKeys.includes('public')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-public"
+                                            value="public"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-public">
+                                                Public timelines
+                                            </label>
+                                        </div>
+                                    </div>
+                                    <div v-if="contextItemKeys.includes('tags')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-hashtags"
+                                            value="tags"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-hashtags">
+                                                Hashtags
+                                            </label>
+                                        </div>
+                                    </div>
+                                    <div v-if="contextItemKeys.includes('thread')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-thread"
+                                            value="thread"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-thread">
+                                                Conversations
+                                            </label>
+                                        </div>
+                                    </div>
+                                    <div v-if="contextItemKeys.includes('groups')" class="col-6 mb-2">
+                                        <div class="custom-control custom-checkbox">
+                                            <input
+                                            type="checkbox"
+                                            class="custom-control-input"
+                                            id="context-groups"
+                                            value="groups"
+                                            v-model="formData.context"
+                                            />
+                                            <label class="custom-control-label" for="context-groups">
+                                                Groups
+                                            </label>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div class="form-group">
+                                <label for="duration" class="label">Duration</label>
+                                <select v-model="selectedDuration" id="duration" class="custom-select custom-select-lg form-control-mat">
+                                    <option value="0">Forever</option>
+                                    <option value="1800">30 minutes</option>
+                                    <option value="3600">1 hour</option>
+                                    <option value="21600">6 hours</option>
+                                    <option value="43200">12 hours</option>
+                                    <option value="86400">1 day</option>
+                                    <option value="604800">1 week</option>
+                                    <option value="-1">Custom...</option>
+                                </select>
+                                <div v-if="selectedDuration === '-1'" class="input-group mt-2">
+                                    <input
+                                    v-model="customDuration"
+                                    type="number"
+                                    min="1"
+                                    class="form-control form-control-lg form-control-mat"
+                                    placeholder="Enter duration in seconds"
+                                    />
+                                    <div class="input-group-append overflow-hidden">
+                                        <span class="input-group-text">seconds</span>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="modal-footer bg-light d-flex justify-content-between align-items-center">
+                            <div>
+                                <button type="button" @click="closeModal()" class="btn btn-outline-secondary font-weight-light rounded-pill">
+                                    Cancel
+                                </button>
+
+                                <button
+                                    v-if="isEditing"
+                                    type="button"
+                                    class="btn btn-outline-danger font-weight-light rounded-pill"
+                                    @click="deleteFilter()">
+                                    Delete
+                                </button>
+                            </div>
+                            <button type="submit" class="btn btn-primary font-weight-bold rounded-pill" :disabled="!isValid">
+                                <template v-if="isPosting">
+                                    <div class="spinner-border text-white mx-4 spinner-border-sm" role="status">
+                                        <span class="sr-only">Loading...</span>
+                                    </div>
+                                </template>
+                                <template v-else>
+                                    {{ isEditing ? 'Save Changes' : 'Create Filter' }}
+                                </template>
+                            </button>
+                        </div>
+                    </form>
+
+                    <form v-else>
+                        <div class="modal-body p-0">
+                            <div class="wizard-progress bg-light py-2 px-md-5 d-flex justify-content-between">
+                                <div
+                                    v-for="(step, index) in wizardSteps"
+                                    :key="index"
+                                    class="wizard-step d-flex flex-column align-items-center px-md-2 position-relative"
+                                    :class="{'active': currentStep === index, 'completed': currentStep > index}"
+                                    @click="goToStep(index)"
+                                    >
+                                    <div class="wizard-step-indicator rounded-circle d-flex align-items-center justify-content-center mb-1">
+                                        <span v-if="currentStep > index"><i class="fas fa-check"></i></span>
+                                        <span v-else>{{ index + 1 }}</span>
+                                    </div>
+                                    <span
+                                        class="wizard-step-label small"
+                                        :class="[ currentStep === index ? 'text-dark font-weight-bold' : 'text-lighter text-weight-light']">{{ step.label }}
+                                    </span>
+                                </div>
+                            </div>
+
+                            <div class="wizard-content py-4 px-3 px-md-5">
+                                <div v-if="currentStep === 0" key="step1" class="step-content">
+                                    <div class="step-content-info text-center mb-4">
+                                        <div class="step-content-info-icon">
+                                            <i class="fal fa-filter fa-3x"></i>
+                                        </div>
+                                        <h4>Name Your Filter</h4>
+                                        <p class="text-muted">Give your filter a name that will help you remember what content it filters.</p>
+                                    </div>
+                                    <div class="form-group">
+                                        <label for="wizard-title">Filter title</label>
+                                        <input
+                                        v-model="formData.title"
+                                        type="text"
+                                        id="wizard-title"
+                                        class="form-control form-control-lg"
+                                        placeholder="My filter name"
+                                        required
+                                        />
+                                    </div>
+                                    <div class="form-group">
+                                        <label for="wizard-duration">Filter Duration</label>
+                                        <select v-model="selectedDuration" id="wizard-duration" class="custom-select">
+                                            <option value="0">Forever</option>
+                                            <option value="1800">30 minutes</option>
+                                            <option value="3600">1 hour</option>
+                                            <option value="21600">6 hours</option>
+                                            <option value="43200">12 hours</option>
+                                            <option value="86400">1 day</option>
+                                            <option value="604800">1 week</option>
+                                            <option value="-1">Custom...</option>
+                                        </select>
+                                        <div v-if="selectedDuration === '-1'" class="input-group mt-2">
+                                            <input
+                                            v-model="customDuration"
+                                            type="number"
+                                            min="1"
+                                            max="63072000"
+                                            class="form-control"
+                                            placeholder="Enter duration in seconds"
+                                            />
+                                            <div class="input-group-append">
+                                                <span class="input-group-text">seconds</span>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div v-if="currentStep === 1" key="step2" class="step-content">
+                                    <div class="step-content-info text-center mb-4">
+                                        <div class="step-content-info-icon">
+                                            <i class="fal fa-key fa-3x"></i>
+                                        </div>
+                                        <h4>Add Filter Keywords</h4>
+                                        <p class="text-muted">Add words or phrases you want to filter.<br />Content containing these words will be filtered according to your settings.</p>
+                                    </div>
+
+                                    <div class="keywords-container d-flex flex-column align-items-center">
+                                        <div v-for="(keyword, index) in formData.keywords" :key="index" class="keyword-item mb-4 position-relative w-75">
+                                            <div class="input-group">
+                                                <input
+                                                v-model="keyword.keyword"
+                                                type="text"
+                                                class="form-control form-control-lg border-right-0"
+                                                :class="{
+                                                    'border-primary': keyword.whole_word && !keywordErrors[index],
+                                                    'border-info': !keyword.whole_word && !keywordErrors[index],
+                                                    'is-invalid': keywordErrors[index]
+                                                }"
+                                                placeholder="Enter keyword or phrase"
+                                                maxlength="40"
+                                                @input="checkDuplicateKeyword(index)"
+                                                />
+
+                                                <div class="input-group-append">
+                                                    <button
+                                                    type="button"
+                                                    class="btn btn-outline-secondary border-left-0 bg-white"
+                                                    :class="{'text-primary': keyword.whole_word, 'text-info': !keyword.whole_word}"
+                                                    @click="toggleWholeWord(index)"
+                                                    >
+                                                    <i class="fas" :class="{'fa-font': keyword.whole_word, 'fa-text-width': !keyword.whole_word}"></i>
+                                                </button>
+                                                <button
+                                                type="button"
+                                                class="btn btn-outline-danger"
+                                                @click="removeKeyword(keyword)"
+                                                >
+                                                <i class="fas fa-trash"></i>
+                                                </button>
+                                                </div>
+                                            </div>
+
+                                            <div v-if="keywordErrors[index]" class="text-danger small mt-1">
+                                                <i class="fas fa-exclamation-circle mr-1"></i>
+                                                {{ keywordErrors[index] }}
+                                            </div>
+
+                                            <small class="text-muted">
+                                                {{ keyword.whole_word ? 'Whole word match - filters exact matches only (e.g. "book" won\'t match "bookstore")' : 'Partial word match - filters any content containing this text (e.g. "book" will match "bookstore")' }}
+                                            </small>
+                                        </div>
+
+                                        <button
+                                            v-if="canAddMoreKeywords"
+                                            type="button"
+                                            class="btn btn-outline-primary mt-3 font-weight-light rounded-pill"
+                                            @click="addKeyword"
+                                        >
+                                            <i class="fas fa-plus mr-1"></i> Add another keyword
+                                        </button>
+
+                                        <div v-if="isDuplicateError" class="alert alert-warning mt-4 w-75">
+                                            <i class="fas fa-exclamation-triangle mr-2"></i>
+                                            Please remove duplicate keywords before continuing
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div v-if="currentStep === 2" key="step3" class="step-content">
+                                    <div class="step-content-info text-center mb-4">
+                                        <div class="step-content-info-icon">
+                                            <i class="fal fa-shield-alt fa-3x"></i>
+                                        </div>
+                                        <h4>Choose Filter Action</h4>
+                                        <p class="text-muted">How would you like to handle content that matches your filter?</p>
+                                    </div>
+
+                                    <div class="card-deck">
+                                        <div
+                                            class="card shadow-none text-center p-3 filter-action-card"
+                                            :class="{'selected': formData.filter_action === 'blur'}"
+                                            @click="formData.filter_action = 'blur'"
+                                            >
+                                            <div class="card-body">
+                                                <i class="fas fa-tint fa-2x text-info mb-3"></i>
+                                                <h5 class="card-title">Blur</h5>
+                                                <p class="card-text text-muted small">Hide media behind a blurhash</p>
+                                            </div>
+                                        </div>
+                                        <div
+                                            class="card shadow-none text-center p-3 filter-action-card"
+                                            :class="{'selected': formData.filter_action === 'warn'}"
+                                            @click="formData.filter_action = 'warn'"
+                                        >
+                                            <div class="card-body">
+                                                <i class="fas fa-exclamation-triangle fa-2x text-warning mb-3"></i>
+                                                <h5 class="card-title">Warn</h5>
+                                                <p class="card-text text-muted small">Show a warning before displaying the content</p>
+                                            </div>
+                                        </div>
+                                        <div
+                                            class="card shadow-none text-center p-3 filter-action-card"
+                                            :class="{'selected': formData.filter_action === 'hide'}"
+                                            @click="formData.filter_action = 'hide'"
+                                        >
+                                            <div class="card-body">
+                                                <i class="fas fa-eye-slash fa-2x text-danger mb-3"></i>
+                                                <h5 class="card-title">Hide</h5>
+                                                <p class="card-text text-muted small">Completely hide content that matches</p>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div v-if="currentStep === 3" key="step4" class="step-content">
+                                    <div class="step-content-info text-center mb-4">
+                                        <div class="step-content-info-icon">
+                                            <i class="fal fa-map fa-3x"></i>
+                                        </div>
+                                        <h4>Choose Where to Apply</h4>
+                                        <p class="text-muted">Select which sections of the application should use this filter.</p>
+                                    </div>
+                                    <div class="row">
+                                        <div class="col-md-6 mb-3" v-for="item in contextItems" :key="item.value">
+                                            <div
+                                                class="card shadow-none rounded-lg context-card p-3 h-100"
+                                                :class="{'selected': formData.context.includes(item.value)}"
+                                                @click="toggleContext(item.value)"
+                                            >
+                                                <div class="card-body d-flex align-items-center">
+                                                    <div class="custom-control custom-checkbox mr-2">
+                                                        <input
+                                                            class="custom-control-input"
+                                                            type="checkbox"
+                                                            :id="`wizard-context-${item.value}`"
+                                                            :value="item.value"
+                                                            v-model="formData.context"
+                                                        />
+                                                        <label class="custom-control-label" :for="`wizard-context-${item.value}`"></label>
+                                                    </div>
+                                                    <div>
+                                                        <h5 class="mb-1">{{ item.label }}</h5>
+                                                        <p class="text-muted mb-0 small">{{ item.description }}</p>
+                                                    </div>
+                                                </div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+
+                                <div v-if="currentStep === 4" key="step5" class="step-content">
+                                    <div class="step-content-info text-center mb-4">
+                                        <div class="step-content-info-icon bg-success border-success">
+                                            <i class="fas fa-check fa-3x text-white"></i>
+                                        </div>
+                                        <h4>Review Your Filter</h4>
+                                        <p class="text-muted">Here's a summary of the filter you've created.</p>
+                                    </div>
+                                    <div class="card shadow-none border rounded-lg mb-3">
+                                        <div class="card-header bg-light">
+                                            <h5 class="mb-0 text-center font-weight-light">{{ formData.title || 'Untitled Filter' }}</h5>
+                                        </div>
+                                        <div class="card-body">
+                                            <div class="row mb-3">
+                                                <div class="col-md-4 font-weight-bold">Keywords:</div>
+                                                <div class="col-md-8">
+                                                    <div v-if="formData.keywords.length > 0">
+                                                        <span
+                                                            v-for="(keyword, idx) in formData.keywords.filter(k => k.keyword)"
+                                                            :key="idx"
+                                                            class="badge badge-pill badge-light badge-lg border mr-1 mb-1 p-2"
+                                                        >
+                                                            {{ keyword.keyword }}
+                                                            <span v-if="keyword.whole_word" class="small font-italic ml-1">(whole)</span>
+                                                        </span>
+                                                    </div>
+                                                    <span v-else class="text-muted">No keywords specified</span>
+                                                </div>
+                                            </div>
+                                            <div class="row mb-4">
+                                                <div class="col-md-4 font-weight-bold">Action:</div>
+                                                <div class="col-md-8">
+                                                    <span
+                                                        class="font-weight-bold mb-1"
+                                                    >
+                                                        <div v-html="renderActionDescription()"></div>
+                                                    </span>
+                                                </div>
+                                            </div>
+                                            <div class="row mb-3">
+                                                <div class="col-md-4 font-weight-bold">Applied to:</div>
+                                                <div class="col-md-8">
+                                                    <span
+                                                        v-for="context in formData.context"
+                                                        :key="context"
+                                                        class="badge badge-pill badge-light border mr-1 mb-1 p-2"
+                                                        >
+                                                        {{ formatContext(context) }}
+                                                    </span>
+                                                </div>
+                                            </div>
+                                            <div class="row mb-3">
+                                                <div class="col-md-4 font-weight-bold">Duration:</div>
+                                                <div class="col-md-8 text-muted small">
+                                                    <span v-if="selectedDuration === '0'">Forever</span>
+                                                    <span v-else-if="selectedDuration === '1800'">30 minutes</span>
+                                                    <span v-else-if="selectedDuration === '3600'">1 hour</span>
+                                                    <span v-else-if="selectedDuration === '21600'">6 hours</span>
+                                                    <span v-else-if="selectedDuration === '43200'">12 hours</span>
+                                                    <span v-else-if="selectedDuration === '86400'">1 day</span>
+                                                    <span v-else-if="selectedDuration === '604800'">1 week</span>
+                                                    <span v-else-if="selectedDuration === '-1'">{{ customDuration }} seconds</span>
+                                                </div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div>
+                            </div>
+                        </div>
+
+                        <div class="modal-footer bg-light justify-content-between">
+                            <div>
+                                <button
+                                        type="button"
+                                        class="btn btn-outline-secondary font-weight-light rounded-pill"
+                                        @click="currentStep > 0 ? currentStep-- : closeModal()"
+                                    >
+                                    {{ currentStep > 0 ? 'Back' : 'Cancel' }}
+                                </button>
+
+                                <button
+                                    v-if="isEditing"
+                                    type="button"
+                                    class="btn btn-outline-danger font-weight-light rounded-pill"
+                                    @click="deleteFilter()"
+                                >
+                                    Delete
+                                </button>
+                            </div>
+                            <div>
+                                <button
+                                    v-if="currentStep < wizardSteps.length - 1"
+                                    type="button"
+                                    class="btn btn-primary font-weight-bold rounded-pill"
+                                    @click="nextStep"
+                                    :disabled="!canContinue"
+                                >
+                                    Continue
+                                </button>
+                                <button
+                                    v-else
+                                    type="button"
+                                    @click="saveFilter"
+                                    class="btn btn-success font-weight-bold rounded-pill"
+                                    :disabled="!isValid || isPosting"
+                                >
+                                    <template v-if="isPosting">
+                                        <div class="spinner-border text-white mx-4 spinner-border-sm" role="status">
+                                            <span class="sr-only">Loading...</span>
+                                        </div>
+                                    </template>
+                                    <template v-else>
+                                        {{ isEditing ? 'Save Changes' : 'Create Filter' }}
+                                    </template>
+                                </button>
+                            </div>
+                        </div>
+                    </form>
+                </div>
+            </div>
+        </div>
+    </div>
+</template>
+
+<script>
+    export default {
+        name: 'FilterModal',
+        props: {
+            filter: {
+                type: Object,
+                default: null
+            },
+            isEditing: {
+                type: Boolean,
+                default: false
+            },
+            wizardMode: {
+                type: Boolean,
+                default: true
+            }
+        },
+        data() {
+            return {
+                currentStep: 0,
+                formData: {
+                    title: '',
+                    keywords: [],
+                    keywords_attributes: [],
+                    context: [],
+                    irreversible: false,
+                    filter_action: 'warn',
+                    expires_in: 0
+                },
+                newKeyword: '',
+                selectedDuration: '0',
+                customDuration: null,
+                keywordErrors: {},
+                isDuplicateError: false,
+                isPosting: false,
+                contextItems: [
+                    {
+                        value: 'home',
+                        label: 'Home timeline',
+                        description: 'Filter content on your main feed'
+                    },
+                    // {
+                    //   value: 'notifications',
+                    //   label: 'Notifications',
+                    //   description: 'Filter content in your notifications'
+                    // },
+                    {
+                        value: 'public',
+                        label: 'Public timelines',
+                        description: 'Filter content on public and explore pages'
+                    },
+                    // {
+                    //   value: 'thread',
+                    //   label: 'Conversations',
+                    //   description: 'Filter content in threads and replies'
+                    // },
+                    {
+                        value: 'tags',
+                        label: 'Hashtags',
+                        description: 'Filter content in hashtag feeds'
+                    },
+                    // {
+                    //   value: 'groups',
+                    //   label: 'Groups',
+                    //   description: 'Filter content in groups and group feeds'
+                    // },
+                ],
+                wizardSteps: [
+                    { label: 'Title', field: 'title' },
+                    { label: 'Keywords', field: 'keywords' },
+                    { label: 'Action', field: 'filter_action' },
+                    { label: 'Context', field: 'context' },
+                    { label: 'Review', field: null }
+                ]
+            }
+        },
+
+        watch: {
+            newKeyword: {
+                deep: true,
+                handler: function(old) {
+                    this.validateKeywords()
+                }
+            }
+        },
+
+        computed: {
+            contextItemKeys() {
+                return this.contextItems.map(c => c.value);
+            },
+            isValid() {
+                const hasDuplicates = this.isDuplicateError;
+
+                return !hasDuplicates &&
+                this.formData.title &&
+                this.formData.context.length > 0 &&
+                (this.formData.keywords.length === 0 ||
+                    this.formData.keywords.some(k => k.keyword && k.keyword.trim() !== ''));
+            },
+            canAddMoreKeywords() {
+                return (this.formData.keywords.length === 0 ||
+                    this.formData.keywords.length < 10) && !this.isDuplicateError
+            },
+            canAddMoreKeywordsWithoutDuplicate() {
+                return (this.formData.keywords.length === 0 ||
+                    this.formData.keywords.length < 10)
+            },
+            canContinue() {
+                switch(this.currentStep) {
+                case 0:
+                    return this.formData.title && this.formData.title.trim() !== '';
+                case 1:
+                    return !this.isDuplicateError && this.formData.keywords.filter(k => k.keyword.trim() !== '').length;
+                case 3:
+                    return this.formData.context.length > 0;
+                default:
+                    return true;
+                }
+            }
+        },
+        mounted() {
+            document.body.classList.add('modal-open');
+            if (this.filter) {
+                this.formData = {
+                    id: this.filter.id,
+                    title: this.filter.title || '',
+                    keywords: this.filter.keywords ? [...this.filter.keywords] : [],
+                    keywords_attributes: this.filter.keywords ? [...this.filter.keywords] : [],
+                    context: Array.isArray(this.filter.context) ? [...this.filter.context] : [],
+                    irreversible: this.filter.irreversible || false,
+                    filter_action: this.filter.filter_action || 'warn',
+                    expires_in: 0
+                };
+
+                if (this.formData.keywords.length === 0) {
+                    this.addKeyword();
+                }
+
+                if (this.filter.expires_at) {
+                    const now = new Date();
+                    const expiresAt = new Date(this.filter.expires_at);
+                    const secondsRemaining = Math.floor((expiresAt - now) / 1000);
+                    const standardDurations = [1800, 3600, 21600, 43200, 86400, 604800];
+                    const matchedDuration = standardDurations.find(d => Math.abs(d - secondsRemaining) < 60);
+                    if (matchedDuration) {
+                        this.selectedDuration = String(matchedDuration);
+                    } else {
+                        this.selectedDuration = '-1';
+                        this.customDuration = secondsRemaining;
+                    }
+                }
+            } else {
+                this.addKeyword();
+            }
+        },
+        beforeDestroy() {
+            this.isPosting = false;
+            document.body.classList.remove('modal-open');
+        },
+        methods: {
+            addKeyword() {
+                this.formData.keywords.push({
+                    keyword: '',
+                    whole_word: true
+                });
+                this.formData.keywords_attributes.push({
+                    keyword: '',
+                    whole_word: true
+                });
+
+                this.$set(this.keywordErrors, this.formData.keywords.length - 1, '');
+            },
+            addKeywordFromInput() {
+                if (!this.newKeyword || this.newKeyword.trim() === '') return;
+
+                const trimmedKeyword = this.newKeyword.trim();
+
+                const isDuplicate = this.formData.keywords.some(k =>
+                    k.keyword.toLowerCase() === trimmedKeyword.toLowerCase()
+                    );
+
+                if (isDuplicate) {
+                    this.isDuplicateError = true;
+                    return;
+                }
+
+                if(!this.canAddMoreKeywords) {
+                    return;
+                }
+
+                this.formData.keywords.push({
+                    keyword: trimmedKeyword,
+                    whole_word: true
+                });
+
+                this.formData.keywords_attributes.push({
+                    keyword: trimmedKeyword,
+                    whole_word: true
+                });
+
+                this.newKeyword = '';
+                this.isDuplicateError = false;
+            },
+
+            validateKeywords() {
+                const keywordSet = new Set();
+                let hasErrors = false;
+
+                this.keywordErrors = {};
+                this.isDuplicateError = false;
+
+                this.formData.keywords.forEach((keywordObj, index) => {
+                    if (!keywordObj.keyword || keywordObj.keyword.trim() === '') {
+                        this.$set(this.keywordErrors, index, '');
+                        return;
+                    }
+
+                    const normalizedKeyword = keywordObj.keyword.toLowerCase().trim();
+
+                    if (keywordSet.has(normalizedKeyword)) {
+                        this.$set(this.keywordErrors, index, 'Duplicate keyword');
+                        hasErrors = true;
+                        this.isDuplicateError = true;
+                    } else {
+                        keywordSet.add(normalizedKeyword);
+                        this.$set(this.keywordErrors, index, '');
+                    }
+                });
+
+                return !hasErrors;
+            },
+
+            toggleWizardMode(event) {
+                if(this.wizardMode) {
+                    this.formData.keywords = this.formData.keywords.filter(k => k.keyword && k.keyword.trim() !== '');
+                    this.formData.keywords_attributes = this.formData.keywords.filter(k => k.keyword && k.keyword.trim() !== '');
+                } else {
+                    if(!this.formData.keywords.length) {
+                        this.formData.keywords.push({
+                            keyword: '',
+                            whole_word: true
+                        });
+                    }
+                }
+                this.$emit('toggle', event.target.checked);
+            },
+
+            saveFilter() {
+                if (!this.validateKeywords() || !this.isValid || this.isPosting) {
+                    return;
+                }
+
+                this.isPosting = true;
+                if(!this.isEditing) {
+                    this.formData.keywords_attributes = this.formData.keywords.filter(k => k.keyword && k.keyword.trim() !== '');
+                }
+
+                if (this.selectedDuration === '-1' && this.customDuration) {
+                    this.formData.expires_in = parseInt(this.customDuration);
+                } else {
+                    this.formData.expires_in = parseInt(this.selectedDuration);
+                }
+                setTimeout(() => {
+                    this.$emit('save', this.formData);
+                    this.isPosting = false;
+                }, 1500)
+            },
+
+            checkDuplicateKeyword(index) {
+                const currentKeyword = this.formData.keywords[index].keyword.toLowerCase().trim();
+
+                if (!currentKeyword) {
+                    this.$set(this.keywordErrors, index, '');
+                    return true;
+                }
+
+                const isDuplicate = this.formData.keywords.some((k, i) =>
+                    i !== index &&
+                    k.keyword &&
+                    k.keyword.toLowerCase().trim() === currentKeyword
+                    );
+
+                if (isDuplicate) {
+                    this.$set(this.keywordErrors, index, 'Duplicate keyword');
+                    this.isDuplicateError = true;
+                    return false;
+                } else {
+                    this.$set(this.keywordErrors, index, '');
+                    this.isDuplicateError = Object.values(this.keywordErrors).some(error => error !== '');
+                    return true;
+                }
+            },
+
+            close() {
+                this.closeModal();
+            },
+
+            closeModal() {
+                document.body.classList.remove('modal-open');
+                this.$emit('close');
+            },
+
+            deleteFilter() {
+                this.$emit('delete');
+            },
+
+            removeKeyword(keywordObj) {
+                const attrIndex = this.formData.keywords_attributes.findIndex(item =>
+                    item.keyword === keywordObj.keyword &&
+                    (item.id === keywordObj.id || (!item.id && !keywordObj.id))
+                );
+
+                if (attrIndex !== -1) {
+                    this.formData.keywords_attributes[attrIndex]['_destroy'] = true;
+                }
+
+                const keywordIndex = this.formData.keywords.findIndex(item =>
+                    item.keyword === keywordObj.keyword &&
+                    (item.id === keywordObj.id || (!item.id && !keywordObj.id))
+                );
+
+                if (keywordIndex !== -1) {
+                    this.formData.keywords.splice(keywordIndex, 1);
+                }
+
+                if (this.formData.keywords.length === 0 && this.wizardMode) {
+                    this.addKeyword();
+                }
+
+                this.validateKeywords();
+            },
+
+            toggleContext(contextValue) {
+                const index = this.formData.context.indexOf(contextValue);
+                if (index === -1) {
+                    this.formData.context.push(contextValue);
+                } else {
+                    this.formData.context.splice(index, 1);
+                }
+            },
+
+            formatContext(context) {
+                const contexts = {
+                    'home': 'Home feed',
+                    'notifications': 'Notifications',
+                    'public': 'Public feeds',
+                    'thread': 'Conversations',
+                    'tags': 'Hashtags',
+                    'groups': 'Groups'
+                };
+                return contexts[context] || context;
+            },
+
+            nextStep() {
+                this.validateKeywords();
+                if (this.currentStep < this.wizardSteps.length - 1 && this.canContinue) {
+                    this.currentStep++;
+                }
+            },
+
+            goToStep(stepIndex) {
+                if (this.currentStep === 1) {
+                    this.validateKeywords();
+                }
+                if (stepIndex <= this.currentStep) {
+                    this.currentStep = stepIndex;
+                }
+            },
+
+            toggleWholeWord(index) {
+                this.formData.keywords[index].whole_word = !this.formData.keywords[index].whole_word;
+            },
+
+            renderActionDescription() {
+                if(this.formData.filter_action === 'warn') {
+                    return `<div><i class="fas fa-exclamation-triangle text-warning mr-1"></i> <strong>Warn</strong></div>`
+                }
+                if(this.formData.filter_action === 'blur') {
+                    return `<div><i class="fas fa-tint mr-1 text-info"></i> <strong>Blur</strong></div>`
+                }
+                if(this.formData.filter_action === 'hide') {
+                    return `<div><i class="fas fa-eye-slash mr-1 text-danger"></i> <strong>Hide</strong></div>`
+                }
+            },
+
+            showWholeWordExplanation() {
+                let content = document.createElement('div');
+                content.classList = 'p-4';
+                content.style.textAlign = 'left';
+                content.style.marginTop = '20px';
+
+                let title = document.createElement('h4');
+                title.textContent = 'Whole Word Matching';
+                title.style.fontWeight = 'bold';
+                title.style.marginBottom = '15px';
+                title.style.paddingBottom = '15px';
+                title.style.borderBottom = '1px solid #ccc';
+
+                let description = document.createElement('p');
+                description.textContent = 'When enabled, keywords will only match complete words.';
+                description.style.marginBottom = '15px';
+
+                let example = document.createElement('p');
+                example.textContent = 'Example: If your keyword is "cat", it will match "I have a cat" but won\'t match "category" or "concatenate".';
+                example.style.marginBottom = '15px';
+
+                let usage = document.createElement('p');
+                usage.textContent = 'This is useful when you want to filter specific terms without affecting words that contain those letters as part of a larger word.';
+
+                content.appendChild(title);
+                content.appendChild(description);
+                content.appendChild(example);
+                content.appendChild(usage);
+
+                swal({
+                    title: '',
+                    text: '',
+                    html: true,
+                    customClass: 'word-matching-modal',
+                    content: content,
+                    confirmButtonText: 'Got it',
+                    confirmButtonColor: '#6c7cff'
+                });
+            },
+
+            showPartialPhraseExplanation() {
+                var content = document.createElement('div');
+                content.classList = 'p-4';
+                content.style.textAlign = 'left';
+                content.style.marginTop = '20px';
+
+                var title = document.createElement('h4');
+                title.textContent = 'Partial Phrase Matching';
+                title.style.fontWeight = 'bold';
+                title.style.marginBottom = '15px';
+                title.style.paddingBottom = '15px';
+                title.style.borderBottom = '1px solid #ccc';
+
+                var description = document.createElement('p');
+                description.textContent = 'When enabled, keywords will match any text containing these characters.';
+                description.style.marginBottom = '15px';
+
+                var example = document.createElement('p');
+                example.textContent = 'Example: If your keyword is "cat", it will match "I have a cat" as well as "category" and "concatenate".';
+                example.style.marginBottom = '15px';
+
+                var usage = document.createElement('p');
+                usage.textContent = 'This is useful when you want to filter variations of words or when the same letters might appear in different contexts.';
+
+                content.appendChild(title);
+                content.appendChild(description);
+                content.appendChild(example);
+                content.appendChild(usage);
+
+                swal({
+                    title: '',
+                    text: '',
+                    html: true,
+                    customClass: 'word-matching-modal',
+                    content: content,
+                    confirmButtonText: 'Got it',
+                    confirmButtonColor: '#6c7cff'
+                });
+            }
+        }
+    }
+</script>
+
+<style scoped>
+.custom-control-label {
+    cursor: pointer;
+}
+
+.modal-content {
+    border-radius: 0.5rem;
+}
+
+.modal-header, .modal-footer {
+    border-color: rgba(0, 0, 0, 0.05);
+}
+
+.wizard-progress {
+    position: relative;
+    display: flex;
+    justify-content: space-between;
+    padding: 1rem 3rem;
+    border-bottom: 1px solid rgba(0, 0, 0, 0.05);
+}
+
+.wizard-progress:after {
+    content: '';
+    position: absolute;
+    top: 26px;
+    left: 15%;
+    width: 70%;
+    height: 2px;
+    background-color: #e9ecef;
+    z-index: 1;
+
+    @media(min-width: 991px) {
+        left: 10%;
+        width: 80%;
+    }
+}
+
+.wizard-step {
+    z-index: 2;
+    cursor: pointer;
+    opacity: 1;
+    transition: all 0.2s ease;
+}
+
+.simple-wizard label {
+    font-weight: 200;
+}
+
+.simple-wizard .label {
+    width: 100%;
+    color: var(--muted);
+    font-weight: 200;
+    margin-top: 1rem;
+    font-size: 18px;
+}
+
+.wizard-step.active {
+    opacity: 1;
+    transform: scale(1.05);
+}
+
+.wizard-step.completed {
+    opacity: 1;
+}
+
+.wizard-step-indicator {
+    width: 36px;
+    height: 36px;
+    background-color: #e9ecef;
+    color: #6c757d;
+    font-weight: bold;
+    transition: all 0.2s ease;
+}
+
+.wizard-step.active .wizard-step-indicator {
+    background-color: #007bff;
+    color: white;
+}
+
+.wizard-step.completed .wizard-step-indicator {
+    background-color: #28a745;
+    color: white;
+}
+
+.wizard-step-label {
+    white-space: nowrap;
+    font-weight: 500;
+}
+
+.wizard-content {
+    max-height: 50dvh;
+
+    @media(min-width: 991px) {
+        min-height: 70dvh;
+    }
+}
+
+.step-content {
+    animation: fadeIn 0.5s;
+}
+
+.step-content-info {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    flex-direction: column;
+    margin: 1.5rem auto 2rem auto;
+    padding-bottom: 3rem;
+}
+
+.step-content-info-icon {
+    display: none;
+    justify-content: center;
+    align-items: center;
+    text-align: center;
+    padding: 2rem;
+    border: 1px solid #bbb;
+    border-radius: 100%;
+    color: #bbb;
+    margin-bottom: 2rem;
+
+
+    @media(min-width: 991px) {
+        display: flex;
+    }
+}
+
+.step-content-info-icon i {
+    color: #bbb;
+}
+
+.filter-action-card, .context-card {
+    cursor: pointer;
+    transition: all 0.2s ease;
+    border: 1px solid #dee2e6;
+}
+
+.filter-action-card:hover, .context-card:hover {
+    box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.1);
+    border-color: #c8d1d9;
+}
+
+.filter-action-card.selected, .context-card.selected {
+    border-color: #007bff;
+    box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
+}
+
+.keyword-item {
+    transition: all 0.3s ease;
+}
+
+.keyword-item:hover {
+    transform: translateY(-2px);
+}
+
+.is-invalid {
+    border-color: #dc3545 !important;
+    padding-right: calc(1.5em + 0.75rem) !important;
+    background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") !important;
+    background-repeat: no-repeat !important;
+    background-position: right calc(0.375em + 0.1875rem) center !important;
+    background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem) !important;
+}
+
+.invalid-feedback, .text-danger {
+    display: block;
+    animation: fadeIn 0.3s;
+}
+
+.alert {
+    animation: fadeIn 0.3s;
+}
+
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateY(-5px);
+    }
+    to {
+        opacity: 1;
+        transform: translateY(0);
+    }
+}
+
+.keyword-tags {
+    border: 1px solid #E5E5E5;
+    border-radius: 30px;
+    min-height: 100px;
+    background-color: #F7F7FA;
+}
+
+.form-control-mat {
+    border: 1px solid #E5E5E5;
+    border-radius: 30px;
+    background-color: #F7F7FA;
+}
+
+.keyword-tag {
+    font-size: 0.9rem;
+    background-color: #E1E1E1;
+    font-weight: bold;
+}
+
+.keyword-tag-whole {
+    background-color: #E1E1E1;
+    border: 2px solid #E1E1E1;
+}
+
+.keyword-tag-partial {
+    border: 2px dashed #E1E1E1;
+    background-color: #fff;
+}
+
+.keyword-tag-whole-times {
+    color: var(--muted);
+}
+
+.keyword-tag-partial-times {
+    color: var(--muted);
+}
+
+.filter-action-options .custom-control {
+    padding-left: 2rem;
+}
+
+.custom-control-input:checked ~ .custom-control-label::before {
+    background-color: #6c7cff;
+    border-color: #6c7cff;
+}
+
+.wizard-mode .keyword-item .is-invalid {
+    background-position: right calc(0.375em + 0.5rem) center !important;
+}
+
+
+body.modal-open {
+    overflow: hidden;
+    position: fixed;
+    width: 100%;
+}
+
+.modal-dialog-scrollable .modal-body {
+    overflow-y: auto !important;
+    max-height: 70vh !important;
+}
+
+.modal-dialog-scrollable .modal-content {
+    max-height: 85vh;
+}
+
+.slide-fade-enter-active {
+    transition: all .1s;
+}
+
+.slide-fade-leave-active {
+    transition: all .1s;
+}
+
+.slide-fade-enter, .slide-fade-leave-to {
+    transform: translateX(10px);
+    opacity: 0;
+}
+
+@keyframes fadeIn {
+    from {
+        opacity: 0;
+        transform: translateX(10px);
+    }
+    to {
+        opacity: 1;
+        transform: translateX(0);
+    }
+}
+</style>

+ 264 - 0
resources/assets/js/components/filters/FiltersList.vue

@@ -0,0 +1,264 @@
+<template>
+    <div class="pb-4">
+        <div class="d-flex flex-column flex-md-row justify-content-between align-items-center mb-4">
+            <div class="title">
+                <h3 class="font-weight-bold mb-0">
+                    Filters
+                </h3>
+                <p class="lead mb-3 mb-md-0">Manage your custom filters.</p>
+            </div>
+            <button
+                @click="showAddFilterModal = true"
+                class="btn btn-primary font-weight-bold rounded-pill px-3"
+                :disabled="filters?.length >= 20">
+                <i class="fas fa-plus mr-1"></i> Add New Filter
+            </button>
+        </div>
+
+        <!-- <p>Customize your experience with powerful content filters that screen for specific words or phrases throughout your entire account—including home and public timelines, notifications, messages, groups, hashtag feeds, and explore sections.</p> -->
+        <p>Customize your experience with powerful content filters that screen for specific words or phrases throughout your entire account - including home and public timelines, and hashtag feeds.</p>
+        <p class="text-muted mb-0">You can add up to <strong>20 filters</strong> that can have up to <strong>10 keywords</strong>.</p>
+        <p class="text-muted mb-4 small">Learn more in our <a href="/site/help">Help Center</a>.</p>
+
+        <div v-if="loading" class="d-flex justify-content-center py-4">
+            <div class="spinner-border text-primary" role="status">
+                <span class="sr-only">Loading...</span>
+            </div>
+        </div>
+
+        <div v-else-if="filters.length === 0" class="bg-light p-4 rounded text-center border">
+            <div class="py-3">
+                <i class="fas fa-filter text-secondary fa-3x mb-3"></i>
+                <p class="font-weight-bold text-secondary">You don't have any content filters yet.</p>
+                <p class="text-muted small mt-2">
+                    Filters help you hide content containing specific words or phrases from your timelines.
+                </p>
+                <button @click="showAddFilterModal = true" class="btn btn-outline-primary rounded-pill font-weight-light mt-2">
+                    <i class="fas fa-plus mr-1"></i> Create Your First Filter
+                </button>
+            </div>
+        </div>
+
+        <div v-else>
+            <div class="d-flex justify-content-between align-items-center mb-3">
+                <p v-if="!searchQuery || !searchQuery.trim().length" class="text-muted mb-0">
+                    <span class="font-weight-bold">{{ filters.length }}</span>
+                    {{ filters.length === 1 ? 'filter' : 'filters' }} found
+                </p>
+                <p v-else class="text-muted mb-0">
+                    <span class="font-weight-bold">{{ filteredFilters.length }}</span>
+                    {{ filteredFilters.length === 1 ? 'filter' : 'filters' }} found
+                </p>
+                <div class="input-group input-group-sm" style="max-width: 250px;">
+                    <div class="input-group-prepend">
+                        <span class="input-group-text bg-light border-right-0">
+                          <i class="fas fa-search text-muted"></i>
+                      </span>
+                  </div>
+                  <input
+                      type="text"
+                      v-model="searchQuery"
+                      class="form-control border-left-0 bg-light"
+                      placeholder="Search filters..."
+                      />
+                  </div>
+            </div>
+
+            <div v-if="searchQuery && filteredFilters.length === 0" class="bg-light p-4 rounded text-center border">
+                <div class="py-3">
+                    <i class="fas fa-filter text-secondary fa-3x mb-3"></i>
+                    <p class="lead text-secondary">You don't have any content filters that match <strong>{{searchQuery}}</strong>.</p>
+                    <p class="text-muted small mt-2">
+                    Filters help you hide content containing specific words or phrases from your timelines.
+                    </p>
+                    <button @click="showAddFilterModal = true" class="btn btn-outline-primary rounded-pill font-weight-light mt-2">
+                        <i class="fas fa-plus mr-1"></i> Create new Filter
+                    </button>
+                </div>
+            </div>
+
+            <div class="card-deck-wrapper">
+                <div class="list-group">
+                    <filter-card
+                    v-for="filter in filteredFilters"
+                    :key="filter.id"
+                    :filter="filter"
+                    @edit="editFilter"
+                    @delete="deleteFilter"
+                    />
+                </div>
+            </div>
+        </div>
+
+        <filter-modal
+            v-if="showAddFilterModal || showEditFilterModal"
+            :filter="editingFilter"
+            :is-editing="showEditFilterModal"
+            :wizard-mode="wizardMode"
+            @delete="handleFilterDelete"
+            @toggle="updateWizardMode"
+            @close="closeModals"
+            @save="saveFilter"
+        />
+    </div>
+</template>
+
+<script>
+import FilterCard from './FilterCard.vue';
+import FilterModal from './FilterModal.vue';
+
+export default {
+    name: 'FiltersList',
+    components: {
+        FilterCard,
+        FilterModal
+    },
+    data() {
+        return {
+            filters: [],
+            loading: true,
+            filtersLoaded: false,
+            showAddFilterModal: false,
+            showEditFilterModal: false,
+            editingFilter: null,
+            searchQuery: '',
+            wizardMode: true,
+        }
+    },
+    computed: {
+        filteredFilters() {
+            if (!this.searchQuery) return this.filters;
+
+            const query = this.searchQuery.toLowerCase().trim();
+            return this.filters.filter(filter => {
+                if (filter.title && filter.title.toLowerCase().includes(query)) return true;
+
+                if (filter.keywords && filter.keywords.some(k =>
+                    k.keyword && k.keyword.toLowerCase().includes(query)
+                    )) return true;
+
+                    if (filter.context && filter.context.some(c => c.toLowerCase().includes(query))) return true;
+
+                return false;
+            });
+        }
+    },
+    mounted() {
+        this.fetchFilters();
+    },
+    methods: {
+        fetchFilters() {
+            this.loading = true;
+            axios.get('/api/v2/filters')
+            .then(response => {
+                this.filters = response.data;
+            })
+            .catch(error => {
+                console.error('Failed to fetch filters:', error);
+                swal('Error', 'Failed to load filters. Please try again.', 'error');
+            })
+            .finally(() => {
+                this.loading = false;
+                this.filtersLoaded = true;
+            });
+        },
+        closeModals() {
+            this.wizardMode = true;
+            this.showAddFilterModal = false;
+            this.showEditFilterModal = false;
+            this.editingFilter = null;
+        },
+        handleFilterDelete() {
+            this.deleteFilter(this.editingFilter.id);
+            this.closeModals();
+        },
+        updateWizardMode() {
+            this.wizardMode = !this.wizardMode;
+        },
+        editFilter(filter) {
+            this.wizardMode = false;
+            this.editingFilter = JSON.parse(JSON.stringify(filter));
+            this.showEditFilterModal = true;
+        },
+        deleteFilter(filterId) {
+            if (!confirm('Are you sure you want to delete this filter?')) return;
+
+            this.loading = true;
+            axios.delete(`/api/v2/filters/${filterId}`)
+            .then(() => {
+                this.filters = this.filters.filter(f => f.id !== filterId);
+                swal('Success', 'Filter deleted successfully', 'success');
+            })
+            .catch(error => {
+                swal('Error', 'Failed to delete filter. Please try again.', 'error')
+            })
+            .finally(() => {
+                this.loading = false;
+            });
+        },
+        saveFilter(filterData) {
+            this.loading = true;
+
+            if (this.showEditFilterModal) {
+                axios.put(`/api/v2/filters/${filterData.id}`, filterData)
+                .then(response => {
+                    const updatedIndex = this.filters.findIndex(f => f.id === filterData.id);
+                    if (updatedIndex !== -1) {
+                        this.$set(this.filters, updatedIndex, response.data);
+                    }
+                    this.$bvToast.toast(`${response.data?.title ?? 'Untitled'} filter updated successfully`, {
+                        title: 'Updated Filter',
+                        autoHideDelay: 5000,
+                        appendToast: true,
+                        variant: 'success'
+                    })
+                    this.closeModals();
+                })
+                .catch(error => {
+                    if(error.response?.data?.error) {
+                        swal(error.response?.data?.error, error.response?.data?.message, 'error')
+                    } else if(error.response?.data?.message) {
+                        swal('Error', error.response?.data?.message, 'error')
+                    } else {
+                        swal('Error', 'Failed to update filter. Please try again.', 'error')
+                    }
+                })
+                .finally(() => {
+                    this.loading = false;
+                });
+            } else {
+                axios.post('/api/v2/filters', filterData)
+                .then(response => {
+                    this.filters.unshift(response.data);
+                    this.$bvToast.toast(`${response.data?.title ?? 'Untitled'} filter created`, {
+                        title: 'New Filter',
+                        autoHideDelay: 5000,
+                        appendToast: true,
+                        variant: 'success'
+                    })
+                    this.closeModals();
+                })
+                .catch(error => {
+                    if(error.response?.data?.error) {
+                        swal(error.response?.data?.error, error.response?.data?.message, 'error')
+                    } else if(error.response?.data?.message) {
+                        swal('Error', error.response?.data?.message, 'error')
+                    } else {
+                        swal('Error', 'Failed to create filter. Please try again.', 'error')
+                    }
+                })
+                .finally(() => {
+                    this.loading = false;
+                });
+            }
+        }
+    }
+}
+</script>
+
+<style scoped>
+    .card-deck-wrapper {
+        overflow-y: auto;
+        max-height: 40dvh;
+    }
+</style>

+ 14 - 0
resources/assets/js/custom_filters.js

@@ -0,0 +1,14 @@
+Vue.component(
+    'filter-card',
+    require('./components/filters/FilterCard.vue').default
+);
+
+Vue.component(
+    'filter-modal',
+    require('./components/filters/FilterModal.vue').default
+);
+
+Vue.component(
+    'filters-list',
+    require('./components/filters/FiltersList.vue').default
+);

+ 11 - 0
resources/views/settings/filters/home.blade.php

@@ -0,0 +1,11 @@
+@extends('settings.template')
+
+@section('section')
+
+<filters-list />
+
+@endsection
+
+@push('scripts')
+<script type="text/javascript" src="{{mix('js/custom_filters.js')}}"></script>
+@endpush

+ 3 - 0
resources/views/settings/partial/sidebar.blade.php

@@ -17,6 +17,9 @@
             <li class="nav-item pl-3 {{request()->is('settings/media*')?'active':''}}">
             <li class="nav-item pl-3 {{request()->is('settings/media*')?'active':''}}">
                 <a class="nav-link font-weight-light text-muted" href="{{route('settings.media')}}">{{__('settings.media')}}</a>
                 <a class="nav-link font-weight-light text-muted" href="{{route('settings.media')}}">{{__('settings.media')}}</a>
             </li>
             </li>
+            <li class="nav-item pl-3 {{request()->is('settings/filters*')?'active':''}}">
+                <a class="nav-link font-weight-light text-muted" href="{{route('settings.filters')}}">Filters</a>
+            </li>
             {{-- <li class="nav-item pl-3 {{request()->is('settings/notifications')?'active':''}}">
             {{-- <li class="nav-item pl-3 {{request()->is('settings/notifications')?'active':''}}">
                 <a class="nav-link font-weight-light text-muted" href="{{route('settings.notifications')}}">{{__('settings.notifications')}}</a>
                 <a class="nav-link font-weight-light text-muted" href="{{route('settings.notifications')}}">{{__('settings.notifications')}}</a>
             </li> --}}
             </li> --}}

+ 6 - 0
routes/api.php

@@ -187,6 +187,12 @@ Route::group(['prefix' => 'api'], function () use ($middleware) {
         Route::post('media', 'Api\ApiV2Controller@mediaUploadV2')->middleware($middleware);
         Route::post('media', 'Api\ApiV2Controller@mediaUploadV2')->middleware($middleware);
         Route::get('streaming/config', 'Api\ApiV2Controller@getWebsocketConfig');
         Route::get('streaming/config', 'Api\ApiV2Controller@getWebsocketConfig');
         Route::get('instance', 'Api\ApiV2Controller@instance');
         Route::get('instance', 'Api\ApiV2Controller@instance');
+
+        Route::get('filters', 'CustomFilterController@index')->middleware($middleware);
+        Route::get('filters/{id}', 'CustomFilterController@show')->middleware($middleware);
+        Route::post('filters', 'CustomFilterController@store')->middleware($middleware);
+        Route::put('filters/{id}', 'CustomFilterController@update')->middleware($middleware);
+        Route::delete('filters/{id}', 'CustomFilterController@delete')->middleware($middleware);
     });
     });
 
 
     Route::group(['prefix' => 'v1.1'], function () use ($middleware) {
     Route::group(['prefix' => 'v1.1'], function () use ($middleware) {

+ 4 - 0
routes/web.php

@@ -365,6 +365,10 @@ Route::domain(config('pixelfed.domain.app'))->middleware(['validemail', 'twofact
             Route::get('manage', 'ProfileMigrationController@index');
             Route::get('manage', 'ProfileMigrationController@index');
             Route::post('manage', 'ProfileMigrationController@store');
             Route::post('manage', 'ProfileMigrationController@store');
         });
         });
+
+        Route::group(['prefix' => 'filters'], function() {
+            Route::get('/', 'SettingsController@filtersHome')->name('settings.filters');
+        });
     });
     });
 
 
     Route::group(['prefix' => 'site'], function () {
     Route::group(['prefix' => 'site'], function () {

+ 1 - 0
webpack.mix.js

@@ -44,6 +44,7 @@ mix.js('resources/assets/js/app.js', 'public/js')
 .js('resources/assets/js/groups.js', 'public/js')
 .js('resources/assets/js/groups.js', 'public/js')
 .js('resources/assets/js/group-status.js', 'public/js')
 .js('resources/assets/js/group-status.js', 'public/js')
 .js('resources/assets/js/group-topic-feed.js', 'public/js')
 .js('resources/assets/js/group-topic-feed.js', 'public/js')
+.js('resources/assets/js/custom_filters.js', 'public/js')
 .vue({ version: 2 });
 .vue({ version: 2 });
 
 
 mix.extract();
 mix.extract();