From 00cc0950c3a32a41d138859fedbe2efdba811d59 Mon Sep 17 00:00:00 2001 From: Tahsin Shahriar Date: Thu, 24 Sep 2026 22:40:00 +0600 Subject: [PATCH] feat(admin): migrate panel to TSX and unify dashboard chrome - Migrate admin pages from Vue SFCs to TypeScript TSX components and redesign. - New resources/js/components/admin/ui.tsx: AdminPageHeader, SegmentedTabs, AdminSelect, sub-nav, buttons, lists, table shell; all 18 admin pages migrated. - Fix invisible segmented active state in dark mode (gray-700 pill, no ring/glow); hover rows use spaced lists instead of divide+hover-pill outline clash. - Dark-safe native selects (color-scheme + themed options); Bulk Rename into node header; boxed moderation table; mobile header-action wrapping. - app.blade.php resolves the Inertia page entry by existing extension (.vue vs .tsx) so migrated pages load in production builds. --- resources/js/app.ts | 16 + resources/js/components/admin/ui.tsx | 328 +++++ resources/js/layouts/AdminLayout.vue | 8 +- resources/js/pages/admin/Blog.tsx | 235 ++++ resources/js/pages/admin/Blog.vue | 68 - resources/js/pages/admin/BlogCreateOrEdit.tsx | 393 ++++++ resources/js/pages/admin/BlogCreateOrEdit.vue | 242 ---- resources/js/pages/admin/ChatSettings.tsx | 1149 +++++++++++++++++ resources/js/pages/admin/ChatSettings.vue | 1069 --------------- resources/js/pages/admin/Dashboard.tsx | 338 +++++ resources/js/pages/admin/Dashboard.vue | 393 ------ resources/js/pages/admin/EmailSend.tsx | 992 ++++++++++++++ resources/js/pages/admin/EmailSend.vue | 998 -------------- resources/js/pages/admin/Index.tsx | 274 ++++ resources/js/pages/admin/Index.vue | 154 --- resources/js/pages/admin/Node.tsx | 449 +++++++ resources/js/pages/admin/Node.vue | 346 ----- resources/js/pages/admin/NoticeEdit.tsx | 467 +++++++ resources/js/pages/admin/NoticeEdit.vue | 376 ------ resources/js/pages/admin/PeerSettings.vue | 5 +- resources/js/pages/admin/Product.vue | 39 +- .../js/pages/admin/ProductCreateOrEdit.vue | 40 +- resources/js/pages/admin/Tickets.tsx | 598 +++++++++ resources/js/pages/admin/Tickets.vue | 611 --------- resources/js/pages/admin/chat/Reports.tsx | 411 ++++++ resources/js/pages/admin/chat/Reports.vue | 548 -------- resources/js/pages/admin/forums/Index.tsx | 527 ++++++++ resources/js/pages/admin/forums/Index.vue | 482 ------- resources/js/pages/admin/forums/Reports.tsx | 449 +++++++ resources/js/pages/admin/forums/Reports.vue | 474 ------- resources/js/pages/admin/forums/Settings.tsx | 422 ++++++ resources/js/pages/admin/forums/Settings.vue | 330 ----- .../js/pages/admin/users/CreateOrEdit.tsx | 708 ++++++++++ .../js/pages/admin/users/CreateOrEdit.vue | 607 --------- resources/js/pages/admin/users/Index.tsx | 282 ++++ resources/js/pages/admin/users/Index.vue | 279 ---- resources/views/app.blade.php | 12 +- 37 files changed, 8106 insertions(+), 7013 deletions(-) create mode 100644 resources/js/components/admin/ui.tsx create mode 100644 resources/js/pages/admin/Blog.tsx delete mode 100644 resources/js/pages/admin/Blog.vue create mode 100644 resources/js/pages/admin/BlogCreateOrEdit.tsx delete mode 100644 resources/js/pages/admin/BlogCreateOrEdit.vue create mode 100644 resources/js/pages/admin/ChatSettings.tsx delete mode 100644 resources/js/pages/admin/ChatSettings.vue create mode 100644 resources/js/pages/admin/Dashboard.tsx delete mode 100644 resources/js/pages/admin/Dashboard.vue create mode 100644 resources/js/pages/admin/EmailSend.tsx delete mode 100644 resources/js/pages/admin/EmailSend.vue create mode 100644 resources/js/pages/admin/Index.tsx delete mode 100644 resources/js/pages/admin/Index.vue create mode 100644 resources/js/pages/admin/Node.tsx delete mode 100644 resources/js/pages/admin/Node.vue create mode 100644 resources/js/pages/admin/NoticeEdit.tsx delete mode 100644 resources/js/pages/admin/NoticeEdit.vue create mode 100644 resources/js/pages/admin/Tickets.tsx delete mode 100644 resources/js/pages/admin/Tickets.vue create mode 100644 resources/js/pages/admin/chat/Reports.tsx delete mode 100644 resources/js/pages/admin/chat/Reports.vue create mode 100644 resources/js/pages/admin/forums/Index.tsx delete mode 100644 resources/js/pages/admin/forums/Index.vue create mode 100644 resources/js/pages/admin/forums/Reports.tsx delete mode 100644 resources/js/pages/admin/forums/Reports.vue create mode 100644 resources/js/pages/admin/forums/Settings.tsx delete mode 100644 resources/js/pages/admin/forums/Settings.vue create mode 100644 resources/js/pages/admin/users/CreateOrEdit.tsx delete mode 100644 resources/js/pages/admin/users/CreateOrEdit.vue create mode 100644 resources/js/pages/admin/users/Index.tsx delete mode 100644 resources/js/pages/admin/users/Index.vue diff --git a/resources/js/app.ts b/resources/js/app.ts index 1bd2831f..90c7cbb4 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -20,6 +20,22 @@ router.on('navigate', () => { createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), + // Dual-extension page resolver: `.vue` SFCs first, `.tsx` + // `defineComponent` pages (e.g. admin TSX proof-of-concept) as fallback. + resolve: (name) => { + const pages = import.meta.glob('./pages/**/*.{vue,tsx}') as Record< + string, + () => Promise<{ default: unknown }> + >; + const loader = + pages[`./pages/${name}.vue`] ?? pages[`./pages/${name}.tsx`]; + + if (!loader) { + throw new Error(`Page not found: ${name}`); + } + + return loader().then((module) => module.default ?? module); + }, layout: (name) => { switch (true) { case name.startsWith('admin/'): diff --git a/resources/js/components/admin/ui.tsx b/resources/js/components/admin/ui.tsx new file mode 100644 index 00000000..ad02d12b --- /dev/null +++ b/resources/js/components/admin/ui.tsx @@ -0,0 +1,328 @@ +/** + * Shared admin UI primitives. + * + * Single source of truth for the admin dashboard's repeated chrome: + * page shell, headers, buttons, icon buttons, segmented filter tabs, + * dark-safe native selects, list rows and badges. Pages should compose + * these instead of re-declaring the same 200-char class strings so a + * visual fix (e.g. dark-mode contrast) lands everywhere at once. + */ +import { defineComponent } from 'vue'; +import type { PropType } from 'vue'; + +/* ------------------------------------------------------------------ */ +/* Page shell + header */ +/* ------------------------------------------------------------------ */ + +/** Root container every admin page renders (spacing comes from here). */ +export const adminPageClass = 'space-y-6'; + +/** + * Standard page header row: title block left, actions right, hairline + * below. Keeps header height/rhythm identical across pages so client + * navigation doesn't shift layout. + */ +export const adminPageHeaderClass = + 'flex flex-col gap-3 border-b border-slate-100 pb-4 sm:flex-row sm:items-center sm:justify-between dark:border-gray-800'; + +export const adminPageTitleClass = + 'truncate text-lg font-bold tracking-tight text-slate-900 dark:text-gray-100'; + +export const adminPageDescriptionClass = + 'mt-0.5 text-xs text-slate-500 dark:text-gray-400'; + +export const AdminPageHeader = defineComponent({ + name: 'AdminPageHeader', + props: { + title: { type: String, required: true }, + /** Number/count badge shown next to the title. */ + count: { + type: [Number, String] as PropType, + default: undefined, + }, + /** Small badge variant. */ + countTone: { + type: String as PropType<'indigo' | 'amber' | 'rose'>, + default: 'indigo', + }, + description: { type: String, default: '' }, + }, + setup(props, { slots }) { + const badgeClass = + props.countTone === 'amber' + ? 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400' + : props.countTone === 'rose' + ? 'bg-rose-50 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400' + : 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400'; + + return () => ( +
+
+
+

{props.title}

+ {props.count !== undefined && ( + + {props.count} + + )} +
+ {props.description && ( +

+ {props.description} +

+ )} +
+ {slots.actions && ( +
+ {slots.actions()} +
+ )} +
+ ); + }, +}); + +/* ------------------------------------------------------------------ */ +/* Buttons */ +/* ------------------------------------------------------------------ */ + +/** Primary action (indigo). */ +export const adminPrimaryBtnClass = + 'inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-xl bg-indigo-600 px-3.5 text-xs font-semibold text-white transition hover:bg-indigo-700 focus-visible:outline-2 focus-visible:outline-indigo-500 active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-600'; + +/** Secondary action (bordered). */ +export const adminSecondaryBtnClass = + 'inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3 text-xs font-semibold text-slate-700 transition hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-indigo-500 active:scale-95 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800'; + +/** Ghost icon-only action (neutral). */ +export const adminIconBtnClass = + 'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-2 focus-visible:outline-indigo-500 active:scale-95 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-300'; + +/** Ghost icon-only action (danger). */ +export const adminDangerIconBtnClass = + 'flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-slate-400 transition hover:bg-rose-50 hover:text-rose-600 focus-visible:outline-2 focus-visible:outline-indigo-500 active:scale-95 dark:text-gray-500 dark:hover:bg-rose-500/10 dark:hover:text-rose-400'; + +/* ------------------------------------------------------------------ */ +/* Small text buttons (row-level actions like Resolve / Dismiss) */ +/* ------------------------------------------------------------------ */ + +const adminTextBtnBase = + 'inline-flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold transition focus-visible:outline-2 focus-visible:outline-indigo-500 active:scale-95'; + +/** Small danger text button (Suspend, Clear all). */ +export const adminDangerTextBtnClass = `${adminTextBtnBase} text-rose-600 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-500/10`; + +/** Small success text button (Resolve). */ +export const adminSuccessTextBtnClass = `${adminTextBtnBase} text-emerald-600 hover:bg-emerald-50 dark:text-emerald-400 dark:hover:bg-emerald-500/10`; + +/** Small muted text button (Dismiss). */ +export const adminMutedTextBtnClass = `${adminTextBtnBase} text-slate-500 hover:bg-slate-100 hover:text-slate-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200`; + +/* ------------------------------------------------------------------ */ +/* Sub-navigation (Discussions / Reports / Settings switcher) */ +/* ------------------------------------------------------------------ */ + +/** Horizontal sub-nav container used under forum/chat headers. */ +export const adminSubNavClass = + 'flex max-w-full items-center gap-1 overflow-x-auto'; + +export function adminSubNavLinkClass(isActive: boolean): string[] { + return [ + 'flex shrink-0 cursor-pointer items-center gap-1.5 px-2.5 py-1.5 text-xs font-semibold transition focus-visible:outline-2 focus-visible:outline-indigo-500 active:scale-95', + isActive + ? 'text-indigo-600 underline decoration-2 underline-offset-4 dark:text-indigo-400' + : 'text-slate-500 hover:text-slate-800 dark:text-gray-400 dark:hover:text-gray-200', + ]; +} + +/** Count badge inside a sub-nav link. */ +export const adminSubNavBadgeClass = + 'rounded-full bg-rose-500 px-1.5 py-0.5 text-[10px] leading-none font-bold text-white'; + +/* ------------------------------------------------------------------ */ +/* Segmented filter tabs */ +/* ------------------------------------------------------------------ */ + +/** Pill container behind a segmented filter control. */ +export const adminSegmentedClass = + 'inline-flex max-w-full flex-wrap items-center gap-0.5 self-start rounded-xl bg-slate-100 p-1 dark:bg-gray-800'; + +/** + * One segmented tab. NOTE: the active state must contrast with the + * container in both themes — `dark:bg-gray-800` on a `dark:bg-gray-800` + * container is invisible, which is why this lives here instead of being + * re-declared per page. + */ +export const adminSegmentedActiveClass = + 'bg-white text-slate-900 shadow-2xs ring-1 ring-slate-900/5 dark:bg-gray-700 dark:text-white dark:shadow-none dark:ring-0'; + +export const adminSegmentedIdleClass = + 'text-slate-500 hover:text-slate-800 dark:text-gray-400 dark:hover:text-gray-200'; + +export function adminSegmentedTabClass(isActive: boolean): string[] { + return [ + 'cursor-pointer rounded-lg px-2.5 py-1 text-xs font-semibold transition-all focus-visible:outline-2 focus-visible:outline-indigo-500 active:scale-95', + isActive ? adminSegmentedActiveClass : adminSegmentedIdleClass, + ]; +} + +export interface AdminTabOption { + value: string; + label: string; + icon?: unknown; + /** Small count pill rendered after the label. */ + badge?: string | number; +} + +export const SegmentedTabs = defineComponent({ + name: 'SegmentedTabs', + props: { + tabs: { type: Array as PropType, required: true }, + active: { type: String, required: true }, + }, + emits: ['select'], + setup(props, { emit }) { + return () => ( +
+ {props.tabs.map((tab) => { + const Icon = tab.icon as + | (new (...args: unknown[]) => unknown) + | undefined; + const isActive = props.active === tab.value; + + return ( + + ); + })} +
+ ); + }, +}); + +/* ------------------------------------------------------------------ */ +/* Native select (dark-safe) */ +/* ------------------------------------------------------------------ */ + +/** + * Base classes for native `` (e.g. full-width form selects that + * keep their own input styling) so the OS-rendered dropdown list follows + * the dark theme. Pair with `adminOptionClass` on each `