import { useCallback, useEffect, useMemo, useState } from 'react'; import { ArrowDownIcon, ArrowUpIcon, CornerDownRightIcon, DownloadIcon, Trash2Icon, } from 'lucide-react'; import { toast } from 'sonner'; import { SeoPage, SeoPageHeader } from '../../components/seo/seo-page'; import { ListError, SkeletonRows } from '../../components/seo/list-state'; import { ModuleOffNotice } from '../../components/seo/module-off-notice'; import { Badge } from '../../components/ui/badge'; import { Button } from '../../components/ui/button'; import { Checkbox } from '../../components/ui/checkbox'; import ConfirmDialog from '../../components/ui/confirm-dialog'; import { Input } from '../../components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../../components/ui/table'; import { nextSort, type ListSort } from '../../lib/seo/sort'; import type { ISeoNotFoundEntry, ISeoNotFoundList, ISeoRedirectInput, } from '../../service/seo/seo.interface'; import { bulkSeoNotFound, exportSeoNotFound, fetchSeoNotFound, } from '../../service/seo/seo.service'; import { RedirectDialog } from './SeoRedirects'; import { PAGES } from './settings.copy'; const PER_PAGE = 20; type OrderBy = 'accessed_at' | 'times_accessed' | 'uri'; const download = (name: string, text: string): void => { const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' })); const link = document.createElement('a'); link.href = url; link.download = name; link.click(); URL.revokeObjectURL(url); }; const when = (value: string): string => value ? new Date(value).toLocaleString() : ''; /** The 404 log: what was asked for, how often, from where, and a button to redirect it. */ const SeoNotFound = (): JSX.Element => { const [list, setList] = useState(null); const [error, setError] = useState(''); const [search, setSearch] = useState(''); const [query, setQuery] = useState(''); const [sort, setSort] = useState>({ by: 'accessed_at', order: 'desc', }); const [page, setPage] = useState(1); const [selected, setSelected] = useState([]); const [redirecting, setRedirecting] = useState( null ); const [confirm, setConfirm] = useState<'clear' | number[] | null>(null); const [busy, setBusy] = useState(false); const load = useCallback(async (): Promise => { try { setList( await fetchSeoNotFound({ search: query, page, per_page: PER_PAGE, orderby: sort.by, order: sort.order, }) ); setError(''); } catch (reason) { setError((reason as Error).message); } }, [query, page, sort]); useEffect(() => { load(); }, [load]); useEffect(() => { const timer = setTimeout(() => { setQuery(search.trim()); setPage(1); }, 400); return () => clearTimeout(timer); }, [search]); const pages = useMemo( () => (list ? Math.max(1, Math.ceil(list.total / PER_PAGE)) : 1), [list] ); const advanced = list?.mode === 'advanced'; // The selection is of the rows on screen: a new page, tab or search // leaves only those, so a bulk action never reaches rows nobody sees. useEffect(() => { setSelected(current => current.filter(id => list?.items.some(item => item.id === id)) ); }, [list]); const allSelected = !!list && list.items.length > 0 && list.items.every(item => selected.includes(item.id)); const run = async ( action: 'delete' | 'clear' | 'redirected', payload: { ids?: number[]; uri?: string }, message: string ): Promise => { setBusy(true); try { await bulkSeoNotFound(action, payload); toast.success(message); setSelected([]); await load(); } catch (reason) { toast.error((reason as Error).message); } finally { setBusy(false); setConfirm(null); } }; const exportCsv = async (): Promise => { try { const { csv } = await exportSeoNotFound(); download('recomaze-404-log.csv', csv); } catch (reason) { toast.error((reason as Error).message); } }; const redirectInput = (entry: ISeoNotFoundEntry): ISeoRedirectInput => ({ sources: [ { pattern: entry.uri.replace(/[?#].*$/, ''), comparison: 'exact', ignore_case: false, }, ], url_to: '', header_code: Number(list?.default_code ?? 301), status: 'active', }); const sortButton = (key: OrderBy, label: string): JSX.Element => ( ); return ( } /> void load()}> The 404 monitor is off: nothing new is logged. The rows below are what it logged while it ran.
setSearch(event.target.value)} /> Sort by {sortButton('accessed_at', 'last seen')} ·{' '} {sortButton('times_accessed', 'hits')} ·{' '} {sortButton('uri', 'address')} {selected.length > 0 && ( )} {list && ( {list.total} of {list.count} row{list.count === 1 ? '' : 's'} {advanced ? ', one per visit' : ', one per address'} )}
{error && void load()} />} {!list && error ? null : list && list.items.length === 0 ? (
{list.count === 0 ? 'Nothing logged yet. Rows appear as soon as someone opens an address that does not exist.' : 'Nothing matches this search.'}
) : (
setSelected( checked && list ? list.items.map(item => item.id) : [] ) } /> Address Hits Last seen Referer {advanced && ( Browser )} {!list && ( )} {(list?.items ?? []).map(item => ( setSelected(current => checked ? [...current, item.id] : current.filter(id => id !== item.id) ) } /> {item.uri} {item.redirected && ( Redirected now )} {item.times_accessed} {/* Narrow, the secondary columns give way so the actions stay on screen. */} {when(item.accessed_at)} {item.referer || '—'} {advanced && ( {item.user_agent || '—'} )} {!item.redirected && ( )} ))}
)} {pages > 1 && (
Page {page} of {pages}
)}
setRedirecting(null)} onSaved={() => { if (redirecting) { run( 'redirected', { uri: redirecting.uri }, 'Redirect added; the address leaves the log' ); } }} /> !next && setConfirm(null)} title={ confirm === 'clear' ? 'Clear the whole log?' : 'Delete these rows?' } description={ confirm === 'clear' ? 'Every row goes. New 404s keep being logged.' : 'The rows go; the addresses are logged again if they are visited again.' } confirmLabel={confirm === 'clear' ? 'Clear log' : 'Delete'} tone="destructive" busy={busy} onConfirm={() => confirm === 'clear' ? run('clear', {}, 'Log cleared') : confirm && run('delete', { ids: confirm }, 'Rows deleted') } />
); }; export default SeoNotFound;