diff --git a/proto/src/app/(payload)/admin/importMap.js b/proto/src/app/(payload)/admin/importMap.js index af86423..e630098 100644 --- a/proto/src/app/(payload)/admin/importMap.js +++ b/proto/src/app/(payload)/admin/importMap.js @@ -1,6 +1,8 @@ +import { QuickSearch as QuickSearch_899db48c9ce30e524aae8643dea53f6d } from '../../../../src/components/admin/QuickSearch' import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc' /** @type import('payload').ImportMap */ export const importMap = { + "./src/components/admin/QuickSearch#QuickSearch": QuickSearch_899db48c9ce30e524aae8643dea53f6d, "@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } diff --git a/proto/src/components/admin/QuickSearch.tsx b/proto/src/components/admin/QuickSearch.tsx new file mode 100644 index 0000000..2c5f4a8 --- /dev/null +++ b/proto/src/components/admin/QuickSearch.tsx @@ -0,0 +1,280 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import Link from 'next/link' + +/** + * MITHOME-120 — teljes szöveges gyorskeresés a Payload admin tetején. + * + * WHY kliens-oldali, egyszerű megoldás és nem `@payloadcms/plugin-search`: + * az a plugin egy külön "search" collection-t tart karban hookokkal + * szinkronban — szerver-oldali index, extra karbantartási teher. Ennek a + * projektnek 5 Global + 2 kis Collection a teljes tartalma (lásd + * payload.config.ts) — ennyi dokumentumnál egyszerűbb és megbízhatóbb + * minden alkalommal frissen lekérdezni a REST API-t (a bejelentkezett admin + * session-jével, cookie-alapú auth, nincs külön hitelesítési logika itt), + * kliens-oldalon szöveges mezőkre lapítani, és substring-alapján szűrni. + * + * Regisztrálva: payload.config.ts `admin.components.header` — minden admin + * oldalon megjelenik. Az importMap.js-t a `payload generate:importmap` + * generálja újra, ha ez a fájl elmozdul/átnevezik. + */ + +type Locale = 'hu' | 'en' +const LOCALES: Locale[] = ['hu', 'en'] + +type SearchTarget = + | { type: 'global'; slug: string; label: string } + | { type: 'collection'; slug: string; label: string } + +// A projekt tényleges Globals/Collections listája (payload.config.ts) — +// szándékosan nincs dinamikusan introspektálva, mert ahhoz szerver-oldali +// config-hozzáférés kellene ebből a kliens komponensből. +const SEARCH_TARGETS: SearchTarget[] = [ + { type: 'global', slug: 'home', label: 'Home' }, + { type: 'global', slug: 'about', label: 'About' }, + { type: 'global', slug: 'services', label: 'Services' }, + { type: 'global', slug: 'contact', label: 'Contact' }, + { type: 'global', slug: 'common', label: 'Common' }, + { type: 'collection', slug: 'legal-pages', label: 'Legal Pages' }, + { type: 'collection', slug: 'partners', label: 'Partners' }, +] + +// Payload belső/rendszer mezői — nem érdekesek szöveges keresésre, és csak +// zajt jelentenének (id-k, időbélyegek, belső flag-ek). +const SKIP_KEYS = new Set([ + 'id', + '_id', + 'createdAt', + 'updatedAt', + 'globalType', + 'blockType', + '_status', + 'sizes', +]) + +type SearchEntry = { + key: string + source: string + editHref: string + locale: Locale + fieldPath: string + value: string +} + +function flatten( + value: unknown, + path: string, + out: { fieldPath: string; value: string }[] +): void { + if (value == null) return + if (typeof value === 'string') { + if (value.trim().length > 0) out.push({ fieldPath: path, value }) + return + } + if (typeof value === 'number' || typeof value === 'boolean') return + if (Array.isArray(value)) { + value.forEach((item, index) => flatten(item, `${path}[${index}]`, out)) + return + } + if (typeof value === 'object') { + for (const [key, val] of Object.entries(value as Record)) { + if (SKIP_KEYS.has(key)) continue + flatten(val, path ? `${path}.${key}` : key, out) + } + } +} + +async function fetchIndex(): Promise { + const entries: SearchEntry[] = [] + + await Promise.all( + SEARCH_TARGETS.flatMap((target) => + LOCALES.map(async (locale) => { + try { + if (target.type === 'global') { + const res = await fetch(`/api/globals/${target.slug}?locale=${locale}&depth=0`, { + credentials: 'include', + }) + if (!res.ok) return + const doc = await res.json() + const flat: { fieldPath: string; value: string }[] = [] + flatten(doc, '', flat) + for (const f of flat) { + entries.push({ + key: `${target.slug}:${locale}:${f.fieldPath}`, + source: target.label, + editHref: `/admin/globals/${target.slug}`, + locale, + fieldPath: f.fieldPath, + value: f.value, + }) + } + } else { + const res = await fetch( + `/api/${target.slug}?locale=${locale}&depth=0&limit=200`, + { credentials: 'include' } + ) + if (!res.ok) return + const { docs } = await res.json() + for (const doc of docs ?? []) { + const flat: { fieldPath: string; value: string }[] = [] + flatten(doc, '', flat) + for (const f of flat) { + entries.push({ + key: `${target.slug}:${doc.id}:${locale}:${f.fieldPath}`, + source: `${target.label} — ${doc.name ?? doc.title ?? doc.id}`, + editHref: `/admin/collections/${target.slug}/${doc.id}`, + locale, + fieldPath: f.fieldPath, + value: f.value, + }) + } + } + } + } catch { + // Egy célpont hibája (pl. időleges hálózati hiba) ne akassza meg a + // többi találatot — csendben kihagyjuk. + } + }) + ) + ) + + return entries +} + +function snippetAround(value: string, query: string, radius = 40): string { + const idx = value.toLowerCase().indexOf(query.toLowerCase()) + if (idx === -1) return value.length > 80 ? `${value.slice(0, 80)}…` : value + const start = Math.max(0, idx - radius) + const end = Math.min(value.length, idx + query.length + radius) + const prefix = start > 0 ? '…' : '' + const suffix = end < value.length ? '…' : '' + return `${prefix}${value.slice(start, end)}${suffix}` +} + +export function QuickSearch() { + const [query, setQuery] = useState('') + const [open, setOpen] = useState(false) + const [loading, setLoading] = useState(false) + // WHY state és nem ref: a keresési index közvetlenül a render kimenetét + // (a találati listát) befolyásolja, tehát a react-hooks/refs szabály + // szerint is state-nek kell lennie, nem ref-nek (ref olvasása render + // közben nem váltana ki újra-renderelést, ha közben módosulna). + const [index, setIndex] = useState(null) + // Csak azt jelzi, hogy a fetch elindult-e már — ez NEM befolyásolja a + // render kimenetét, csak elkerüli a duplikált egyidejű lekérdezést, ezért + // maradhat ref. + const fetchStartedRef = useRef(false) + const containerRef = useRef(null) + + const ensureIndex = useCallback(async () => { + if (fetchStartedRef.current) return + fetchStartedRef.current = true + setLoading(true) + try { + const entries = await fetchIndex() + setIndex(entries) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + function onClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false) + } + } + document.addEventListener('mousedown', onClickOutside) + return () => document.removeEventListener('mousedown', onClickOutside) + }, []) + + const results = + query.trim().length >= 2 && index + ? index.filter((e) => e.value.toLowerCase().includes(query.toLowerCase())).slice(0, 40) + : [] + + return ( +
+ { + setOpen(true) + void ensureIndex() + }} + onChange={(e) => { + setQuery(e.target.value) + setOpen(true) + void ensureIndex() + }} + style={{ + width: '100%', + boxSizing: 'border-box', + padding: '8px 12px', + fontSize: 14, + borderRadius: 4, + border: '1px solid var(--theme-elevation-150, #ccc)', + background: 'var(--theme-input-bg, #fff)', + color: 'var(--theme-text, #000)', + }} + /> + {open && query.trim().length >= 2 && ( +
+ {loading && !index && ( +
Tartalom betöltése…
+ )} + {index && results.length === 0 && ( +
Nincs találat.
+ )} + {results.map((r) => ( + setOpen(false)} + style={{ + display: 'block', + padding: '8px 12px', + borderBottom: '1px solid var(--theme-elevation-100, #eee)', + textDecoration: 'none', + color: 'inherit', + }} + > +
+ {r.source} · {r.fieldPath} · {r.locale} +
+
{snippetAround(r.value, query)}
+ + ))} +
+ )} +
+ ) +} + +export default QuickSearch diff --git a/proto/src/payload.config.ts b/proto/src/payload.config.ts index d8ad47c..bdbc74f 100644 --- a/proto/src/payload.config.ts +++ b/proto/src/payload.config.ts @@ -14,6 +14,9 @@ * MITHOME-110: lokalizáció bekapcsolva (hu alapértelmezett, en). A mezőnkénti * `localized: true` retrofit a Globals/LegalPages configokon külön ticket * (MITHOME-111/112) — ez a ticket csak magát a mechanizmust kapcsolja be. + * MITHOME-120: admin gyorskeresés (QuickSearch) az admin.components.header + * slotba regisztrálva — teljes szöveges keresés minden Global/Collection + * mezőjében, mindkét locale-ban. */ import path from 'path' import { fileURLToPath } from 'url' @@ -40,6 +43,14 @@ export default buildConfig({ // jelszó-politika: MITHOME-90 (src/collections/Users.ts). admin: { user: Users.slug, + components: { + // MITHOME-120: gyorskeresés minden admin oldal tetején. + // WHY 'src/...' és nem './components/...': a Payload komponens-útvonal + // az admin.importMap.baseDir-hez relatív, ami alapértelmezésben + // process.cwd() (a `generate:importmap` a proto/ mappából fut, NEM a + // payload.config.ts mappájából). + header: ['./src/components/admin/QuickSearch#QuickSearch'], + }, }, editor: lexicalEditor(),