1
0
Daniel Supernault 2 сар өмнө
parent
commit
1b048664b7

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

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

@@ -0,0 +1,1361 @@
+<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(index)"
+                                                >
+                                                <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(index)"
+                                                >
+                                                <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: [],
+                    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] : [],
+                    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.$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.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;
+
+                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(index) {
+                this.formData.keywords.splice(index, 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>