import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { LoaderCircleIcon, type LucideIcon } from 'lucide-react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { useSeoBadges } from '../../context/seo-badges.context';
import { useQuietLauncher } from '../../lib/copilot/quiet-launcher';
import {
settingsIndex,
type SettingsEntry,
} from '../../lib/seo/settings-index';
import { classNames } from '../../lib/utils';
import { Button } from '../ui/button';
import { Tabs } from '../ui/tabs';
import { useLeaveGuard } from './leave-guard';
import {
OptionIndexProvider,
OptionSearch,
SettingsSection,
useOptionIndex,
type OptionEntry,
} from './settings-search';
export { SettingsSection };
export interface SectionItem {
key: string;
label: string;
/** What the section is for, printed under its title above the options. */
description?: string;
icon?: LucideIcon;
/** Sections that belong together, such as one per post type, sit behind a caption. */
group?: string;
}
const HIGHLIGHT_MS = 2600;
const SaveActions = ({
isDirty,
saving,
onDiscard,
}: {
isDirty: boolean;
saving: boolean;
onDiscard: () => void;
}): JSX.Element => (
{isDirty && (
)}
);
/** The option groups of one section, one under the other. */
export const SettingsPanel = ({
children,
}: {
children: ReactNode;
}): JSX.Element => (
{children}
);
/**
* A settings page: the section's title and description on top with the
* option search beside them, the options of the section, and the reset
* and save buttons under them. The sections themselves are picked from
* the sidebar, which lists them under the page with their open counts,
* so the panel carries no second menu of its own. A bar with the save
* button follows the page while something is unsaved and the buttons
* under the options are out of view.
*/
export const SettingsShell = ({
sections,
section,
onSectionChange,
isDirty,
saving,
onDiscard,
onReset,
ownSave = false,
pending = [],
children,
}: {
sections: SectionItem[];
section: string;
onSectionChange: (section: string) => void;
isDirty: boolean;
saving: boolean;
onDiscard: () => void;
/** Puts the defaults back into the section on screen; nothing is saved until the form is. */
onReset?: () => void;
/**
* The section on screen saves itself (the robots.txt and .htaccess
* editors): the page's reset and save buttons would sit under its own
* and save something else, so they give way to the bar, shown only
* while another section has unsaved changes.
*/
ownSave?: boolean;
/**
* Sections that save themselves and hold unsaved changes (a robots.txt
* draft): they count for the prompt on leaving, and another section says
* where they are waiting.
*/
pending?: { section: string; label: string }[];
children: ReactNode;
}): JSX.Element => {
const current = sections.find(item => item.key === section) ?? sections[0];
const footer = useRef(null);
const [footerInView, setFooterInView] = useState(true);
const { register, entries } = useOptionIndex();
const [highlighted, setHighlighted] = useState(null);
const leaveGuard = useLeaveGuard(isDirty || pending.length > 0);
const elsewhere = pending.filter(item => item.section !== section);
// Save changes sits bottom-right, under the Copilot's bubble.
useQuietLauncher();
useEffect(() => {
const element = footer.current;
if (!element || typeof IntersectionObserver === 'undefined') {
return undefined;
}
const observer = new IntersectionObserver(([entry]) =>
setFooterInView(entry.isIntersecting)
);
observer.observe(element);
return () => observer.disconnect();
}, []);
const showOption = (entry: OptionEntry): void => {
onSectionChange(entry.section);
setHighlighted(entry.id);
window.setTimeout(() => {
document
.getElementById(entry.id)
?.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 60);
window.setTimeout(() => {
setHighlighted(latest => (latest === entry.id ? null : latest));
}, HIGHLIGHT_MS);
};
// The search reaches the other settings pages too; a match there opens
// its page on its section, with ?option= naming the row to highlight.
const { pathname } = useLocation();
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const types = useSeoBadges().badges?.types;
const otherPages = useMemo(
() => settingsIndex(types).filter(entry => entry.page !== pathname),
[types, pathname]
);
const openElsewhere = (entry: SettingsEntry): void => {
const query = new URLSearchParams({ section: entry.section });
if (!entry.isSection) {
query.set('option', entry.label);
}
navigate(`${entry.page}?${query.toString()}`);
};
const wanted = params.get('option');
useEffect(() => {
if (!wanted) {
return;
}
const found = entries.find(
entry => entry.label === wanted && entry.section === section
);
if (found) {
const next = new URLSearchParams(params);
next.delete('option');
setParams(next, { replace: true });
showOption(found);
}
// Runs again as rows register, until the one named is there.
}, [wanted, entries, section]);
const sectionLabel = (key: string): string =>
sections.find(item => item.key === key)?.label ?? key;
return (
onSectionChange(String(next))}
className="gap-4"
>
{/* Three columns only from 72rem: under that the side columns of
a centred title up to 42rem left the search about 100px, and its
placeholder read "Searc". Narrower, it sits under the title. */}