feat: refine admin workflows and deployment packaging
This commit is contained in:
@@ -1,19 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname, useSearchParams } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { clearFrontendCache, refreshSitemapCache } from './admin-cache-actions'
|
||||
|
||||
type AdminLocale = 'en' | 'zh'
|
||||
|
||||
function getValidLocale(value: string | null): AdminLocale | null {
|
||||
return value === 'en' || value === 'zh' ? value : null
|
||||
}
|
||||
|
||||
export function AdminCacheButton() {
|
||||
const router = useRouter()
|
||||
const localeMenuRef = useRef<HTMLDetailsElement>(null)
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const cacheCleared = searchParams.get('cache') === 'cleared'
|
||||
const sitemapRefreshed = searchParams.get('sitemap') === 'refreshed'
|
||||
const returnTo = pathname || '/admin'
|
||||
const localeFromParams = getValidLocale(searchParams.get('locale'))
|
||||
const [currentLocale, setCurrentLocale] = useState<AdminLocale>(localeFromParams ?? 'en')
|
||||
const basePath = pathname || '/admin'
|
||||
const cleanParams = new URLSearchParams(searchParams.toString())
|
||||
|
||||
cleanParams.delete('cache')
|
||||
cleanParams.delete('sitemap')
|
||||
|
||||
const cleanQuery = cleanParams.toString()
|
||||
const returnTo = `${basePath}${cleanQuery ? `?${cleanQuery}` : ''}`
|
||||
|
||||
useEffect(() => {
|
||||
if (localeFromParams) {
|
||||
setCurrentLocale(localeFromParams)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
async function loadLocalePreference() {
|
||||
try {
|
||||
const response = await fetch('/api/payload-preferences/locale', {
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
const preference = (await response.json()) as { value?: string }
|
||||
const nextLocale = getValidLocale(preference.value ?? null)
|
||||
|
||||
if (!cancelled && nextLocale) {
|
||||
setCurrentLocale(nextLocale)
|
||||
}
|
||||
} catch {
|
||||
// Keep the configured default locale when preferences are not available.
|
||||
}
|
||||
}
|
||||
|
||||
void loadLocalePreference()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [localeFromParams])
|
||||
|
||||
async function handleLocaleChange(nextLocale: AdminLocale) {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
|
||||
params.set('locale', nextLocale)
|
||||
params.delete('cache')
|
||||
params.delete('sitemap')
|
||||
setCurrentLocale(nextLocale)
|
||||
localeMenuRef.current?.removeAttribute('open')
|
||||
|
||||
void fetch('/api/payload-preferences/locale', {
|
||||
body: JSON.stringify({ value: nextLocale }),
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'POST',
|
||||
}).catch(() => undefined)
|
||||
|
||||
const query = params.toString()
|
||||
router.push(`${basePath}${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
if (pathname === '/admin/login') return null
|
||||
|
||||
return (
|
||||
<div className="admin-cache-header-slot" aria-live="polite">
|
||||
<details className="admin-cache-header-slot__locale-menu" ref={localeMenuRef}>
|
||||
<summary
|
||||
className="admin-cache-header-slot__button admin-cache-header-slot__button--locale"
|
||||
title={currentLocale === 'zh' ? '当前语言:简体中文' : 'Current language: English'}
|
||||
>
|
||||
<span className="admin-cache-header-slot__locale-label">语言</span>
|
||||
<span className="admin-cache-header-slot__locale-current">
|
||||
{currentLocale === 'zh' ? '中文' : 'English'}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="admin-cache-header-slot__locale-panel">
|
||||
<button
|
||||
className="admin-cache-header-slot__locale-option"
|
||||
type="button"
|
||||
disabled={currentLocale === 'zh'}
|
||||
onClick={() => handleLocaleChange('zh')}
|
||||
>
|
||||
简体中文
|
||||
</button>
|
||||
<button
|
||||
className="admin-cache-header-slot__locale-option"
|
||||
type="button"
|
||||
disabled={currentLocale === 'en'}
|
||||
onClick={() => handleLocaleChange('en')}
|
||||
>
|
||||
English
|
||||
</button>
|
||||
</div>
|
||||
</details>
|
||||
<form action={clearFrontendCache}>
|
||||
<input type="hidden" name="returnTo" value={returnTo} />
|
||||
<button
|
||||
|
||||
@@ -1,50 +1,59 @@
|
||||
import Link from 'next/link'
|
||||
import { getPayload } from 'payload'
|
||||
import { getPayload, type PayloadRequest } from 'payload'
|
||||
import configPromise from '@payload-config'
|
||||
import { canReadAdminModule, type AdminModuleKey } from '@/payload/admin-access'
|
||||
import { clearFrontendCache } from './admin-cache-actions'
|
||||
|
||||
const QUICK_LINKS = [
|
||||
{
|
||||
description: 'Manage hero modules, featured products, support panels, and homepage news surfaces.',
|
||||
href: '/admin/globals/homepage',
|
||||
module: 'siteManagement',
|
||||
title: 'Homepage',
|
||||
},
|
||||
{
|
||||
description: 'Open core product records, templates, editor-driven pages, downloads, reviews, and videos.',
|
||||
href: '/admin/collections/products',
|
||||
module: 'productCatalog',
|
||||
title: 'Products',
|
||||
},
|
||||
{
|
||||
description: 'Review firmware, manuals, online-upgrade entries, and release-note content.',
|
||||
href: '/admin/collections/downloads',
|
||||
module: 'productCatalog',
|
||||
title: 'Downloads',
|
||||
},
|
||||
{
|
||||
description: 'Maintain media asset categories, package links, and individual image, PDF, or video entries.',
|
||||
href: '/admin/collections/mediaAssetCategories',
|
||||
module: 'mediaAssetManagement',
|
||||
title: 'Media asset categories',
|
||||
},
|
||||
{
|
||||
description: 'Add and edit the downloadable media asset entries shown inside secondary categories.',
|
||||
href: '/admin/collections/mediaAssets',
|
||||
module: 'mediaAssetManagement',
|
||||
title: 'Media asset list',
|
||||
},
|
||||
{
|
||||
description: 'Keep support landing copy, FAQ content, contact instructions, and warranty policy aligned.',
|
||||
href: '/admin/globals/supportPage',
|
||||
module: 'supportCenter',
|
||||
title: 'Support',
|
||||
},
|
||||
{
|
||||
description: 'Track incoming after-sales, distributor, and OEM requests in one operations lane.',
|
||||
href: '/admin/collections/afterServiceRequests',
|
||||
module: 'operations',
|
||||
title: 'Operations',
|
||||
},
|
||||
{
|
||||
description: 'Publish news, maintain redirects, and keep site-wide branding settings consistent.',
|
||||
href: '/admin/collections/news',
|
||||
module: 'contentPublishing',
|
||||
title: 'Publishing',
|
||||
},
|
||||
] as const
|
||||
] satisfies Array<{ description: string; href: string; module: AdminModuleKey; title: string }>
|
||||
|
||||
const WORKSTREAMS = [
|
||||
'Template-backed products: keep local template assignments stable and manage hero/card media from the product record.',
|
||||
@@ -57,7 +66,7 @@ function formatCount(value: number) {
|
||||
return new Intl.NumberFormat('en-US').format(value)
|
||||
}
|
||||
|
||||
async function getOverviewCounts() {
|
||||
async function getOverviewCounts(user: PayloadRequest['user']) {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
|
||||
const [
|
||||
@@ -89,26 +98,36 @@ async function getOverviewCounts() {
|
||||
])
|
||||
|
||||
return [
|
||||
{ eyebrow: 'Catalog', href: '/admin/collections/products', title: 'Products', value: products.totalDocs },
|
||||
{ eyebrow: 'Catalog', href: '/admin/collections/downloads', title: 'Downloads', value: downloads.totalDocs },
|
||||
{ eyebrow: 'Social Proof', href: '/admin/collections/productVideos', title: 'Product Videos', value: productVideos.totalDocs },
|
||||
{ eyebrow: 'Social Proof', href: '/admin/collections/productReviews', title: 'Product Reviews', value: productReviews.totalDocs },
|
||||
{ eyebrow: 'Media Kit', href: '/admin/collections/mediaAssetCategories', title: 'Categories', value: mediaAssetCategories.totalDocs },
|
||||
{ eyebrow: 'Media Kit', href: '/admin/collections/mediaAssets', title: 'Asset Entries', value: mediaAssets.totalDocs },
|
||||
{ eyebrow: 'Publishing', href: '/admin/collections/news', title: 'News', value: news.totalDocs },
|
||||
{ eyebrow: 'Support', href: '/admin/collections/faqs', title: 'FAQs', value: faqs.totalDocs },
|
||||
{ eyebrow: 'Sales', href: '/admin/collections/dealers', title: 'Dealers', value: dealers.totalDocs },
|
||||
{ eyebrow: 'Catalog', href: '/admin/collections/products', module: 'productCatalog', title: 'Products', value: products.totalDocs },
|
||||
{ eyebrow: 'Catalog', href: '/admin/collections/downloads', module: 'productCatalog', title: 'Downloads', value: downloads.totalDocs },
|
||||
{ eyebrow: 'Social Proof', href: '/admin/collections/productVideos', module: 'productCatalog', title: 'Product Videos', value: productVideos.totalDocs },
|
||||
{ eyebrow: 'Social Proof', href: '/admin/collections/productReviews', module: 'productCatalog', title: 'Product Reviews', value: productReviews.totalDocs },
|
||||
{ eyebrow: 'Media Kit', href: '/admin/collections/mediaAssetCategories', module: 'mediaAssetManagement', title: 'Categories', value: mediaAssetCategories.totalDocs },
|
||||
{ eyebrow: 'Media Kit', href: '/admin/collections/mediaAssets', module: 'mediaAssetManagement', title: 'Asset Entries', value: mediaAssets.totalDocs },
|
||||
{ eyebrow: 'Publishing', href: '/admin/collections/news', module: 'contentPublishing', title: 'News', value: news.totalDocs },
|
||||
{ eyebrow: 'Support', href: '/admin/collections/faqs', module: 'supportCenter', title: 'FAQs', value: faqs.totalDocs },
|
||||
{ eyebrow: 'Sales', href: '/admin/collections/dealers', module: 'salesChannels', title: 'Dealers', value: dealers.totalDocs },
|
||||
{
|
||||
eyebrow: 'Operations',
|
||||
href: '/admin/collections/afterServiceRequests',
|
||||
module: 'operations',
|
||||
title: 'Requests',
|
||||
value: afterServiceRequests.totalDocs + distributorRequests.totalDocs + oemRequests.totalDocs,
|
||||
},
|
||||
]
|
||||
].filter((item) => canReadAdminModule(user, item.module as AdminModuleKey))
|
||||
}
|
||||
|
||||
export async function AdminHome({ cacheCleared = false }: { cacheCleared?: boolean }) {
|
||||
const stats = await getOverviewCounts()
|
||||
export async function AdminHome({
|
||||
cacheCleared = false,
|
||||
user,
|
||||
}: {
|
||||
cacheCleared?: boolean
|
||||
user: PayloadRequest['user']
|
||||
}) {
|
||||
const stats = await getOverviewCounts(user)
|
||||
const quickLinks = QUICK_LINKS.filter((link) => canReadAdminModule(user, link.module))
|
||||
const canOpenProducts = canReadAdminModule(user, 'productCatalog')
|
||||
const canEditHomepage = canReadAdminModule(user, 'siteManagement')
|
||||
|
||||
return (
|
||||
<div className="studio-home">
|
||||
@@ -122,12 +141,16 @@ export async function AdminHome({ cacheCleared = false }: { cacheCleared?: boole
|
||||
</p>
|
||||
</div>
|
||||
<div className="studio-home__actions">
|
||||
<Link className="studio-home__primary" href="/admin/collections/products">
|
||||
Open products
|
||||
</Link>
|
||||
<Link className="studio-home__secondary" href="/admin/globals/homepage">
|
||||
Edit homepage
|
||||
</Link>
|
||||
{canOpenProducts ? (
|
||||
<Link className="studio-home__primary" href="/admin/collections/products">
|
||||
Open products
|
||||
</Link>
|
||||
) : null}
|
||||
{canEditHomepage ? (
|
||||
<Link className="studio-home__secondary" href="/admin/globals/homepage">
|
||||
Edit homepage
|
||||
</Link>
|
||||
) : null}
|
||||
<form action={clearFrontendCache}>
|
||||
<input type="hidden" name="returnTo" value="/admin" />
|
||||
<button className="studio-home__secondary studio-home__secondary--warm" type="submit">
|
||||
@@ -160,7 +183,7 @@ export async function AdminHome({ cacheCleared = false }: { cacheCleared?: boole
|
||||
<p>Jump into the surfaces that drive the storefront most often.</p>
|
||||
</div>
|
||||
<div className="studio-home__link-list">
|
||||
{QUICK_LINKS.map((item) => (
|
||||
{quickLinks.map((item) => (
|
||||
<Link key={item.href} className="studio-home__link-card" href={item.href}>
|
||||
<strong>{item.title}</strong>
|
||||
<span>{item.description}</span>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
type CaptchaResponse = {
|
||||
expiresAt: number
|
||||
@@ -184,9 +183,6 @@ export function AdminLoginForm() {
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<Link className="eversolo-login-form__forgot" href="/admin/forgot" prefetch={false}>
|
||||
Forgot password?
|
||||
</Link>
|
||||
<button className="btn btn--style-primary btn--size-large" disabled={submitting || loadingCaptcha} type="submit">
|
||||
{submitting ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
const storageKey = 'eversolo-admin-nav-scroll-top'
|
||||
|
||||
function getNavScrollElement() {
|
||||
return document.querySelector<HTMLElement>('.nav__scroll')
|
||||
}
|
||||
|
||||
function getSavedScrollTop() {
|
||||
const saved = window.sessionStorage.getItem(storageKey)
|
||||
if (!saved) return 0
|
||||
|
||||
const parsed = Number.parseInt(saved, 10)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function saveNavScrollTop() {
|
||||
const navScroll = getNavScrollElement()
|
||||
if (!navScroll) return
|
||||
window.sessionStorage.setItem(storageKey, String(navScroll.scrollTop))
|
||||
}
|
||||
|
||||
function restoreNavScrollTop() {
|
||||
const targetScrollTop = getSavedScrollTop()
|
||||
if (targetScrollTop <= 0) return
|
||||
|
||||
let attempts = 0
|
||||
const restore = () => {
|
||||
const navScroll = getNavScrollElement()
|
||||
if (navScroll) {
|
||||
navScroll.scrollTop = targetScrollTop
|
||||
}
|
||||
|
||||
attempts += 1
|
||||
if (attempts < 12) {
|
||||
window.requestAnimationFrame(restore)
|
||||
}
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(restore)
|
||||
}
|
||||
|
||||
export function AdminNavScrollMemory() {
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
let navScroll = getNavScrollElement()
|
||||
|
||||
const handleScroll = () => saveNavScrollTop()
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const target = event.target instanceof Element ? event.target.closest('.nav a, .nav button') : null
|
||||
if (target) saveNavScrollTop()
|
||||
}
|
||||
|
||||
const bindScrollListener = () => {
|
||||
const nextNavScroll = getNavScrollElement()
|
||||
if (nextNavScroll === navScroll) return
|
||||
|
||||
navScroll?.removeEventListener('scroll', handleScroll)
|
||||
navScroll = nextNavScroll
|
||||
navScroll?.addEventListener('scroll', handleScroll, { passive: true })
|
||||
}
|
||||
|
||||
bindScrollListener()
|
||||
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||
|
||||
const observer = new MutationObserver(bindScrollListener)
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
|
||||
return () => {
|
||||
navScroll?.removeEventListener('scroll', handleScroll)
|
||||
document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
restoreNavScrollTop()
|
||||
}, [pathname])
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user