diff --git a/resources/js/components/admin/UserRow.vue b/resources/js/components/admin/UserRow.vue
index 37f7c3e3..55b67417 100644
--- a/resources/js/components/admin/UserRow.vue
+++ b/resources/js/components/admin/UserRow.vue
@@ -1,6 +1,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
- Global Chat Management
-
-
- Manage realtime student lounge access, rate limits,
- cooldown intervals, and moderation.
-
-
-
-
-
-
-
-
- {{
- form.audience !== 'disabled'
- ? 'Chat Live'
- : 'Chat Inactive'
- }}
-
-
-
-
-
-
-
-
- Chat Configuration
-
-
-
-
- Reported Messages
-
- {{ pendingReportsCount }}
-
-
-
-
-
-
-
-
- Stored Messages
-
-
-
- {{ totalMessages }}
-
-
Auto-pruning maintains max {{ form.max_messages }}
-
-
-
-
-
Activity (Last 24h)
-
-
-
- {{ recentMessagesCount }}
-
-
Messages sent today
-
-
-
-
- Current Cooldown
-
-
-
- {{ form.cooldown_seconds }}s
-
-
Interval per student
-
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/Dashboard.tsx b/resources/js/pages/admin/Dashboard.tsx
new file mode 100644
index 00000000..48d6eb99
--- /dev/null
+++ b/resources/js/pages/admin/Dashboard.tsx
@@ -0,0 +1,445 @@
+/**
+ * AdminDashboard — TSX port of `Dashboard.vue`.
+ *
+ * Management tools grid + on-demand analytics, rewritten as a `.tsx`
+ * `defineComponent` render function. Resolved via the explicit
+ * dual-extension (`*.vue` + `*.tsx`) page resolver in `resources/js/app.ts`.
+ *
+ * Ported 1:1 from the SFC (parity is the reviewable contract); shared
+ * admin chrome (headers/buttons) comes from `@/components/admin/ui`.
+ */
+import { Head, Link } from '@inertiajs/vue3';
+import {
+ BarChart3,
+ Bell,
+ BookOpen,
+ CheckSquare,
+ Eye,
+ LayoutGrid,
+ LifeBuoy,
+ Mail,
+ MessageCircle,
+ MessageSquare,
+ Radio,
+ RefreshCw,
+ Share2,
+ UserCheck,
+ Users,
+} from 'lucide-vue-next';
+import { computed, defineComponent, ref } from 'vue';
+import type { Component } from 'vue';
+import type { PropType } from 'vue';
+
+import {
+ adminListClass,
+ adminPageClass,
+ adminSectionTitleClass,
+ adminPageDescriptionClass,
+ adminPageHeaderClass,
+ adminPageTitleClass,
+ adminPrimaryBtnClass,
+ adminSecondaryBtnClass,
+} from '@/components/admin/ui';
+import EmptyState from '@/components/EmptyState.vue';
+import { usePermissions } from '@/lib/usePermissions';
+
+interface AnalyticsSource {
+ source: string;
+ visits: number;
+}
+
+interface DashboardAnalytics {
+ total_accounts?: number;
+ realtime_users?: number;
+ total_visits?: number;
+ total_users?: number;
+ top_sources?: AnalyticsSource[];
+}
+
+interface AdminTool {
+ name: string;
+ href: string;
+ icon: Component;
+ color: string;
+ permission?: string;
+}
+
+const adminTools: AdminTool[] = [
+ {
+ name: 'Resource Moderation',
+ href: '/admin/moderation/resources',
+ icon: CheckSquare,
+ color: 'text-emerald-600 bg-emerald-50 dark:bg-emerald-500/10 dark:text-emerald-400',
+ permission: 'moderate resources',
+ },
+ {
+ name: 'Manage Contents',
+ href: '/admin/subjects',
+ icon: BookOpen,
+ color: 'text-indigo-600 bg-indigo-50 dark:bg-indigo-500/10 dark:text-indigo-400',
+ },
+ {
+ name: 'Manage Forum',
+ href: '/admin/forums',
+ icon: MessageSquare,
+ color: 'text-blue-600 bg-blue-50 dark:bg-blue-500/10 dark:text-blue-400',
+ permission: 'manage forums',
+ },
+ {
+ name: 'Support Tickets',
+ href: '/admin/tickets',
+ icon: LifeBuoy,
+ color: 'text-amber-600 bg-amber-50 dark:bg-amber-500/10 dark:text-amber-400',
+ permission: 'manage tickets',
+ },
+ {
+ name: 'Site Notice',
+ href: '/admin/notice',
+ icon: Bell,
+ color: 'text-purple-600 bg-purple-50 dark:bg-purple-500/10 dark:text-purple-400',
+ permission: 'edit notice',
+ },
+ {
+ name: 'Global Chat',
+ href: '/admin/chat',
+ icon: MessageCircle,
+ color: 'text-teal-600 bg-teal-50 dark:bg-teal-500/10 dark:text-teal-400',
+ permission: 'manage chat',
+ },
+ {
+ name: 'Users & Roles',
+ href: '/admin/users',
+ icon: UserCheck,
+ color: 'text-sky-600 bg-sky-50 dark:bg-sky-500/10 dark:text-sky-400',
+ permission: 'view users',
+ },
+ {
+ name: 'Peer & Pokes',
+ href: '/admin/peers/settings',
+ icon: Radio,
+ color: 'text-rose-600 bg-rose-50 dark:bg-rose-500/10 dark:text-rose-400',
+ permission: 'manage peers',
+ },
+ {
+ name: 'Send Emails',
+ href: '/admin/emails/send',
+ icon: Mail,
+ color: 'text-orange-600 bg-orange-50 dark:bg-orange-500/10 dark:text-orange-400',
+ permission: 'send email',
+ },
+];
+
+export default defineComponent({
+ name: 'AdminDashboard',
+ props: {
+ totalAccounts: { type: Number as PropType
, default: undefined },
+ },
+ setup(props) {
+ const { can } = usePermissions();
+
+ const stats = ref(null);
+ const isLoading = ref(false);
+ const hasFetched = ref(false);
+ const errorMsg = ref(null);
+
+ const visibleTools = computed(() =>
+ adminTools.filter(
+ (tool) => !tool.permission || can(tool.permission),
+ ),
+ );
+
+ const fetchAnalytics = async (
+ refresh: boolean = false,
+ ): Promise => {
+ isLoading.value = true;
+ errorMsg.value = null;
+
+ try {
+ const res = await fetch(
+ `/admin/analytics${refresh ? '?refresh=1' : ''}`,
+ );
+
+ if (!res.ok) {
+ throw new Error('Analytics could not be loaded');
+ }
+
+ const data: unknown = await res.json();
+ stats.value = data as DashboardAnalytics;
+ hasFetched.value = true;
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ errorMsg.value = e.message || 'Failed to load analytics';
+ } else {
+ errorMsg.value = 'Failed to load analytics';
+ }
+ } finally {
+ isLoading.value = false;
+ }
+ };
+
+ return () => {
+ const totalAccounts =
+ stats.value?.total_accounts ?? props.totalAccounts ?? 0;
+
+ return (
+ <>
+
+
+
+ {/* Header — description carries the account counter */}
+
+
+ {/* Management Tools Grid */}
+
+
+ Management Tools
+
+
+
+ {visibleTools.value.map((tool) => {
+ const Icon = tool.icon;
+
+ return (
+
+
+
+
+
+ {tool.name}
+
+
+ );
+ })}
+
+
+
+ {/* Analytics Section */}
+
+
+
+ Live Traffic & Analytics
+
+ {hasFetched.value && (
+
+ Last 30 days
+
+ )}
+
+
+ {/* Load errors render above every branch so a
+ failed fetch is never silent. */}
+ {errorMsg.value && (
+
+ {errorMsg.value}
+
+ )}
+
+ {/* Unloaded State Placeholder */}
+ {!hasFetched.value && !isLoading.value ? (
+
+
+
+ ) : isLoading.value && !hasFetched.value ? (
+
+
+
+ Loading metrics...
+
+
+ ) : (
+
+
+ {/* Realtime Active */}
+
+
+
+
+
+
+
+
+ Active Now
+
+
+
+ Past 5 mins
+
+
+
+
+ {stats.value
+ ?.realtime_users ?? 0}
+
+
+
+
+ {/* Total Visits */}
+
+
+
+
+ {stats.value?.total_visits?.toLocaleString() ??
+ 0}
+
+
+ Pageviews recorded
+
+
+
+
+ {/* Unique Visitors */}
+
+
+
+ Unique Visitors
+
+
+
+
+
+ {stats.value?.total_users?.toLocaleString() ??
+ 0}
+
+
+ Distinct visitors
+
+
+
+
+
+ {/* Top Traffic Sources */}
+
+
+
+
+ Top Traffic Sources
+
+
+
+
+ {stats.value?.top_sources
+ ?.length ? (
+
+ {stats.value.top_sources.map(
+ (
+ source,
+ index: number,
+ ) => (
+
+
+ {source.source ||
+ 'Direct / Unknown'}
+
+
+ {source.visits?.toLocaleString()}
+
+
+ ),
+ )}
+
+ ) : (
+
+
+
+ No source data available
+ yet.
+
+
+ )}
+
+
+
+ )}
+
+
+ >
+ );
+ };
+ },
+});
diff --git a/resources/js/pages/admin/Dashboard.vue b/resources/js/pages/admin/Dashboard.vue
deleted file mode 100644
index d49d5792..00000000
--- a/resources/js/pages/admin/Dashboard.vue
+++ /dev/null
@@ -1,417 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Dashboard
-
-
- Staff workspace •
-
- {{
- (
- stats?.total_accounts ??
- props.totalAccounts ??
- 0
- ).toLocaleString()
- }}
- Total Accounts
-
-
-
-
-
-
-
-
-
-
-
-
- Management Tools
-
-
-
-
-
-
-
-
-
- {{ tool.name }}
-
-
-
-
-
-
-
-
-
- Live Traffic & Analytics
-
-
- Last 30 days
-
-
-
-
-
-
-
-
-
-
-
-
- Loading metrics...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Active Now
-
-
-
Past 5 mins
-
-
-
- {{ stats?.realtime_users ?? 0 }}
-
-
-
-
-
-
-
-
-
- {{ stats?.total_visits?.toLocaleString() ?? 0 }}
-
-
- Pageviews recorded
-
-
-
-
-
-
-
-
- Unique Visitors
-
-
-
-
-
- {{ stats?.total_users?.toLocaleString() ?? 0 }}
-
-
- Distinct visitors
-
-
-
-
-
-
-
-
-
-
- Top Traffic Sources
-
-
-
-
-
-
-
- {{ source.source || 'Direct / Unknown' }}
-
-
- {{ source.visits?.toLocaleString() }}
-
-
-
-
-
-
-
- No source data available yet.
-
-
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/EmailSend.tsx b/resources/js/pages/admin/EmailSend.tsx
new file mode 100644
index 00000000..47fcfdc2
--- /dev/null
+++ b/resources/js/pages/admin/EmailSend.tsx
@@ -0,0 +1,1183 @@
+import { Head, useForm, usePage } from '@inertiajs/vue3';
+import {
+ Loader2,
+ Mail,
+ Bell,
+ Send,
+ Users,
+ GraduationCap,
+ ShieldCheck,
+ AlertCircle,
+ CheckCircle2,
+ Eye,
+ X,
+ Upload,
+ Trash2,
+ Download,
+ ChevronDown,
+} from 'lucide-vue-next';
+import {
+ Teleport,
+ computed,
+ defineComponent,
+ onBeforeUnmount,
+ onMounted,
+ onUnmounted,
+ ref,
+ watch,
+} from 'vue';
+
+import {
+ AdminPageHeader,
+ adminDangerIconBtnClass,
+ adminInputClass,
+ adminPanelClass,
+ adminPageClass,
+ adminPrimaryBtnClass,
+ adminSectionTitleClass,
+ adminSecondaryBtnClass,
+} from '@/components/admin/ui';
+import HTMLEditor from '@/components/HTMLEditor.vue';
+import { markAdminFormDirty } from '@/lib/adminFormGuard';
+
+interface EmailFormFields {
+ recipients: string;
+ subject: string;
+ body: string;
+ image: File | null;
+ send_email: boolean;
+ send_in_app: boolean;
+ action_url: string;
+}
+
+interface RecipientsResponse {
+ emails?: unknown;
+}
+
+export default defineComponent({
+ name: 'AdminEmailSend',
+ props: {
+ allUsersCount: { type: Number, default: 0 },
+ recipientCount: { type: Number, default: 0 },
+ studentsCount: { type: Number, default: 0 },
+ staffCount: { type: Number, default: 0 },
+ },
+ setup(props) {
+ const page = usePage();
+ const appName = computed(() => {
+ const propsRecord = page.props as Record;
+
+ return typeof propsRecord.appName === 'string'
+ ? propsRecord.appName
+ : 'HSCStack';
+ });
+
+ const showConfirmModal = ref(false);
+ const showPreviewModal = ref(false);
+ const isImportDropdownOpen = ref(false);
+ const isImporting = ref(false);
+
+ const importDropdownRef = ref(null);
+ const imagePreview = ref(null);
+ const fileInput = ref(null);
+
+ const form = useForm({
+ recipients: '',
+ subject: '',
+ body: '',
+ image: null as File | null,
+ send_email: true,
+ send_in_app: false,
+ action_url: '',
+ });
+
+ watch(
+ () => form.isDirty,
+ (v) => markAdminFormDirty(v),
+ );
+
+ onUnmounted(() => markAdminFormDirty(false));
+
+ const formattedCurrentDate = computed(() => {
+ return new Date().toLocaleDateString('en-US', {
+ weekday: 'short',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ });
+
+ // Click outside handler for import dropdown
+ const handleClickOutside = (event: MouseEvent): void => {
+ if (
+ importDropdownRef.value &&
+ !importDropdownRef.value.contains(event.target as Node)
+ ) {
+ isImportDropdownOpen.value = false;
+ }
+ };
+
+ onMounted(() => {
+ document.addEventListener('click', handleClickOutside);
+ });
+
+ onBeforeUnmount(() => {
+ document.removeEventListener('click', handleClickOutside);
+ });
+
+ // Parse and analyze recipient emails in real-time
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+ const recipientStats = computed(() => {
+ const raw = form.recipients.trim();
+
+ if (!raw) {
+ return {
+ validEmails: [] as string[],
+ invalidItems: [] as string[],
+ totalLines: 0,
+ duplicateCount: 0,
+ };
+ }
+
+ const tokens = raw
+ .split(/[\r\n,;]+/)
+ .map((t) => t.trim())
+ .filter(Boolean);
+ const seen = new Set();
+ const validEmails: string[] = [];
+ const invalidItems: string[] = [];
+ let duplicateCount = 0;
+
+ for (const token of tokens) {
+ const lower = token.toLowerCase();
+
+ if (emailRegex.test(lower)) {
+ if (seen.has(lower)) {
+ duplicateCount++;
+ } else {
+ seen.add(lower);
+ validEmails.push(lower);
+ }
+ } else {
+ invalidItems.push(token);
+ }
+ }
+
+ return {
+ validEmails,
+ invalidItems,
+ totalLines: tokens.length,
+ duplicateCount,
+ };
+ });
+
+ const handleImageSelect = (event: Event): void => {
+ const target = event.target as HTMLInputElement;
+
+ if (target.files && target.files[0]) {
+ const file = target.files[0];
+ form.image = file;
+ imagePreview.value = URL.createObjectURL(file);
+ }
+ };
+
+ const handleRemoveImage = (): void => {
+ form.image = null;
+ imagePreview.value = null;
+
+ if (fileInput.value) {
+ fileInput.value.value = '';
+ }
+ };
+
+ /**
+ * Merges subscriber strings for the selected audience into the list.
+ * Existing entries are trimmed and lowercased; exact duplicates are
+ * removed across both lists. Concurrent calls are ignored. HTTP,
+ * network, or JSON parsing failures leave recipients unchanged and
+ * do not reject the returned promise.
+ */
+ const importSubscribers = async (
+ type: 'subscribers' | 'all' | 'students' | 'staff',
+ ): Promise => {
+ if (isImporting.value) {
+ return;
+ }
+
+ isImporting.value = true;
+ isImportDropdownOpen.value = false;
+
+ try {
+ const response = await fetch(
+ `/admin/emails/recipients?type=${type}`,
+ {
+ headers: {
+ Accept: 'application/json',
+ 'X-Requested-With': 'XMLHttpRequest',
+ },
+ },
+ );
+
+ if (response.ok) {
+ const data = (await response.json()) as RecipientsResponse;
+ const rawEmails: unknown = data.emails;
+ const importedList: string[] = Array.isArray(rawEmails)
+ ? rawEmails.filter(
+ (e): e is string => typeof e === 'string',
+ )
+ : [];
+
+ // Merge with existing emails avoiding duplicates
+ const currentTokens = form.recipients
+ .split(/[\r\n,;]+/)
+ .map((t) => t.trim().toLowerCase())
+ .filter(Boolean);
+
+ const mergedSet = new Set([
+ ...currentTokens,
+ ...importedList,
+ ]);
+ form.recipients = Array.from(mergedSet).join('\n');
+ }
+ } catch (e) {
+ console.error('Failed to import recipients:', e);
+ } finally {
+ isImporting.value = false;
+ }
+ };
+
+ /**
+ * Replaces the list with lowercase, unique valid addresses, one per
+ * line, discarding invalid entries separated by newlines, commas, or
+ * semicolons. Validity uses the page's basic email pattern.
+ */
+ const cleanAndFormatRecipients = (): void => {
+ const valid = recipientStats.value.validEmails;
+ form.recipients = valid.join('\n');
+ };
+
+ /**
+ * Clears prior errors and opens confirmation when at least one valid
+ * recipient and a nonblank subject and body exist. Otherwise sets
+ * field errors; this does not clean the recipient list or send mail.
+ */
+ const handleSendClick = (): void => {
+ // Client-side validation is idempotent here; without clearing,
+ // errors from a previous failed attempt stick around until the
+ // server response replaces them.
+ form.clearErrors();
+
+ // Guard also covers Enter-key implicit submission, which fires
+ // the form handler without touching the disabled submit button.
+ if (!form.send_email && !form.send_in_app) {
+ return;
+ }
+
+ if (recipientStats.value.validEmails.length === 0) {
+ form.setError(
+ 'recipients',
+ 'Please provide at least one valid recipient email.',
+ );
+
+ return;
+ }
+
+ if (!form.subject.trim() || !form.body.trim()) {
+ if (!form.subject.trim()) {
+ form.setError('subject', 'Subject is required.');
+ }
+
+ if (!form.body.trim()) {
+ form.setError('body', 'Body is required.');
+ }
+
+ return;
+ }
+
+ showConfirmModal.value = true;
+ };
+
+ /** Closes confirmation and posts the current fields as multipart data. */
+ const submitForm = (): void => {
+ showConfirmModal.value = false;
+ form.post('/admin/emails/send', {
+ preserveScroll: true,
+ forceFormData: true,
+ });
+ };
+
+ const onRecipientsInput = (e: Event) => {
+ form.recipients = (e.target as HTMLTextAreaElement).value;
+ };
+
+ const onSubjectInput = (e: Event) => {
+ form.subject = (e.target as HTMLInputElement).value;
+ };
+
+ return () => (
+ <>
+
+
+
+ {/* Page header */}
+
+ {{
+ actions: () => (
+ <>
+
+
+
+
+ {props.recipientCount}
+ {' '}
+ total subscribers
+
+
+
+ >
+ ),
+ }}
+
+
+
+
+
+ {/* Live PC / Desktop Email Client Preview Modal */}
+
+ {showPreviewModal.value && (
+
+
+ {/* Desktop Window Titlebar */}
+
+
+
+
+
+
+
+ Email Preview
+
+
+
+
+
+
+ {/* Email Client Header Info */}
+
+
+ {form.subject.trim() ||
+ '(No subject specified)'}
+
+
+
+
+
+ From:
+
+
+ {appName.value}{' '}
+ {''}
+
+
+
+
+ To:
+
+
+ {
+ recipientStats.value
+ .validEmails.length
+ }{' '}
+ unique recipient
+ {recipientStats.value
+ .validEmails.length === 1
+ ? ''
+ : 's'}
+
+
+
+
+ Date:
+
+
+ {formattedCurrentDate.value}
+
+
+
+
+
+ {/* Email Body Container (Exact 600px Template Rendering) */}
+
+
+ {/* Template Header with Logo */}
+
+
+

+
+
+
+
+ {/* Template Content */}
+
+
+ Hello{' '}
+ {recipientStats.value
+ .validEmails.length === 1
+ ? recipientStats.value
+ .validEmails[0]
+ : '[Recipient Name]'}
+ ,
+
+
+ {/* Banner Image in Preview */}
+ {imagePreview.value && (
+
+

+
+ )}
+
+ {/* Rendered HTML Content */}
+ {form.body.trim() ? (
+
+ ) : (
+
+ Your composed email content
+ will appear here...
+
+ )}
+
+
+ {/* Template Footer */}
+
+
+ HSCStack — The Open Learning
+ Platform
+
+
+
+ Visit Platform
+ •
+ Read Blogs
+ •
+ Support Us
+
+
+
+ Manage email preferences in your{' '}
+
+ Account Settings
+
+ , or visit our{' '}
+
+ Support Center
+ {' '}
+ if you need assistance or
+ don't have an account.
+
+
+
+
+
+ {/* Preview Footer Modal Action */}
+
+
+
+
+
+ )}
+
+
+ {/* Send Confirmation Modal */}
+
+ {showConfirmModal.value && (
+
+
+
+
+
+
+
+
+ Confirm Broadcast Queue
+
+
+ {form.send_email && form.send_in_app
+ ? 'Email & In-App Notification'
+ : form.send_email
+ ? `Email Only (${recipientStats.value.validEmails.length} recipient${recipientStats.value.validEmails.length === 1 ? '' : 's'})`
+ : 'In-App Bell Notification Only'}
+
+
+
+
+
+ Are you sure you want to send this
+ broadcast? The broadcast will be queued and
+ delivered asynchronously.
+ {form.send_email &&
+ recipientStats.value.duplicateCount >
+ 0 && (
+
+ (
+ {
+ recipientStats.value
+ .duplicateCount
+ }{' '}
+ duplicates were detected and
+ will only receive 1 email).
+
+ )}
+
+
+
+
+
+
+
+
+
+ )}
+
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/EmailSend.vue b/resources/js/pages/admin/EmailSend.vue
deleted file mode 100644
index 3d4ace42..00000000
--- a/resources/js/pages/admin/EmailSend.vue
+++ /dev/null
@@ -1,1166 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- Compose and dispatch email announcements to custom recipient
- lists or imported platform subscribers.
-
-
-
-
-
-
-
-
-
-
-
-
- Total Subscribers:
-
-
- {{ recipientCount }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Email Preview
-
-
-
-
-
-
-
-
-
- {{ form.subject.trim() || '(No subject specified)' }}
-
-
-
-
- From:
-
- {{ appName }} <team@example.com>
-
-
-
- To:
-
- {{ recipientStats.validEmails.length }} unique
- recipient{{
- recipientStats.validEmails.length === 1
- ? ''
- : 's'
- }}
-
-
-
- Date:
- {{ formattedCurrentDate }}
-
-
-
-
-
-
-
-
-
-
-

-
-
-
-
-
-
-
- Hello
- {{
- recipientStats.validEmails.length === 1
- ? recipientStats.validEmails[0]
- : '[Recipient Name]'
- }},
-
-
-
-
-
![Announcement preview]()
-
-
-
-
-
- Your composed email content will appear here...
-
-
-
-
-
-
- HSCStack — The Open Learning Platform
-
-
-
- Visit Platform
- •
- Read Blogs
- •
- Support Us
-
-
-
- Manage email preferences in your
- Account Settings, or visit our
- Support Center
- if you need assistance or don't have an account.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Confirm Broadcast Queue
-
-
-
- Email & In-App Notification
-
-
- Email Only ({{
- recipientStats.validEmails.length
- }}
- recipient{{
- recipientStats.validEmails.length === 1
- ? ''
- : 's'
- }})
-
- In-App Bell Notification Only
-
-
-
-
-
- Are you sure you want to send this broadcast? The broadcast
- will be queued and delivered asynchronously.
-
- ({{ recipientStats.duplicateCount }} duplicates were
- detected and will only receive 1 email).
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/Index.tsx b/resources/js/pages/admin/Index.tsx
new file mode 100644
index 00000000..40e85191
--- /dev/null
+++ b/resources/js/pages/admin/Index.tsx
@@ -0,0 +1,212 @@
+/**
+ * AdminIndex — TSX port of `Index.vue`.
+ *
+ * Server-query-driven course/group filters + subject list + create/edit
+ * modal, rewritten as a `.tsx` `defineComponent` render function.
+ * Resolved via the explicit dual-extension (`*.vue` + `*.tsx`) page
+ * resolver in `resources/js/app.ts`.
+ *
+ * Ported 1:1 from the SFC; shared admin chrome (header/buttons) comes
+ * from `@/components/admin/ui`.
+ */
+import { Head, Link } from '@inertiajs/vue3';
+import { Plus } from 'lucide-vue-next';
+import { defineComponent, ref } from 'vue';
+import type { PropType } from 'vue';
+
+import CreateSubjectModal from '@/components/admin/CreateSubjectModal.vue';
+import SubjectCard from '@/components/admin/SubjectCard.vue';
+import {
+ AdminPageHeader,
+ adminPageClass,
+ adminPrimaryBtnClass,
+} from '@/components/admin/ui';
+import EmptyState from '@/components/EmptyState.vue';
+import { usePermissions } from '@/lib/usePermissions';
+
+interface Subject {
+ id?: number | null;
+ name: string;
+ slug?: string | null;
+ course?: string | null;
+ [key: string]: unknown;
+}
+
+/* Filter option data shared by both pill groups. */
+const CURRICULUM_PILLS = [
+ { key: 'hsc', label: 'HSC' },
+ { key: 'ssc', label: 'SSC' },
+];
+
+const GROUP_PILLS = [
+ { key: 'science', label: 'বিজ্ঞান' },
+ { key: 'humanities', label: 'মানবিক' },
+ { key: 'commerce', label: 'ব্যবসায় শিক্ষা' },
+];
+
+/** One server-query-driven filter pill (active = the rail's indigo wash). */
+const FilterPill = defineComponent({
+ name: 'AdminFilterPill',
+ props: {
+ href: { type: String, required: true },
+ label: { type: String, required: true },
+ active: { type: Boolean, default: false },
+ },
+ setup(props) {
+ return () => (
+
+ {props.label}
+
+ );
+ },
+});
+
+export default defineComponent({
+ name: 'AdminIndex',
+ props: {
+ subjects: { type: Array as PropType, required: true },
+ currentCourse: {
+ type: String as PropType,
+ default: null,
+ },
+ currentGroup: {
+ type: String as PropType,
+ default: null,
+ },
+ },
+ setup(props) {
+ const { can } = usePermissions();
+
+ const isCreateModalOpen = ref(false);
+ const editingSubject = ref(null);
+
+ const openCreateModal = () => {
+ editingSubject.value = null;
+ isCreateModalOpen.value = true;
+ };
+
+ const openEditModal = (subject: Subject) => {
+ editingSubject.value = subject;
+ isCreateModalOpen.value = true;
+ };
+
+ const handleModalClose = () => {
+ isCreateModalOpen.value = false;
+ editingSubject.value = null;
+ };
+
+ const getFilterUrl = (course: string, group: string) => {
+ return `/admin/subjects?course=${course}&group=${group}`;
+ };
+
+ return () => (
+ <>
+
+
+
+ {/* Compact Page Title Bar */}
+
+ {{
+ actions: () =>
+ can('create subjects') && (
+
+ ),
+ }}
+
+
+ {/* Create/Edit Subject Modal */}
+
+
+ {/* Filter Pills Bar (Server Query Driven, Defaults to HSC & Science) */}
+
+ {/* Curriculum Filter Pills */}
+
+
+ কারিকুলাম:
+
+ {CURRICULUM_PILLS.map((c) => (
+
+ ))}
+
+
+ {/* Group Filter Pills */}
+
+
+ বিভাগ:
+
+ {GROUP_PILLS.map((g) => (
+
+ ))}
+
+
+
+
+ {props.subjects && props.subjects.length > 0 ? (
+
+ {props.subjects.map((subject) => (
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/Index.vue b/resources/js/pages/admin/Index.vue
deleted file mode 100644
index 921c532e..00000000
--- a/resources/js/pages/admin/Index.vue
+++ /dev/null
@@ -1,155 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Manage Subjects
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- কারিকুলাম:
-
-
- {{ c.label }}
-
-
-
-
-
-
- বিভাগ:
-
-
- {{ g.label }}
-
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/Node.tsx b/resources/js/pages/admin/Node.tsx
new file mode 100644
index 00000000..0b530335
--- /dev/null
+++ b/resources/js/pages/admin/Node.tsx
@@ -0,0 +1,485 @@
+/**
+ * AdminNode — TSX port of `Node.vue`.
+ *
+ * Folder/resources browser with live/pending/rejected moderation tabs,
+ * freeze controls and breadcrumbs, rewritten as a `.tsx`
+ * `defineComponent` render function. Resolved via the explicit
+ * dual-extension (`*.vue` + `*.tsx`) page resolver in `resources/js/app.ts`.
+ *
+ * Ported 1:1 from the SFC; the chrome lives in `NodeHeader.vue` /
+ * `NodeTabsHeader.vue`, rows in `NodeRow.vue` / `ResourceRow.vue` /
+ * `PendingSubmissionRow.vue`.
+ */
+import { Head, router } from '@inertiajs/vue3';
+import { computed, defineComponent, onMounted, onUnmounted, ref } from 'vue';
+import type { PropType } from 'vue';
+
+import BulkNodeModal from '@/components/admin/BulkNodeModal.vue';
+import BulkRenameModal from '@/components/admin/BulkRenameModal.vue';
+import CreateNodeModal from '@/components/admin/CreateNodeModal.vue';
+import CreateResourceModal from '@/components/admin/CreateResourceModal.vue';
+import NodeHeader from '@/components/admin/NodeHeader.vue';
+import NodeRow from '@/components/admin/NodeRow.vue';
+import NodeTabsHeader from '@/components/admin/NodeTabsHeader.vue';
+import PendingSubmissionRow from '@/components/admin/PendingSubmissionRow.vue';
+import ResourceRow from '@/components/admin/ResourceRow.vue';
+import ReviewResourceModal from '@/components/admin/ReviewResourceModal.vue';
+import { adminPageClass } from '@/components/admin/ui';
+import EmptyState from '@/components/EmptyState.vue';
+import { usePermissions } from '@/lib/usePermissions';
+
+interface BreadcrumbItem {
+ name: string;
+ link: string;
+}
+
+type ResourceTab = 'live' | 'pending' | 'rejected';
+
+export default defineComponent({
+ name: 'AdminNode',
+ props: {
+ subject: { type: Object as PropType, default: null },
+ nodes: { type: Array as PropType, default: null },
+ resources: { type: Array as PropType, default: null },
+ pending_creates: { type: Array as PropType, default: null },
+ rejected_creates: { type: Array as PropType, default: null },
+ parent: { type: Object as PropType, default: null },
+ breadcrumb: { type: Array as PropType, default: null },
+ },
+ setup(props) {
+ const { can } = usePermissions();
+
+ /* Frozen state (direct on this folder vs inherited from a parent). */
+ const isDirectlyFrozen = computed(() =>
+ Boolean(props.parent?.is_frozen),
+ );
+ const isInheritedFrozen = computed(
+ () =>
+ !isDirectlyFrozen.value &&
+ Boolean(props.parent?.is_effectively_frozen),
+ );
+ const isFrozen = computed(
+ () => isDirectlyFrozen.value || isInheritedFrozen.value,
+ );
+
+ const isTogglingFreeze = ref(false);
+
+ /**
+ * Confirms and requests a toggle of this folder's direct freeze flag.
+ * Inherited freezes can be made direct; without a parent ID, does nothing.
+ */
+ const toggleFreeze = () => {
+ if (!props.parent?.id) {
+ return;
+ }
+
+ let message = 'Are you sure you want to freeze this folder?';
+
+ if (props.parent.is_frozen) {
+ message = 'Are you sure you want to unfreeze this folder?';
+ } else if (isInheritedFrozen.value) {
+ message =
+ 'প্যারেন্ট ফোল্ডার আনলক করা হলেও এই ফোল্ডারটি যাতে লক থাকে, সেজন্য কি এটিকে আলাদাভাবে ফ্রিজ করতে চান?';
+ }
+
+ if (confirm(message)) {
+ isTogglingFreeze.value = true;
+ router.post(
+ `/admin/nodes/${props.parent.id}/toggle-freeze`,
+ {},
+ {
+ preserveScroll: true,
+ onFinish: () => {
+ isTogglingFreeze.value = false;
+ },
+ },
+ );
+ }
+ };
+
+ const isBulkModalOpen = ref(false);
+ const isBulkRenameModalOpen = ref(false);
+ const isSingleModalOpen = ref(false);
+ const editingNode = ref(null);
+ const isSingleResourceModalOpen = ref(false);
+ const editingResource = ref(null);
+ const viewingPendingModal = ref(null);
+
+ const openCreateNodeModal = () => {
+ editingNode.value = null;
+ isSingleModalOpen.value = true;
+ };
+
+ const openEditNodeModal = (node: any) => {
+ editingNode.value = node;
+ isSingleModalOpen.value = true;
+ };
+
+ const handleNodeModalClose = () => {
+ isSingleModalOpen.value = false;
+ editingNode.value = null;
+ };
+
+ const openEditResourceModal = (resource: any) => {
+ editingResource.value = resource;
+ isSingleResourceModalOpen.value = true;
+ };
+
+ const handleResourceModalClose = () => {
+ isSingleResourceModalOpen.value = false;
+ editingResource.value = null;
+ };
+
+ const totalItemsCount = computed(
+ () =>
+ (props.nodes?.length ?? 0) +
+ (props.resources?.length ?? 0) +
+ (props.pending_creates?.length ?? 0) +
+ (props.rejected_creates?.length ?? 0),
+ );
+
+ /* Moderation tabs — the active tab is mirrored into the URL hash
+ so it survives refreshes and can be linked directly. */
+ const activeResourceTab = ref('live');
+
+ /**
+ * Reads the moderation tab from the URL hash without regard to case.
+ * An empty hash or #approved selects live; unknown hashes leave the
+ * selection unchanged. Does nothing outside the browser.
+ */
+ const syncTabFromHash = () => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ const hash = window.location.hash.toLowerCase();
+
+ if (hash === '#rejected') {
+ activeResourceTab.value = 'rejected';
+ } else if (hash === '#pending') {
+ activeResourceTab.value = 'pending';
+ } else if (hash === '#live' || hash === '#approved') {
+ activeResourceTab.value = 'live';
+ } else if (hash === '') {
+ // No hash — navigating to another folder resets to Live so
+ // the visible tab can't silently disagree with the URL.
+ activeResourceTab.value = 'live';
+ }
+ };
+
+ /** Selects a tab and replaces the browser URL hash without adding history. */
+ const handleTabChange = (tab: ResourceTab) => {
+ activeResourceTab.value = tab;
+
+ if (typeof window !== 'undefined') {
+ const hash = `#${tab}`;
+
+ if (window.location.hash !== hash) {
+ // Keep Inertia's state on the entry — a null state breaks
+ // Back-button restoration to this page.
+ history.replaceState(window.history.state, '', hash);
+ }
+ }
+ };
+
+ onMounted(() => {
+ syncTabFromHash();
+
+ window.addEventListener('hashchange', syncTabFromHash);
+
+ // Inertia client-side navigations (folder → folder) don't fire
+ // hashchange; re-sync so the tab follows the (cleared) URL.
+ const offNavigate = router.on('navigate', () => syncTabFromHash());
+
+ onUnmounted(() => offNavigate());
+ });
+
+ onUnmounted(() => {
+ window.removeEventListener('hashchange', syncTabFromHash);
+ });
+
+ /* Live resources that carry a pending or rejected change request. */
+ const liveResourcesWithPending = computed(() => {
+ return (
+ props.resources?.filter((r) =>
+ Boolean(r.pending_change_request),
+ ) ?? []
+ );
+ });
+
+ const totalPendingCount = computed(() => {
+ return (
+ (props.pending_creates?.length ?? 0) +
+ liveResourcesWithPending.value.length
+ );
+ });
+
+ const liveResourcesWithRejected = computed(() => {
+ return (
+ props.resources?.filter(
+ (r) =>
+ !r.pending_change_request &&
+ Boolean(r.latest_rejected_change_request),
+ ) ?? []
+ );
+ });
+
+ const totalRejectedCount = computed(() => {
+ return (
+ (props.rejected_creates?.length ?? 0) +
+ liveResourcesWithRejected.value.length
+ );
+ });
+
+ const liveCount = computed(() => {
+ return (props.nodes?.length ?? 0) + (props.resources?.length ?? 0);
+ });
+
+ const adminBreadcrumbs = computed(() => {
+ const items: BreadcrumbItem[] = [
+ {
+ name: 'Subjects',
+ link: '/admin/subjects',
+ },
+ {
+ name: props.subject?.name || 'Subject',
+ link: `/admin/subjects/${props.subject?.slug}/nodes`,
+ },
+ ];
+
+ if (props.breadcrumb && Array.isArray(props.breadcrumb)) {
+ let currentPath = `/admin/subjects/${props.subject?.slug}/nodes`;
+
+ for (const crumb of props.breadcrumb) {
+ currentPath += `/${crumb.slug}`;
+ items.push({
+ name: crumb.name,
+ link: currentPath,
+ });
+ }
+ }
+
+ return items;
+ });
+
+ /**
+ * Opens review with the request and its target resource, or clears
+ * the modal when the request is falsy.
+ */
+ const openPendingModal = (pending: any, resource: any) => {
+ viewingPendingModal.value = pending
+ ? { ...pending, targetResource: resource }
+ : null;
+ };
+
+ /** Dashed callout for an empty tab bucket. */
+ const TabEmptyCallout = defineComponent({
+ name: 'AdminNodeTabEmptyCallout',
+ props: { message: { type: String, required: true } },
+ setup(innerProps) {
+ return () => (
+
+ {innerProps.message}
+
+ );
+ },
+ });
+
+ /** Per-render props for ResourceRow spreads. Built as a factory so
+ * `isFrozen` is read during render, not once in setup — otherwise
+ * rows keep stale frozen state across partial page updates. */
+ const resourceRowProps = () => ({
+ isFrozen: isFrozen.value,
+ onEdit: openEditResourceModal,
+ onViewPending: openPendingModal,
+ });
+
+ return () => (
+ <>
+
+
+
+ {/* Page Header & Action Toolbar */}
+
+ (isBulkModalOpen.value = true)
+ }
+ />
+
+ {/* Items Container */}
+
+ {totalItemsCount.value > 0 ? (
+
+ {/* Tabs Header on Top */}
+
+ (isBulkRenameModalOpen.value = true)
+ }
+ />
+
+ {/* Live Tab Content */}
+ {activeResourceTab.value === 'live' ? (
+
+ {props.nodes?.map((node) => (
+
+ ))}
+
+ {props.resources?.map((resource) => (
+
+ ))}
+
+ {liveCount.value === 0 && (
+
+ )}
+
+ ) : activeResourceTab.value === 'pending' ? (
+
+ {props.pending_creates?.map(
+ (pending) => (
+
+ (viewingPendingModal.value =
+ p)
+ }
+ />
+ ),
+ )}
+
+ {liveResourcesWithPending.value.map(
+ (resource) => (
+
+ ),
+ )}
+
+ {totalPendingCount.value === 0 && (
+
+ )}
+
+ ) : (
+
+ {props.rejected_creates?.map(
+ (rejected) => (
+
+ (viewingPendingModal.value =
+ r)
+ }
+ />
+ ),
+ )}
+
+ {liveResourcesWithRejected.value.map(
+ (resource) => (
+
+ ),
+ )}
+
+ {totalRejectedCount.value === 0 && (
+
+ )}
+
+ )}
+
+ ) : (
+
+ )}
+
+
+
+ {/* Modals are outside the spaced page stack: fixed overlays
+ must not receive space-y margins (they'd shift down). */}
+
+
+ {props.parent && (
+
+ )}
+
+ (isBulkModalOpen.value = false)}
+ />
+
+ {props.parent && (
+ (isBulkRenameModalOpen.value = false)}
+ />
+ )}
+
+ {/* Pending / Rejected Resource Preview Modal */}
+ (viewingPendingModal.value = null)}
+ />
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/Node.vue b/resources/js/pages/admin/Node.vue
deleted file mode 100644
index 4d7f7cf1..00000000
--- a/resources/js/pages/admin/Node.vue
+++ /dev/null
@@ -1,424 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- (viewingPendingModal = pending
- ? { ...pending, targetResource: res }
- : null)
- "
- />
-
-
- No live items in this folder.
-
-
-
-
-
-
-
-
- (viewingPendingModal = pending
- ? { ...pending, targetResource: res }
- : null)
- "
- />
-
-
- No pending submissions awaiting review.
-
-
-
-
-
-
-
-
- (viewingPendingModal = pending
- ? { ...pending, targetResource: res }
- : null)
- "
- />
-
-
- No rejected submissions in this folder.
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/NoticeEdit.tsx b/resources/js/pages/admin/NoticeEdit.tsx
new file mode 100644
index 00000000..f0738162
--- /dev/null
+++ b/resources/js/pages/admin/NoticeEdit.tsx
@@ -0,0 +1,479 @@
+import { Head, Link, useForm } from '@inertiajs/vue3';
+import { AlertCircle, Loader2, Save, Trash2, Upload } from 'lucide-vue-next';
+import { computed, defineComponent, onUnmounted, ref, watch } from 'vue';
+import type { PropType } from 'vue';
+
+import {
+ AdminPageHeader,
+ adminInputClass,
+ adminPanelClass,
+ adminPageClass,
+ adminPrimaryBtnClass,
+ adminSecondaryBtnClass,
+ adminSectionTitleClass,
+ adminTextareaClass,
+} from '@/components/admin/ui';
+import { markAdminFormDirty } from '@/lib/adminFormGuard';
+
+interface SiteNotice {
+ title?: string | null;
+ message?: string | null;
+ image?: string | null;
+ show_button?: boolean;
+ button_title?: string | null;
+ button_link?: string | null;
+ is_active?: boolean;
+}
+
+interface NoticeFormFields {
+ title: string;
+ message: string;
+ image: File | null;
+ remove_image: boolean;
+ show_button: boolean;
+ button_title: string;
+ button_link: string;
+ is_active: boolean;
+}
+
+export default defineComponent({
+ name: 'AdminNoticeEdit',
+ props: {
+ notice: {
+ type: Object as PropType,
+ default: undefined,
+ },
+ },
+ setup(props) {
+ const form = useForm({
+ title: props.notice?.title || '',
+ message: props.notice?.message || '',
+ image: null as File | null,
+ remove_image: false,
+ show_button: props.notice?.show_button ?? false,
+ button_title: props.notice?.button_title || '',
+ button_link: props.notice?.button_link || '',
+ is_active: props.notice?.is_active ?? false,
+ });
+
+ watch(
+ () => form.isDirty,
+ (v) => markAdminFormDirty(v),
+ );
+
+ onUnmounted(() => markAdminFormDirty(false));
+
+ const imagePreview = ref(props.notice?.image ?? null);
+ const fileInput = ref(null);
+
+ const hasContent = computed(
+ () => form.title.trim() !== '' || form.message.trim() !== '',
+ );
+
+ /** Selects the first file, previews it, and cancels pending image removal. */
+ const handleFileSelect = (event: Event): void => {
+ const target = event.target as HTMLInputElement;
+
+ if (target.files && target.files[0]) {
+ const file = target.files[0];
+ form.image = file;
+ form.remove_image = false;
+ imagePreview.value = URL.createObjectURL(file);
+ }
+ };
+
+ /** Clears the image preview and input, and flags image removal on save. */
+ const handleRemoveImage = (): void => {
+ form.image = null;
+ form.remove_image = true;
+ imagePreview.value = null;
+
+ if (fileInput.value) {
+ fileInput.value.value = '';
+ }
+ };
+
+ const submitForm = (): void => {
+ form.post('/admin/notice', {
+ preserveScroll: true,
+ forceFormData: true,
+ });
+ };
+
+ const onTitleInput = (e: Event) => {
+ form.title = (e.target as HTMLInputElement).value;
+ };
+
+ const onMessageInput = (e: Event) => {
+ form.message = (e.target as HTMLTextAreaElement).value;
+ };
+
+ const onButtonTitleInput = (e: Event) => {
+ form.button_title = (e.target as HTMLInputElement).value;
+ };
+
+ const onButtonLinkInput = (e: Event) => {
+ form.button_link = (e.target as HTMLInputElement).value;
+ };
+
+ const onShowButtonChange = (e: Event) => {
+ form.show_button = (e.target as HTMLInputElement).checked;
+ };
+
+ const onIsActiveChange = (e: Event) => {
+ form.is_active = (e.target as HTMLInputElement).checked;
+ };
+
+ return () => (
+ <>
+
+
+
+ {/* Page header */}
+
+ {{
+ actions: () => (
+ <>
+
+ Cancel
+
+
+ >
+ ),
+ }}
+
+
+
+
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/NoticeEdit.vue b/resources/js/pages/admin/NoticeEdit.vue
deleted file mode 100644
index 1439120c..00000000
--- a/resources/js/pages/admin/NoticeEdit.vue
+++ /dev/null
@@ -1,376 +0,0 @@
-
-
-
-
-
-
-
-
- Site Notice
-
-
- Configure the announcement dialog shown on the home page. Only
- one notice is displayed at a time.
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/PeerSettings.tsx b/resources/js/pages/admin/PeerSettings.tsx
new file mode 100644
index 00000000..d5990032
--- /dev/null
+++ b/resources/js/pages/admin/PeerSettings.tsx
@@ -0,0 +1,303 @@
+/**
+ * AdminPeerSettings — TSX port of the former `PeerSettings.vue`.
+ *
+ * Same UI/behavior: Inertia `useForm` for the poke feature toggle, cooldown
+ * and message presets (add/remove/reset). Resolved via the explicit
+ * dual-extension (`*.vue` + `*.tsx`) page resolver in `resources/js/app.ts`.
+ */
+import { Head, useForm } from '@inertiajs/vue3';
+import { Clock, Loader2, Plus, RotateCcw, Save, Trash2 } from 'lucide-vue-next';
+import { defineComponent, onUnmounted, watch } from 'vue';
+import type { PropType } from 'vue';
+
+import {
+ AdminPageHeader,
+ adminDangerIconBtnClass,
+ adminInputClass,
+ adminPageClass,
+ adminPanelClass,
+ adminPrimaryBtnClass,
+ adminSectionTitleClass,
+ adminSecondaryBtnClass,
+} from '@/components/admin/ui';
+import { markAdminFormDirty } from '@/lib/adminFormGuard';
+
+interface PokePreset {
+ id: string;
+ icon: string;
+ message: string;
+}
+
+interface PeerSettingsProps {
+ settings: {
+ enabled: boolean;
+ cooldown_minutes: number;
+ presets: PokePreset[];
+ };
+}
+
+const defaultPresets: PokePreset[] = [
+ {
+ id: 'syllabus',
+ icon: '🔥',
+ message: "That syllabus isn't going to finish itself! Open the books!",
+ },
+ {
+ id: 'phone_down',
+ icon: '📱',
+ message: 'Close the tabs, put the phone on DND, and start studying.',
+ },
+];
+
+export default defineComponent({
+ name: 'AdminPeerSettings',
+ props: {
+ settings: {
+ type: Object as PropType,
+ required: true,
+ },
+ },
+ setup(props) {
+ const form = useForm({
+ enabled: props.settings.enabled,
+ cooldown_minutes: props.settings.cooldown_minutes,
+ presets: JSON.parse(
+ JSON.stringify(props.settings.presets || defaultPresets),
+ ) as PokePreset[],
+ });
+
+ watch(
+ () => form.isDirty,
+ (v) => markAdminFormDirty(v),
+ );
+
+ onUnmounted(() => markAdminFormDirty(false));
+
+ const addPreset = () => {
+ form.presets.push({
+ id: 'preset_' + Date.now(),
+ icon: '⚡',
+ message: 'Time to open the books and make today count!',
+ });
+ };
+
+ /** Removes the preset at a zero-based index, alerting if only one remains. */
+ const removePreset = (index: number) => {
+ if (form.presets.length <= 1) {
+ alert('You must have at least one poke preset.');
+
+ return;
+ }
+
+ form.presets.splice(index, 1);
+ };
+
+ const resetToDefaultPresets = () => {
+ if (confirm('Reset all presets to default recommendations?')) {
+ form.presets = JSON.parse(JSON.stringify(defaultPresets));
+ }
+ };
+
+ const submit = () => {
+ form.post('/admin/peers/settings', {
+ preserveScroll: true,
+ });
+ };
+
+ // Numeric field with the shared field vocabulary, fixed width + centered.
+ const cooldownInputClass =
+ 'h-9 w-24 rounded-[10px] border border-slate-200 bg-white px-2.5 text-center text-xs font-semibold text-slate-900 transition-colors outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-indigo-400';
+
+ // Emoji picker cell: same vocabulary, larger glyph.
+ const emojiInputClass =
+ 'h-9 w-14 rounded-[10px] border border-slate-200 bg-white px-1 text-center text-base text-slate-900 transition-colors outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:focus:border-indigo-400';
+
+ const toggleWrapClass =
+ 'relative inline-flex shrink-0 cursor-pointer items-center';
+ const toggleTrackClass =
+ "peer relative h-6 w-11 rounded-full bg-slate-200 after:absolute after:top-[2px] after:left-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-indigo-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2 peer-focus-visible:outline-indigo-500 dark:bg-gray-700 dark:peer-checked:bg-indigo-500";
+
+ return () => (
+ <>
+
+
+
+
+ {{
+ actions: () => (
+
+ ),
+ }}
+
+
+ {/* Single glass panel: sections separated by whitespace,
+ not cards. */}
+
+
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/PeerSettings.vue b/resources/js/pages/admin/PeerSettings.vue
deleted file mode 100644
index 5b8c7fa2..00000000
--- a/resources/js/pages/admin/PeerSettings.vue
+++ /dev/null
@@ -1,356 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Peer & Poke Settings
-
-
- Configure peer pokes, anti-spam cooldowns, and
- message presets.
-
-
-
-
-
-
-
-
-
-
-
-
-
- Settings saved successfully!
-
-
-
-
-
diff --git a/resources/js/pages/admin/Tickets.tsx b/resources/js/pages/admin/Tickets.tsx
new file mode 100644
index 00000000..95d85076
--- /dev/null
+++ b/resources/js/pages/admin/Tickets.tsx
@@ -0,0 +1,633 @@
+import { Head, Link, router, useForm } from '@inertiajs/vue3';
+import {
+ LifeBuoy,
+ MessageSquare,
+ Search,
+ Send,
+ Trash2,
+ Image as ImageIcon,
+} from 'lucide-vue-next';
+import { computed, defineComponent, onUnmounted, ref, watch } from 'vue';
+import type { PropType } from 'vue';
+
+import {
+ AdminPageHeader,
+ AdminSelect,
+ SegmentedTabs,
+ adminCardRowClass,
+ adminDangerIconBtnClass,
+ adminHoverListClass,
+ adminOptionClass,
+ adminPageClass,
+ adminPrimaryBtnClass,
+ adminSearchInputClass,
+} from '@/components/admin/ui';
+import BaseModal from '@/components/BaseModal.vue';
+import EmptyState from '@/components/EmptyState.vue';
+import Pagination from '@/components/Pagination.vue';
+import StatusBadge from '@/components/StatusBadge.vue';
+import { markAdminFormDirty } from '@/lib/adminFormGuard';
+import { formatDateTime } from '@/lib/useDate';
+
+interface UserInfo {
+ id: number;
+ name: string;
+ username: string;
+ email: string;
+ image_path?: string | null;
+}
+
+interface Ticket {
+ id: number;
+ ticket_number: string;
+ user_id: number;
+ user: UserInfo;
+ category: string;
+ subject: string;
+ message: string;
+ attachment_url?: string | null;
+ status: 'open' | 'in_progress' | 'resolved' | 'closed';
+ admin_reply?: string | null;
+ replied_by?: UserInfo | null;
+ replied_at?: string | null;
+ created_at: string;
+}
+
+interface PaginationLinkItem {
+ url: string | null;
+ label: string;
+ active: boolean;
+}
+
+interface PaginatedTickets {
+ data: Ticket[];
+ links: PaginationLinkItem[];
+ total: number;
+ current_page: number;
+ last_page: number;
+}
+
+interface TicketStats {
+ total: number;
+ open: number;
+ in_progress: number;
+ resolved: number;
+ closed: number;
+}
+
+interface TicketFilters {
+ status?: string | null;
+ category?: string | null;
+ search?: string | null;
+}
+
+export default defineComponent({
+ name: 'AdminTickets',
+ props: {
+ tickets: {
+ type: Object as PropType,
+ required: true,
+ },
+ stats: { type: Object as PropType, required: true },
+ filters: { type: Object as PropType, required: true },
+ categories: {
+ type: Object as PropType>,
+ required: true,
+ },
+ },
+ setup(props) {
+ const searchTerm = ref(props.filters.search || '');
+ const selectedStatus = ref(props.filters.status || 'all');
+ const selectedCategory = ref(props.filters.category || 'all');
+
+ // Modal State
+ const isModalOpen = ref(false);
+ const activeTicket = ref(null);
+
+ const replyForm = useForm({
+ admin_reply: '',
+ status: 'resolved',
+ });
+
+ watch(
+ () => replyForm.isDirty,
+ (v) => markAdminFormDirty(v),
+ );
+
+ onUnmounted(() => markAdminFormDirty(false));
+
+ /**
+ * Opens reply editing with the existing reply. For open tickets, the
+ * reply status defaults to resolved; other statuses are kept.
+ */
+ const openReplyModal = (ticket: Ticket) => {
+ activeTicket.value = ticket;
+ replyForm.admin_reply = ticket.admin_reply || '';
+ replyForm.status =
+ ticket.status === 'open' ? 'resolved' : ticket.status;
+ isModalOpen.value = true;
+ };
+
+ const closeReplyModal = () => {
+ isModalOpen.value = false;
+ activeTicket.value = null;
+ replyForm.reset();
+ };
+
+ /** Spread onto BaseModal: avoids the ambiguous JSX
+ * `v-model:isOpen` spelling and stays type-safe. */
+ const bindReplyOpen = (): {
+ isOpen: boolean;
+ 'onUpdate:isOpen': (value: boolean) => void;
+ } => ({
+ isOpen: isModalOpen.value && activeTicket.value !== null,
+ 'onUpdate:isOpen': (value: boolean) => {
+ if (!value) {
+ closeReplyModal();
+ } else {
+ isModalOpen.value = value;
+ }
+ },
+ });
+
+ const submitReply = () => {
+ if (!activeTicket.value) {
+ return;
+ }
+
+ replyForm.patch(`/admin/tickets/${activeTicket.value.id}`, {
+ preserveScroll: true,
+ onSuccess: () => {
+ closeReplyModal();
+ },
+ });
+ };
+
+ /**
+ * Requests a status-only update. The payload omits `admin_reply`
+ * entirely, so the server leaves `replied_by`/`replied_at` untouched
+ * and a status change can't re-attribute another admin's reply.
+ */
+ const updateStatus = (ticket: Ticket, newStatus: string) => {
+ router.patch(
+ `/admin/tickets/${ticket.id}`,
+ {
+ status: newStatus,
+ },
+ { preserveScroll: true },
+ );
+ };
+
+ const deleteTicket = (ticket: Ticket) => {
+ if (
+ confirm(
+ `Are you sure you want to permanently delete ticket ${ticket.ticket_number}?`,
+ )
+ ) {
+ router.delete(`/admin/tickets/${ticket.id}`, {
+ preserveScroll: true,
+ });
+ }
+ };
+
+ const applyFilters = () => {
+ router.get(
+ '/admin/tickets',
+ {
+ search: searchTerm.value || undefined,
+ status:
+ selectedStatus.value === 'all'
+ ? undefined
+ : selectedStatus.value,
+ category:
+ selectedCategory.value === 'all'
+ ? undefined
+ : selectedCategory.value,
+ },
+ { preserveState: true, replace: true },
+ );
+ };
+
+ const setStatusFilter = (status: string) => {
+ selectedStatus.value = status;
+ applyFilters();
+ };
+
+ const getCategoryLabel = (key: string) => {
+ return props.categories[key] || key;
+ };
+
+ const formatDate = formatDateTime;
+
+ const onSearchInput = (e: Event) => {
+ searchTerm.value = (e.target as HTMLInputElement).value;
+ };
+
+ const onSearchKeyup = (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ applyFilters();
+ }
+ };
+
+ const onReplyInput = (e: Event) => {
+ replyForm.admin_reply = (e.target as HTMLTextAreaElement).value;
+ };
+
+ const onReplyStatusChange = (e: Event) => {
+ replyForm.status = (e.target as HTMLSelectElement).value;
+ };
+
+ const onReplySubmit = (e: Event) => {
+ e.preventDefault();
+ submitReply();
+ };
+
+ const tabOptions = computed(() => [
+ { value: 'all', label: `All (${props.stats.total})` },
+ { value: 'open', label: `Open (${props.stats.open})` },
+ {
+ value: 'in_progress',
+ label: `In Progress (${props.stats.in_progress})`,
+ },
+ {
+ value: 'resolved',
+ label: `Resolved (${props.stats.resolved})`,
+ },
+ { value: 'closed', label: `Closed (${props.stats.closed})` },
+ ]);
+
+ return () => {
+ const active = activeTicket.value;
+
+ return (
+ <>
+
+
+
+ {/* Page header */}
+
+ {{
+ actions: () => (
+
+
+
+
+ ),
+ }}
+
+
+ {/* Status filter tabs */}
+
+ setStatusFilter(
+ value as typeof selectedStatus.value,
+ )
+ }
+ />
+
+ {/* Ticket list */}
+
+ {props.tickets.data.length === 0 ? (
+
+ ) : (
+
+ {props.tickets.data.map((ticket) => (
+
+
+
+
+ {ticket.ticket_number}
+
+
+
+ {getCategoryLabel(
+ ticket.category,
+ )}
+
+ {ticket.attachment_url ? (
+
+
+
+ Attachment
+
+
+ ) : null}
+
+
+ {ticket.subject}
+
+
+ {ticket.user ? (
+
+ {ticket.user.name} (
+ {ticket.user.email})
+
+ ) : (
+
+ Unknown user
+
+ )}
+
+ {' '}
+ · Created{' '}
+ {formatDate(
+ ticket.created_at,
+ )}
+
+ {ticket.replied_by ? (
+
+ {' '}
+
+ •
+ {' '}
+
+ Replied by:{' '}
+ {
+ ticket
+ .replied_by
+ .name
+ }
+
+
+ ) : null}
+
+
+
+
+ updateStatus(
+ ticket,
+ (
+ e.target as HTMLSelectElement
+ ).value,
+ )
+ }
+ title="Update ticket status"
+ toneClass="w-32"
+ >
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+ {props.tickets.links &&
+ props.tickets.links.length > 3 ? (
+
+ ) : null}
+
+
+ {/* Reply / resolution modal */}
+
+ {{
+ header: () =>
+ active ? (
+
+
+ {active.ticket_number}
+
+
+ {active.subject}
+
+
+ ) : null,
+ default: () =>
+ active ? (
+
+
+
+ {active.user?.name} (
+ {active.user?.email}) ·{' '}
+ {formatDate(
+ active.created_at,
+ )}
+
+
+ {active.message}
+
+ {active.attachment_url ? (
+
+
+
+ View attachment
+
+
+ ) : null}
+
+
+
+ ) : null,
+ footer: () =>
+ active ? (
+
+
+
+
+ ) : null,
+ }}
+
+
+ >
+ );
+ };
+ },
+});
diff --git a/resources/js/pages/admin/Tickets.vue b/resources/js/pages/admin/Tickets.vue
deleted file mode 100644
index 2cdd483d..00000000
--- a/resources/js/pages/admin/Tickets.vue
+++ /dev/null
@@ -1,633 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Support Tickets
-
-
- {{ stats.total }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ ticket.ticket_number }}
-
-
-
-
-
- {{ getCategoryLabel(ticket.category) }}
-
-
-
-
- Image
-
-
-
-
- {{ ticket.subject }}
-
-
-
-
-
-
- {{ ticket.user.name }} ({{
- ticket.user.email
- }})
-
- •
- Created:
- {{ formatDate(ticket.created_at) }}
-
- •
-
- Replied by: {{ ticket.replied_by.name }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ activeTicket.ticket_number }}
-
-
- {{ activeTicket.subject }}
-
-
-
-
-
-
-
-
-
-
- Submitter: {{ activeTicket.user?.name }} ({{
- activeTicket.user?.email
- }})
-
-
- {{ formatDate(activeTicket.created_at) }}
-
-
-
- {{ activeTicket.message }}
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/chat/Reports.tsx b/resources/js/pages/admin/chat/Reports.tsx
new file mode 100644
index 00000000..b903fb99
--- /dev/null
+++ b/resources/js/pages/admin/chat/Reports.tsx
@@ -0,0 +1,448 @@
+import { Head, Link, router } from '@inertiajs/vue3';
+import { Ban, Flag, MessageCircle, Trash2 } from 'lucide-vue-next';
+import { computed, defineComponent, ref } from 'vue';
+import type { PropType } from 'vue';
+
+import {
+ AdminPageHeader,
+ SegmentedTabs,
+ adminCardRowClass,
+ adminDangerIconBtnClass,
+ adminDangerTextBtnClass,
+ adminHoverListClass,
+ adminMutedTextBtnClass,
+ adminPageClass,
+ adminSubNavBadgeClass,
+ adminSubNavClass,
+ adminSubNavLinkClass,
+ adminSuccessTextBtnClass,
+} from '@/components/admin/ui';
+import ChatBanModal from '@/components/ChatBanModal.vue';
+import type { ChatBanUser } from '@/components/ChatBanModal.vue';
+import EmptyState from '@/components/EmptyState.vue';
+import { formatDateTime } from '@/lib/useDate';
+
+interface ReportReporter {
+ id: number;
+ name: string;
+ username: string;
+}
+
+interface ReportReportedUser {
+ id: number;
+ name: string;
+ username: string;
+ banned_until: string | null;
+ is_banned: boolean;
+}
+
+type ReportStatus = 'pending' | 'reviewed' | 'dismissed';
+type ReportFilter = 'all' | ReportStatus;
+
+interface ReportItem {
+ id: number;
+ reporter_id: number | null;
+ reporter: ReportReporter | null;
+ reported_user_id: number | null;
+ reported_user_name: string | null;
+ reported_user_username: string | null;
+ reported_user: ReportReportedUser | null;
+ message_content: string;
+ message_sent_at: string | null;
+ reason: string | null;
+ status: ReportStatus;
+ created_at: string;
+}
+
+export default defineComponent({
+ name: 'AdminChatReports',
+ props: {
+ reports: { type: Array as PropType, required: true },
+ pendingCount: { type: Number, required: true },
+ reviewedCount: { type: Number, required: true },
+ dismissedCount: { type: Number, required: true },
+ },
+ setup(props) {
+ // Filter deep-links: `?status=reviewed` (etc.) is mirrored below.
+ const statusParam = new URLSearchParams(window.location.search).get(
+ 'status',
+ ) as ReportFilter | null;
+ const allowedFilters: ReportFilter[] = [
+ 'all',
+ 'pending',
+ 'reviewed',
+ 'dismissed',
+ ];
+ const currentFilter = ref(
+ statusParam && allowedFilters.includes(statusParam)
+ ? statusParam
+ : 'pending',
+ );
+
+ const filteredReports = computed(() => {
+ if (currentFilter.value === 'all') {
+ return props.reports;
+ }
+
+ return props.reports.filter(
+ (r) => r.status === currentFilter.value,
+ );
+ });
+
+ const updateReportStatus = (
+ reportId: number,
+ status: ReportStatus,
+ ): void => {
+ router.patch(
+ `/admin/chat/reports/${reportId}/status`,
+ { status },
+ { preserveScroll: true },
+ );
+ };
+
+ const deleteReport = (reportId: number): void => {
+ if (
+ confirm(
+ 'Are you sure you want to permanently delete this report record?',
+ )
+ ) {
+ router.delete(`/admin/chat/reports/${reportId}`, {
+ preserveScroll: true,
+ });
+ }
+ };
+
+ /**
+ * Confirms and requests report deletion for the supplied status.
+ * An omitted status or 'all' requests deletion across all statuses.
+ */
+ const handleClearReports = (statusFilter?: ReportFilter): void => {
+ const isFiltered = statusFilter && statusFilter !== 'all';
+ const confirmMessage = isFiltered
+ ? `Are you sure you want to permanently delete all ${statusFilter} report records? This action cannot be undone.`
+ : 'Are you sure you want to permanently delete ALL report records? This action cannot be undone.';
+
+ if (confirm(confirmMessage)) {
+ router.delete('/admin/chat/reports/clear', {
+ data: isFiltered ? { status: statusFilter } : {},
+ preserveScroll: true,
+ });
+ }
+ };
+
+ const isBanModalOpen = ref(false);
+ const selectedUser = ref(null);
+
+ /** Opens ban controls for the reported user; does nothing without a user ID. */
+ const openBanModal = (report: ReportItem): void => {
+ if (!report.reported_user_id) {
+ return;
+ }
+
+ selectedUser.value = {
+ id: report.reported_user_id,
+ name:
+ report.reported_user?.name ||
+ report.reported_user_name ||
+ 'User',
+ username:
+ report.reported_user?.username ||
+ report.reported_user_username ||
+ null,
+ banned_until: report.reported_user?.banned_until || null,
+ is_banned: report.reported_user?.is_banned ?? false,
+ };
+
+ isBanModalOpen.value = true;
+ };
+
+ const closeBanModal = (): void => {
+ isBanModalOpen.value = false;
+ };
+
+ const formatDate = formatDateTime;
+
+ const getAuthorName = (report: ReportItem): string =>
+ report.reported_user_name ||
+ report.reported_user?.name ||
+ 'Unknown';
+
+ const getReporterName = (report: ReportItem): string =>
+ report.reporter?.name || 'Someone';
+
+ const statusDotClass = (status: ReportStatus): string => {
+ if (status === 'pending') {
+ return 'bg-rose-500';
+ }
+
+ if (status === 'reviewed') {
+ return 'bg-emerald-500';
+ }
+
+ return 'bg-slate-300 dark:bg-gray-600';
+ };
+
+ const tabOptions = computed(() => [
+ { value: 'all', label: `All (${props.reports.length})` },
+ { value: 'pending', label: `Pending (${props.pendingCount})` },
+ {
+ value: 'reviewed',
+ label: `Resolved (${props.reviewedCount})`,
+ },
+ {
+ value: 'dismissed',
+ label: `Dismissed (${props.dismissedCount})`,
+ },
+ ]);
+
+ return () => (
+ <>
+
+
+
+ {/* Page header */}
+
+ {{
+ actions: () =>
+ props.reports.length > 0 && (
+
+ ),
+ }}
+
+
+ {/* Chat management nav */}
+
+
+ {/* Filter tabs */}
+
{
+ currentFilter.value =
+ value as typeof currentFilter.value;
+
+ // Mirror the filter into the URL so a refreshed
+ // or shared link reopens the same view.
+ router.get(
+ '/admin/chat/reports',
+ {
+ status: currentFilter.value,
+ },
+ {
+ preserveState: true,
+ preserveScroll: true,
+ replace: true,
+ },
+ );
+ }}
+ />
+
+ {/* Reports list */}
+
+ {filteredReports.value.length === 0 ? (
+
+ ) : (
+
+ {filteredReports.value.map((report) => (
+
+
+
+
+
+ {getReporterName(report)}{' '}
+
+ reported
+ {' '}
+ {getAuthorName(report)}
+
+
+ {report.reason ||
+ 'Not specified'}
+
+
+
+
+ {report.status}
+
+
+ {' '}
+ · Reported{' '}
+ {formatDate(
+ report.created_at,
+ )}
+
+ {report.message_sent_at ? (
+
+ {' '}
+ · Sent{' '}
+ {formatDate(
+ report.message_sent_at,
+ )}
+
+ ) : null}
+ {report.reported_user
+ ?.is_banned ? (
+
+ {' '}
+ · Currently banned
+
+ ) : null}
+
+
+
+ "
+ {report.message_content}
+ "
+
+
+
+ {report.reported_user_id ? (
+
+ ) : null}
+ {report.status !==
+ 'reviewed' ? (
+
+ ) : null}
+ {report.status !==
+ 'dismissed' ? (
+
+ ) : null}
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Reusable Quick Chat Ban Modal */}
+
+
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/chat/Reports.vue b/resources/js/pages/admin/chat/Reports.vue
deleted file mode 100644
index 97b8210a..00000000
--- a/resources/js/pages/admin/chat/Reports.vue
+++ /dev/null
@@ -1,548 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Reported Chat Messages
-
-
- Review community reports, snapshot of reported messages,
- and moderate disruptive accounts.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Chat Configuration
-
-
-
-
- Reported Messages
-
- {{ pendingCount }}
-
-
-
-
-
-
-
-
-
- Pending Review
-
-
-
-
- {{ pendingCount }}
-
-
Requires moderation action
-
-
-
-
-
- Resolved
-
-
-
-
- {{ reviewedCount }}
-
-
Reviewed & resolved reports
-
-
-
-
-
- Dismissed
-
-
-
-
- {{ dismissedCount }}
-
-
Ignored or invalid reports
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ report.status }}
-
-
-
- Reported {{ formatDate(report.created_at) }}
-
-
-
- by
- {{ report.reporter.name }}
-
- (@{{ report.reporter.username }})
-
-
-
-
- Reason:
-
- {{ report.reason || 'Not specified' }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Message Author:
- {{
- report.reported_user_name ||
- report.reported_user?.name ||
- 'Unknown'
- }}
-
-
- (@{{
- report.reported_user_username ||
- report.reported_user?.username
- }})
-
-
- Currently Banned
-
-
-
-
- Sent {{ formatDate(report.message_sent_at) }}
-
-
-
-
- "{{ report.message_content }}"
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/forums/Index.tsx b/resources/js/pages/admin/forums/Index.tsx
new file mode 100644
index 00000000..c5117e0e
--- /dev/null
+++ b/resources/js/pages/admin/forums/Index.tsx
@@ -0,0 +1,529 @@
+import { Head, Link, router } from '@inertiajs/vue3';
+import {
+ Ban,
+ ExternalLink,
+ Flag,
+ HelpCircle,
+ Lock,
+ Search,
+ Settings,
+ Trash2,
+ Unlock,
+} from 'lucide-vue-next';
+import { computed, defineComponent, ref } from 'vue';
+import type { PropType } from 'vue';
+
+import {
+ AdminPageHeader,
+ AdminSelect,
+ SegmentedTabs,
+ adminDangerIconBtnClass,
+ adminIconBtnClass,
+ adminOptionClass,
+ adminPageClass,
+ adminSearchInputClass,
+ adminSubNavBadgeClass,
+ adminSubNavClass,
+ adminSubNavLinkClass,
+ adminTableHeadClass,
+ adminTableWrapClass,
+} from '@/components/admin/ui';
+import ChatBanModal from '@/components/ChatBanModal.vue';
+import type { ChatBanUser } from '@/components/ChatBanModal.vue';
+import EmptyState from '@/components/EmptyState.vue';
+import Pagination from '@/components/Pagination.vue';
+import type { PaginationLink } from '@/components/Pagination.vue';
+
+interface ForumPostUser {
+ id: number;
+ name: string;
+ username: string;
+ banned_until?: string | null;
+ is_banned?: boolean;
+}
+
+type ModerationStatus = 'approved' | 'pending' | 'flagged' | 'rejected';
+type StatusFilter = 'all' | ModerationStatus;
+
+interface ForumPostItem {
+ id: number;
+ title: string;
+ slug: string;
+ body?: string;
+ curriculum: 'hsc' | 'ssc';
+ is_answered: boolean;
+ is_locked: boolean;
+ moderation_status: ModerationStatus;
+ vote_score: number;
+ answers_count: number;
+ created_at: string;
+ user?: ForumPostUser | null;
+ subject?: {
+ id: number;
+ name: string;
+ course: string;
+ } | null;
+}
+
+interface ForumsIndexPosts {
+ data: ForumPostItem[];
+ links: PaginationLink[];
+ total: number;
+ current_page: number;
+ last_page: number;
+}
+
+interface ForumsIndexFilters {
+ status?: string;
+ search?: string;
+}
+
+interface ForumsIndexStats {
+ totalPosts: number;
+ pendingCount: number;
+ flaggedCount: number;
+ rejectedCount: number;
+ approvedCount: number;
+}
+
+export default defineComponent({
+ name: 'AdminForumsIndex',
+ props: {
+ posts: { type: Object as PropType, required: true },
+ filters: {
+ type: Object as PropType,
+ required: true,
+ },
+ stats: { type: Object as PropType, required: true },
+ pendingReportsCount: {
+ type: Number as PropType,
+ default: undefined,
+ },
+ },
+ setup(props) {
+ const searchInput = ref(props.filters.search || '');
+ const selectedStatus = ref(props.filters.status || 'all');
+
+ const setStatusFilter = (status: StatusFilter | string): void => {
+ selectedStatus.value = status;
+ applyFilters();
+ };
+
+ const applyFilters = (): void => {
+ router.get(
+ '/admin/forums',
+ {
+ search: searchInput.value || undefined,
+ status:
+ selectedStatus.value === 'all'
+ ? undefined
+ : selectedStatus.value,
+ },
+ { preserveState: true, replace: true },
+ );
+ };
+
+ const handleSearch = (): void => {
+ applyFilters();
+ };
+
+ const onSearchInput = (e: Event) => {
+ searchInput.value = (e.target as HTMLInputElement).value;
+ };
+
+ const onSearchKeyup = (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleSearch();
+ }
+ };
+
+ const toggleLock = (post: ForumPostItem): void => {
+ router.patch(
+ `/admin/forums/${post.id}/lock`,
+ {},
+ { preserveScroll: true },
+ );
+ };
+
+ const updateStatus = (
+ post: ForumPostItem,
+ status: ModerationStatus,
+ ): void => {
+ router.patch(
+ `/admin/forums/${post.id}/status`,
+ { moderation_status: status },
+ { preserveScroll: true },
+ );
+ };
+
+ const deletePost = (post: ForumPostItem): void => {
+ if (
+ confirm(
+ `Delete "${post.title}" permanently? This cannot be undone.`,
+ )
+ ) {
+ router.delete(`/admin/forums/${post.id}`, {
+ preserveScroll: true,
+ });
+ }
+ };
+
+ const handleStatusChange = (
+ post: ForumPostItem,
+ event: Event,
+ ): void => {
+ const target = event.target as HTMLSelectElement | null;
+
+ if (!target) {
+ return;
+ }
+
+ updateStatus(post, target.value as ModerationStatus);
+ };
+
+ const isBanModalOpen = ref(false);
+ const selectedUser = ref(null);
+
+ const openBanModal = (post: ForumPostItem): void => {
+ if (!post.user) {
+ return;
+ }
+
+ selectedUser.value = {
+ id: post.user.id,
+ name: post.user.name,
+ username: post.user.username,
+ banned_until: post.user.banned_until || null,
+ is_banned: post.user.is_banned ?? false,
+ };
+
+ isBanModalOpen.value = true;
+ };
+
+ const onStatusSelectChange = (post: ForumPostItem) => (e: Event) => {
+ handleStatusChange(post, e);
+ };
+
+ const statusTabs = computed<{ value: StatusFilter; label: string }[]>(
+ () => [
+ {
+ value: 'all',
+ label: `All (${props.stats.totalPosts})`,
+ },
+ {
+ value: 'pending',
+ label: `Pending (${props.stats.pendingCount})`,
+ },
+ {
+ value: 'flagged',
+ label: `Flagged (${props.stats.flaggedCount})`,
+ },
+ {
+ value: 'rejected',
+ label: `Rejected (${props.stats.rejectedCount})`,
+ },
+ {
+ value: 'approved',
+ label: `Live (${props.stats.approvedCount})`,
+ },
+ ],
+ );
+
+ // Status tint for the AdminSelect: important modifiers so the tint
+ // reliably wins over the shared ring/base colors.
+ const statusToneClass = (status: ModerationStatus): string =>
+ status === 'approved'
+ ? '!bg-emerald-50 !text-emerald-700 !ring-emerald-300/70 dark:!bg-emerald-500/15 dark:!text-emerald-300 dark:!ring-emerald-500/30'
+ : status === 'pending'
+ ? '!bg-amber-50 !text-amber-800 !ring-amber-300/70 dark:!bg-amber-500/15 dark:!text-amber-300 dark:!ring-amber-500/30'
+ : '!bg-rose-50 !text-rose-700 !ring-rose-300/70 dark:!bg-rose-500/15 dark:!text-rose-300 dark:!ring-rose-500/30';
+
+ return () => (
+ <>
+
+
+
+ {/* Page header */}
+
0
+ ? `${props.stats.pendingCount} pending`
+ : undefined
+ }
+ countTone="amber"
+ description="Review community questions, moderate discussions, and manage reports."
+ />
+
+ {/* Section switcher (same row position on Discussions / Reports / Settings) */}
+
+
+ {/* Status segmented control + search */}
+
+
+ {/* Moderation table */}
+
+ {props.posts.data.length === 0 ? (
+
+ ) : (
+
+
+
+
+ |
+ Discussion
+ |
+
+ Author
+ |
+
+ Moderation Status
+ |
+
+ Actions
+ |
+
+
+
+ {props.posts.data.map((post) => (
+
+
+
+
+ {post.body && (
+
+ {post.body}
+
+ )}
+
+ {post.user
+ ? `${post.user.name} (@${post.user.username})`
+ : 'Anonymous'}
+
+
+ |
+
+
+ {post.user ? (
+
+
+ {post.user.name}
+
+
+ @
+ {
+ post.user
+ .username
+ }
+
+
+ ) : (
+
+ Anonymous
+
+ )}
+ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+ {post.user && (
+
+ )}
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+ {props.posts.links && props.posts.links.length > 3 && (
+
+ )}
+
+
+ {
+ isBanModalOpen.value = false;
+ }}
+ />
+
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/forums/Index.vue b/resources/js/pages/admin/forums/Index.vue
deleted file mode 100644
index 11a3f2b7..00000000
--- a/resources/js/pages/admin/forums/Index.vue
+++ /dev/null
@@ -1,482 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Forum Management
-
-
- Review community questions, moderate discussions, and manage
- reports.
-
-
-
-
-
-
- Discussions
-
-
-
-
- Reports
-
- {{ pendingReportsCount }}
-
-
-
-
-
- Settings
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
- Discussion
- |
- Author |
-
- Moderation Status
- |
-
- Actions
- |
-
-
-
-
-
-
-
-
-
-
-
- {{ post.body }}
-
-
- |
-
-
-
-
- {{ post.user.name }}
- @{{ post.user.username }}
-
- Anonymous
- |
-
-
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
- |
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/forums/Reports.tsx b/resources/js/pages/admin/forums/Reports.tsx
new file mode 100644
index 00000000..4d9cf5ff
--- /dev/null
+++ b/resources/js/pages/admin/forums/Reports.tsx
@@ -0,0 +1,486 @@
+import { Head, Link, router } from '@inertiajs/vue3';
+import { Ban, ExternalLink, Flag, Settings, Trash2 } from 'lucide-vue-next';
+import { computed, defineComponent, ref } from 'vue';
+import type { PropType } from 'vue';
+
+import {
+ AdminPageHeader,
+ SegmentedTabs,
+ adminCardRowClass,
+ adminDangerIconBtnClass,
+ adminDangerTextBtnClass,
+ adminHoverListClass,
+ adminMutedTextBtnClass,
+ adminPageClass,
+ adminSubNavBadgeClass,
+ adminSubNavClass,
+ adminSubNavLinkClass,
+ adminSuccessTextBtnClass,
+} from '@/components/admin/ui';
+import ChatBanModal from '@/components/ChatBanModal.vue';
+import type { ChatBanUser } from '@/components/ChatBanModal.vue';
+import EmptyState from '@/components/EmptyState.vue';
+import { formatDateTime } from '@/lib/useDate';
+import { usePermissions } from '@/lib/usePermissions';
+
+interface ForumReportReporter {
+ id: number;
+ name: string;
+ username: string;
+}
+
+interface ForumReportReportedUser {
+ id: number;
+ name: string;
+ username: string;
+ banned_until: string | null;
+ is_banned: boolean;
+}
+
+type ForumReportStatus = 'pending' | 'reviewed' | 'dismissed';
+type ForumReportFilter = 'all' | ForumReportStatus;
+
+interface ReportItem {
+ id: number;
+ reporter_id: number | null;
+ reporter: ForumReportReporter | null;
+ reported_user_id: number | null;
+ reported_user_name: string | null;
+ reported_user_username: string | null;
+ reported_user: ForumReportReportedUser | null;
+ reportable_type: string;
+ reportable_id: number | null;
+ post_slug?: string | null;
+ post_title?: string | null;
+ content_snapshot: string;
+ reason: string | null;
+ status: ForumReportStatus;
+ created_at: string;
+}
+
+export default defineComponent({
+ name: 'AdminForumsReports',
+ props: {
+ reports: { type: Array as PropType, required: true },
+ pendingCount: { type: Number, required: true },
+ reviewedCount: { type: Number, required: true },
+ dismissedCount: { type: Number, required: true },
+ },
+ setup(props) {
+ const { can } = usePermissions();
+
+ // Filter deep-links: `?status=reviewed` (etc.) is mirrored below.
+ const statusParam = new URLSearchParams(window.location.search).get(
+ 'status',
+ ) as ForumReportFilter | null;
+ const allowedFilters: ForumReportFilter[] = [
+ 'all',
+ 'pending',
+ 'reviewed',
+ 'dismissed',
+ ];
+ const currentFilter = ref(
+ statusParam && allowedFilters.includes(statusParam)
+ ? statusParam
+ : 'pending',
+ );
+
+ const filteredReports = computed(() => {
+ if (currentFilter.value === 'all') {
+ return props.reports;
+ }
+
+ return props.reports.filter(
+ (r) => r.status === currentFilter.value,
+ );
+ });
+
+ const updateReportStatus = (
+ reportId: number,
+ status: ForumReportStatus,
+ ): void => {
+ router.patch(
+ `/admin/forums/reports/${reportId}/status`,
+ { status },
+ { preserveScroll: true },
+ );
+ };
+
+ const deleteReport = (reportId: number): void => {
+ if (
+ confirm(
+ 'Are you sure you want to permanently delete this report record?',
+ )
+ ) {
+ router.delete(`/admin/forums/reports/${reportId}`, {
+ preserveScroll: true,
+ });
+ }
+ };
+
+ /**
+ * Confirms and requests report deletion for the supplied status.
+ * An omitted status or 'all' requests deletion across all statuses.
+ */
+ const handleClearReports = (statusFilter?: ForumReportFilter): void => {
+ const isFiltered = statusFilter && statusFilter !== 'all';
+ const confirmMessage = isFiltered
+ ? `Are you sure you want to permanently delete all ${statusFilter} forum report records?`
+ : 'Are you sure you want to permanently delete ALL forum report records?';
+
+ if (confirm(confirmMessage)) {
+ router.delete('/admin/forums/reports/clear', {
+ data: isFiltered ? { status: statusFilter } : {},
+ preserveScroll: true,
+ });
+ }
+ };
+
+ const isBanModalOpen = ref(false);
+ const selectedUser = ref(null);
+
+ /** Opens ban controls for the reported user; does nothing without a user ID. */
+ const openBanModal = (report: ReportItem): void => {
+ if (!report.reported_user_id) {
+ return;
+ }
+
+ selectedUser.value = {
+ id: report.reported_user_id,
+ name:
+ report.reported_user?.name ||
+ report.reported_user_name ||
+ 'User',
+ username:
+ report.reported_user?.username ||
+ report.reported_user_username ||
+ null,
+ banned_until: report.reported_user?.banned_until || null,
+ is_banned: report.reported_user?.is_banned ?? false,
+ };
+
+ isBanModalOpen.value = true;
+ };
+
+ const closeBanModal = (): void => {
+ isBanModalOpen.value = false;
+ };
+
+ const formatDate = formatDateTime;
+
+ const getAuthorName = (report: ReportItem): string =>
+ report.reported_user_name ||
+ report.reported_user?.name ||
+ 'Unknown';
+
+ const getReporterName = (report: ReportItem): string =>
+ report.reporter?.name || 'Someone';
+
+ const getTypeLabel = (report: ReportItem): string =>
+ report.reportable_type === 'ForumPost'
+ ? 'Forum Question'
+ : 'Forum Answer';
+
+ const statusDotClass = (status: ForumReportStatus): string => {
+ if (status === 'pending') {
+ return 'bg-rose-500';
+ }
+
+ if (status === 'reviewed') {
+ return 'bg-emerald-500';
+ }
+
+ return 'bg-slate-300 dark:bg-gray-600';
+ };
+
+ const statusPillClass = (status: ForumReportStatus): string => {
+ if (status === 'pending') {
+ return 'bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-300';
+ }
+
+ if (status === 'reviewed') {
+ return 'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300';
+ }
+
+ return 'bg-slate-100 text-slate-600 dark:bg-gray-800 dark:text-gray-300';
+ };
+
+ const tabOptions = computed(() => [
+ { value: 'all', label: `All (${props.reports.length})` },
+ { value: 'pending', label: `Pending (${props.pendingCount})` },
+ {
+ value: 'reviewed',
+ label: `Resolved (${props.reviewedCount})`,
+ },
+ {
+ value: 'dismissed',
+ label: `Dismissed (${props.dismissedCount})`,
+ },
+ ]);
+
+ return () => (
+ <>
+
+
+
+ {/* Page header */}
+
0
+ ? `${props.pendingCount} pending`
+ : undefined
+ }
+ countTone="amber"
+ description="Review reported questions and answers, then resolve or dismiss."
+ >
+ {{
+ actions: () =>
+ props.reports.length > 0 && (
+
+ ),
+ }}
+
+
+ {/* Forum management nav */}
+
+
+ {/* Filter tabs */}
+
{
+ currentFilter.value =
+ value as typeof currentFilter.value;
+
+ // Mirror the filter into the URL so a refreshed
+ // or shared link reopens the same view.
+ router.get(
+ '/admin/forums/reports',
+ {
+ status: currentFilter.value,
+ },
+ {
+ preserveState: true,
+ preserveScroll: true,
+ replace: true,
+ },
+ );
+ }}
+ />
+
+ {/* Reports list */}
+
+ {filteredReports.value.length === 0 ? (
+
+ ) : (
+
+ {filteredReports.value.map((report) => (
+
+
+
+
+
+ {report.status}
+
+
+ {getTypeLabel(report)}
+
+
+ {getReporterName(report)}{' '}
+
+ reported
+ {' '}
+ {getAuthorName(report)}
+
+
+ {report.reason ||
+ 'Flagged for moderation'}
+
+
+
+
+ Reported{' '}
+ {formatDate(
+ report.created_at,
+ )}
+
+ {report.reported_user
+ ?.is_banned ? (
+
+ {' '}
+ · Currently suspended
+
+ ) : null}
+
+
+
+ {report.reported_user_id &&
+ (can('manage chat') ||
+ can('manage forums')) ? (
+
+ ) : null}
+ {report.status !==
+ 'reviewed' ? (
+
+ ) : null}
+ {report.status !==
+ 'dismissed' ? (
+
+ ) : null}
+
+
+
+
+ ))}
+
+ )}
+
+
+ {/* Suspension Modal */}
+
+
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/forums/Reports.vue b/resources/js/pages/admin/forums/Reports.vue
deleted file mode 100644
index 1ec1b54f..00000000
--- a/resources/js/pages/admin/forums/Reports.vue
+++ /dev/null
@@ -1,474 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Reported Forum Content
-
-
- Review and act on community reports against forum questions
- and answers.
-
-
-
-
-
-
-
- Discussions
-
-
-
-
- Reports
-
- {{ pendingCount }}
-
-
-
-
-
- Settings
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ report.status }}
-
-
-
- {{
- report.reportable_type === 'ForumPost'
- ? 'Forum Question'
- : 'Forum Answer'
- }}
-
-
-
- Reported {{ formatDate(report.created_at) }}
-
-
-
- by
- {{ report.reporter.name }}
-
- (@{{ report.reporter.username }})
-
-
-
-
-
- Reason:
-
- {{
- report.reason ||
- 'Flagged for moderation'
- }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Author:
- {{
- report.reported_user_name ||
- report.reported_user?.name ||
- 'Unknown'
- }}
-
-
- (@{{
- report.reported_user_username ||
- report.reported_user?.username
- }})
-
-
- Suspended
-
-
-
-
- View Discussion
-
-
-
-
-
- {{ report.content_snapshot }}
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/forums/Settings.tsx b/resources/js/pages/admin/forums/Settings.tsx
new file mode 100644
index 00000000..0448383a
--- /dev/null
+++ b/resources/js/pages/admin/forums/Settings.tsx
@@ -0,0 +1,433 @@
+import { Head, Link, useForm } from '@inertiajs/vue3';
+import { Flag, Loader2, Save, Settings } from 'lucide-vue-next';
+import { defineComponent, onUnmounted, watch } from 'vue';
+import type { PropType } from 'vue';
+
+import {
+ AdminPageHeader,
+ adminListClass,
+ adminPanelClass,
+ adminPageClass,
+ adminSectionTitleClass,
+ adminPrimaryBtnClass,
+ adminSegmentedActiveClass,
+ adminSegmentedIdleClass,
+ adminSubNavBadgeClass,
+ adminSubNavClass,
+ adminSubNavLinkClass,
+} from '@/components/admin/ui';
+import { markAdminFormDirty } from '@/lib/adminFormGuard';
+
+type ForumApprovalMode = 'auto' | 'manual';
+
+interface ForumSettingsData {
+ approval_mode: string;
+ posting_enabled: boolean;
+ comments_enabled: boolean;
+ disabled_reason: string;
+ auto_unpublish_threshold: number;
+ profanity_filter_enabled: boolean;
+ banned_words: string;
+}
+
+interface ForumSettingsProps {
+ settings: ForumSettingsData;
+ pendingReportsCount: number;
+}
+
+interface ForumSettingsFormFields {
+ approval_mode: ForumApprovalMode;
+ posting_enabled: boolean;
+ comments_enabled: boolean;
+ disabled_reason: string;
+ auto_unpublish_threshold: number;
+ profanity_filter_enabled: boolean;
+ banned_words: string;
+}
+
+export default defineComponent({
+ name: 'AdminForumsSettings',
+ props: {
+ settings: {
+ type: Object as PropType,
+ required: true,
+ },
+ pendingReportsCount: {
+ type: Number as PropType,
+ required: true,
+ },
+ },
+ setup(props: ForumSettingsProps) {
+ const form = useForm({
+ approval_mode:
+ (props.settings.approval_mode as ForumApprovalMode) || 'auto',
+ posting_enabled: props.settings.posting_enabled,
+ comments_enabled: props.settings.comments_enabled,
+ disabled_reason: props.settings.disabled_reason,
+ auto_unpublish_threshold: props.settings.auto_unpublish_threshold,
+ profanity_filter_enabled: props.settings.profanity_filter_enabled,
+ banned_words: props.settings.banned_words,
+ });
+
+ watch(
+ () => form.isDirty,
+ (v) => markAdminFormDirty(v),
+ );
+
+ onUnmounted(() => markAdminFormDirty(false));
+
+ const submitSettings = (e: Event) => {
+ e.preventDefault();
+ form.post('/admin/forums/settings', {
+ preserveScroll: true,
+ });
+ };
+
+ const onApprovalModeChange = (mode: ForumApprovalMode) => () => {
+ form.approval_mode = mode;
+ };
+
+ const onPostingEnabledChange = (e: Event) => {
+ form.posting_enabled = (e.target as HTMLInputElement).checked;
+ };
+
+ const onCommentsEnabledChange = (e: Event) => {
+ form.comments_enabled = (e.target as HTMLInputElement).checked;
+ };
+
+ const onDisabledReasonInput = (e: Event) => {
+ form.disabled_reason = (e.target as HTMLInputElement).value;
+ };
+
+ const onThresholdInput = (e: Event) => {
+ form.auto_unpublish_threshold = Number(
+ (e.target as HTMLInputElement).value,
+ );
+ };
+
+ const onProfanityFilterChange = (e: Event) => {
+ form.profanity_filter_enabled = (
+ e.target as HTMLInputElement
+ ).checked;
+ };
+
+ const onBannedWordsInput = (e: Event) => {
+ form.banned_words = (e.target as HTMLTextAreaElement).value;
+ };
+
+ const saveButtonContent = () =>
+ form.processing ? (
+ <>
+
+ Saving...
+ >
+ ) : (
+ <>
+
+ Save Settings
+ >
+ );
+
+ return () => (
+ <>
+
+
+ {/* Header + switcher stay full-width like Discussions/Reports;
+ only the form narrows for readability. */}
+
+ {/* Page header with Save action */}
+
0
+ ? `${props.pendingReportsCount} pending`
+ : undefined
+ }
+ countTone="amber"
+ description="Configure question approval mode, availability, and moderation triggers."
+ >
+ {{
+ actions: () => (
+
+ ),
+ }}
+
+
+ {/* Slim text tabs with active underline */}
+
+
+
+
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/forums/Settings.vue b/resources/js/pages/admin/forums/Settings.vue
deleted file mode 100644
index c8bae4a9..00000000
--- a/resources/js/pages/admin/forums/Settings.vue
+++ /dev/null
@@ -1,330 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Forum Settings
-
-
- Configure question approval mode, availability, and
- moderation triggers.
-
-
-
-
-
-
- Discussions
-
-
-
-
- Reports
-
- {{ pendingReportsCount }}
-
-
-
-
-
- Settings
-
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/moderation/Resources.vue b/resources/js/pages/admin/moderation/Resources.vue
index 402e75b5..d9a61774 100644
--- a/resources/js/pages/admin/moderation/Resources.vue
+++ b/resources/js/pages/admin/moderation/Resources.vue
@@ -537,7 +537,7 @@ const handleReject = () => {
Pending
Approved
Rejected
{{ getChangedFields(req).length }}
changed
diff --git a/resources/js/pages/admin/users/CreateOrEdit.tsx b/resources/js/pages/admin/users/CreateOrEdit.tsx
new file mode 100644
index 00000000..fb1bf885
--- /dev/null
+++ b/resources/js/pages/admin/users/CreateOrEdit.tsx
@@ -0,0 +1,759 @@
+/**
+ * AdminUsersCreateOrEdit — TSX port of the former `CreateOrEdit.vue`.
+ *
+ * Same UI/behavior as the SFC: Inertia `useForm` (create POST vs edit
+ * PATCH), editor-permissions section, avatar file input wired via
+ * `handleAvatarSelect`. Resolved via the explicit dual-extension
+ * (`*.vue` + `*.tsx`) page resolver in `resources/js/app.ts`.
+ */
+import { Head, Link, useForm } from '@inertiajs/vue3';
+import { BadgeCheck, Loader2, Save, Sparkles, UserPlus } from 'lucide-vue-next';
+import { defineComponent, onUnmounted, ref, watch } from 'vue';
+import type { PropType } from 'vue';
+
+import {
+ AdminPageHeader,
+ adminInputClass,
+ adminNativePopupClass,
+ adminOptionClass,
+ adminPageClass,
+ adminPanelClass,
+ adminPrimaryBtnClass,
+ adminSecondaryBtnClass,
+ adminSectionTitleClass,
+ adminTextareaClass,
+} from '@/components/admin/ui';
+import { markAdminFormDirty } from '@/lib/adminFormGuard';
+
+interface Role {
+ id?: number;
+ name: string;
+}
+
+interface PermissionItem {
+ id?: number | string;
+ name: string;
+}
+
+interface User {
+ id: number;
+ name?: string | null;
+ username?: string | null;
+ email?: string | null;
+ is_verified?: boolean | null;
+ priority?: number | null;
+ image_url?: string | null;
+ about?: string | null;
+ title?: string | null;
+ institution?: string | null;
+ facebook?: string | null;
+ github?: string | null;
+ instagram?: string | null;
+ roles?: Role[];
+ permissions?: PermissionItem[];
+}
+
+export default defineComponent({
+ name: 'AdminUsersCreateOrEdit',
+ props: {
+ user: { type: Object as PropType, default: undefined },
+ permissions: {
+ type: Array as PropType,
+ default: undefined,
+ },
+ },
+ setup(props) {
+ const availablePermissions = props.permissions ?? [];
+
+ const roles = [
+ { value: '', label: 'Student (No Role)' },
+ { value: 'admin', label: 'Admin' },
+ { value: 'editor', label: 'Editor' },
+ ];
+
+ const form = useForm({
+ _method: props.user ? 'PATCH' : 'POST',
+ name: props.user?.name || '',
+ username: props.user?.username || '',
+ email: props.user?.email || '',
+ is_verified: Boolean(props.user?.is_verified ?? false),
+ priority: props.user?.priority ?? 0,
+ role: props.user?.roles?.[0]?.name || '',
+ permissions: props.user?.permissions?.map((p) => p.name) || [
+ 'view admin',
+ ],
+
+ file: null as File | null,
+ about: props.user?.about || '',
+ title: props.user?.title || '',
+ institution: props.user?.institution || '',
+ facebook: props.user?.facebook || '',
+ github: props.user?.github || '',
+ instagram: props.user?.instagram || '',
+ });
+
+ watch(
+ () => form.isDirty,
+ (v) => markAdminFormDirty(v),
+ );
+
+ onUnmounted(() => markAdminFormDirty(false));
+
+ watch(
+ () => form.role,
+ (newRole) => {
+ if (newRole !== 'editor') {
+ form.permissions = [];
+ } else if (!form.permissions.includes('view admin')) {
+ form.permissions.push('view admin');
+ }
+ },
+ );
+
+ const avatarPreview = ref(null);
+
+ // Replacement selections revoke eagerly in `handleAvatarSelect`;
+ // this covers navigating away with a preview still active.
+ onUnmounted(() => {
+ if (avatarPreview.value) {
+ URL.revokeObjectURL(avatarPreview.value);
+ avatarPreview.value = null;
+ }
+ });
+
+ /** Selects the first avatar file and revokes any previous preview URL. */
+ const handleAvatarSelect = (event: Event) => {
+ const target = event.target as HTMLInputElement;
+
+ if (target.files && target.files[0]) {
+ form.file = target.files[0];
+
+ if (avatarPreview.value) {
+ URL.revokeObjectURL(avatarPreview.value);
+ }
+
+ avatarPreview.value = URL.createObjectURL(target.files[0]);
+ }
+ };
+
+ /** Posts the user form, using its _method field for updates to an existing user. */
+ const submitForm = () => {
+ if (props.user) {
+ form.post(`/admin/users/${props.user.id}`, {
+ preserveScroll: true,
+ });
+ } else {
+ form.post('/admin/users', {
+ preserveScroll: true,
+ });
+ }
+ };
+
+ const onSubmit = (e: Event) => {
+ e.preventDefault();
+ submitForm();
+ };
+
+ const onPermissionToggle = (
+ permissionName: string,
+ checked: boolean,
+ ) => {
+ if (checked) {
+ if (!form.permissions.includes(permissionName)) {
+ form.permissions.push(permissionName);
+ }
+ } else {
+ form.permissions = form.permissions.filter(
+ (p) => p !== permissionName,
+ );
+ }
+ };
+
+ const avatarSrc = () =>
+ avatarPreview.value || props.user?.image_url || null;
+
+ const avatarInitial = () =>
+ (props.user?.name || form.name || 'U').charAt(0).toUpperCase();
+
+ const labelClass =
+ 'mb-1 block text-xs font-semibold text-slate-700 dark:text-gray-300';
+ const errorClass = 'mt-1 text-xs text-rose-600 dark:text-rose-400';
+ const hintClass = 'mt-1 text-[11px] text-slate-400 dark:text-gray-500';
+
+ const inputClass = (hasError: unknown) => [
+ adminInputClass,
+ hasError
+ ? '!border-rose-500 focus:!border-rose-500 focus:!ring-rose-500/30'
+ : '',
+ ];
+
+ const textareaClass = (hasError: unknown) => [
+ adminTextareaClass,
+ hasError
+ ? '!border-rose-500 focus:!border-rose-500 focus:!ring-rose-500/30'
+ : '',
+ ];
+
+ return () => (
+ <>
+
+
+
+ {/* Page header */}
+
+ {{
+ actions: () => (
+ <>
+
+ Cancel
+
+
+ >
+ ),
+ }}
+
+
+
+
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/users/CreateOrEdit.vue b/resources/js/pages/admin/users/CreateOrEdit.vue
deleted file mode 100644
index ed7dc9ea..00000000
--- a/resources/js/pages/admin/users/CreateOrEdit.vue
+++ /dev/null
@@ -1,651 +0,0 @@
-
-
-
-
-
-
-
-
-
- {{ props.user ? 'Edit' : 'Create New' }} User
-
-
- {{
- props.user
- ? 'Update team member details and system access rules.'
- : 'Add a new team member and assign their system access role.'
- }}
-
-
-
- ← Back
-
-
-
-
-
-
diff --git a/resources/js/pages/admin/users/Index.tsx b/resources/js/pages/admin/users/Index.tsx
new file mode 100644
index 00000000..daff03a8
--- /dev/null
+++ b/resources/js/pages/admin/users/Index.tsx
@@ -0,0 +1,288 @@
+/**
+ * AdminUsersIndex — TSX proof-of-concept for "TypeScript TypeScript" admin pages.
+ *
+ * Same UI/behavior as the former `Index.vue` (flat, decardified), rewritten as
+ * a `.tsx` `defineComponent` render function. Resolved via the explicit
+ * dual-extension (`*.vue` + `*.tsx`) page resolver in `resources/js/app.ts`.
+ */
+import { Head, Link, router } from '@inertiajs/vue3';
+import { Plus, Search, ShieldCheck, Users, X } from 'lucide-vue-next';
+import { computed, defineComponent, ref } from 'vue';
+import type { PropType } from 'vue';
+
+import {
+ AdminPageHeader,
+ SegmentedTabs,
+ adminHoverListClass,
+ adminPageClass,
+ adminPrimaryBtnClass,
+ adminSearchInputClass,
+} from '@/components/admin/ui';
+import UserRow from '@/components/admin/UserRow.vue';
+import EmptyState from '@/components/EmptyState.vue';
+import Pagination from '@/components/Pagination.vue';
+import { usePermissions } from '@/lib/usePermissions';
+
+interface AdminUser {
+ id: number;
+ name: string;
+ username: string;
+ email: string;
+ image_url?: string | null;
+ is_verified?: boolean | null;
+ banned_until?: string | null;
+ roles?: { id?: number; name: string }[];
+}
+
+interface PaginationLinkItem {
+ url: string | null;
+ label: string;
+ active: boolean;
+}
+
+interface PaginatedUsers {
+ data: AdminUser[];
+ total: number;
+ current_page: number;
+ last_page: number;
+ per_page: number;
+ from: number | null;
+ to: number | null;
+ links: PaginationLinkItem[];
+}
+
+interface UsersFilters {
+ q?: string;
+ role?: string;
+}
+
+interface UsersCounts {
+ all?: number;
+ staff?: number;
+}
+
+export default defineComponent({
+ name: 'AdminUsersIndex',
+ props: {
+ users: { type: Object as PropType, required: true },
+ filters: { type: Object as PropType, default: undefined },
+ counts: { type: Object as PropType, default: undefined },
+ },
+ setup(props) {
+ const { can } = usePermissions();
+
+ const selectedRole = ref(props.filters?.role || 'all');
+ const searchQuery = ref(
+ props.filters?.q ||
+ (typeof window !== 'undefined'
+ ? new URLSearchParams(window.location.search).get('q') || ''
+ : ''),
+ );
+
+ /**
+ * Optionally changes the role, then visits the user list with a trimmed
+ * search and any non-all role. Replaces history while preserving state
+ * and scroll; an omitted role keeps the current selection.
+ */
+ const applyFilters = (role?: string) => {
+ if (role !== undefined) {
+ selectedRole.value = role;
+ }
+
+ const params: Record = {};
+
+ if (searchQuery.value.trim()) {
+ params.q = searchQuery.value.trim();
+ }
+
+ if (selectedRole.value && selectedRole.value !== 'all') {
+ params.role = selectedRole.value;
+ }
+
+ router.get('/admin/users', params, {
+ preserveState: true,
+ preserveScroll: true,
+ replace: true,
+ });
+ };
+
+ const handleSearch = () => {
+ applyFilters();
+ };
+
+ const clearSearch = () => {
+ searchQuery.value = '';
+ applyFilters();
+ };
+
+ const onSearchInput = (e: Event) => {
+ searchQuery.value = (e.target as HTMLInputElement).value;
+ };
+
+ const onSearchKeyup = (e: KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ handleSearch();
+ }
+ };
+
+ // Derived (not a snapshot): `applyFilters` preserves this component
+ // while the server swaps `props.users`, so this must recompute.
+ const hasUsers = computed(
+ () => props.users.data && props.users.data.length > 0,
+ );
+
+ const tabOptions = computed(() => [
+ {
+ value: 'all',
+ label: 'All',
+ icon: Users,
+ badge: props.counts?.all,
+ },
+ {
+ value: 'staff',
+ label: 'Staff Only',
+ icon: ShieldCheck,
+ badge: props.counts?.staff,
+ },
+ ]);
+
+ return () => (
+ <>
+
+
+
+ {/* Page header */}
+
+ {{
+ actions: () => (
+
+ {/* Search Input Bar */}
+
+
+
+ {searchQuery.value && (
+
+ )}
+
+
+ {can('create users') && (
+
+
+
Create User
+
+ )}
+
+ ),
+ }}
+
+
+ {/* Role Filter Tabs (All / Staff) */}
+
applyFilters(value)}
+ />
+
+ {/* Users List */}
+
+ {hasUsers.value ? (
+
+ {props.users.data.map((user) => (
+
+ ))}
+
+ ) : searchQuery.value ? (
+
+ {{
+ default: () => (
+
+ ),
+ }}
+
+ ) : selectedRole.value === 'staff' ? (
+
+ {{
+ default: () => (
+
+ ),
+ }}
+
+ ) : (
+
+ )}
+
+ {/* Pagination Bar */}
+ {props.users.links && props.users.links.length > 3 && (
+
+ )}
+
+
+ >
+ );
+ },
+});
diff --git a/resources/js/pages/admin/users/Index.vue b/resources/js/pages/admin/users/Index.vue
deleted file mode 100644
index 8bcb6d1a..00000000
--- a/resources/js/pages/admin/users/Index.vue
+++ /dev/null
@@ -1,279 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- Manage Users
-
-
-
- {{ users.total ?? users.data?.length ?? 0 }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php
index 0e1b3c52..e2b918a1 100644
--- a/resources/views/app.blade.php
+++ b/resources/views/app.blade.php
@@ -85,6 +85,16 @@
$pageComponent = $page['component'] ?? '';
$props = $page['props'] ?? [];
+ // Inertia page entries are preloaded via @vite below. Admin pages are
+ // being migrated from `.vue` SFCs to `.tsx` (see the dual-extension
+ // resolver in `resources/js/app.ts`), so resolve whichever exists —
+ // hardcoding `.vue` 500s on migrated pages in production builds.
+ $pageEntry = "resources/js/pages/{$pageComponent}.vue";
+
+ if (!is_file(resource_path("js/pages/{$pageComponent}.vue"))) {
+ $pageEntry = "resources/js/pages/{$pageComponent}.tsx";
+ }
+
$s3Url = rtrim(config('filesystems.disks.s3.url') ?: env('AWS_URL', 'https://cdn.hscstack.site'), '/');
$defaultOgImage = $s3Url ? "{$s3Url}/images/og.png" : url('/images/og.png');
@@ -412,7 +422,7 @@
@endif
- @vite(['resources/css/app.css', 'resources/js/app.ts', "resources/js/pages/{$page['component']}.vue"])
+ @vite(['resources/css/app.css', 'resources/js/app.ts', $pageEntry])
{{ $metaTitle ?? config('app.name', 'HSCStack') }}