first commit

This commit is contained in:
2025-08-02 16:30:27 +02:00
commit 23646bfcee
14851 changed files with 1750626 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import { useQuery } from './utils';
import { useMemo } from 'react';
import { type Section, SectionRow } from './SectionRow';
function getEndpoint(searchTerm: string) {
return `/admin/autocomplete/sections?term=${searchTerm}`;
}
export function AutocompleteSections({ searchTerm }: { searchTerm: string }) {
const { data } = useQuery<Section[]>(useMemo(() => getEndpoint(searchTerm), [searchTerm]), { cache: false });
return (
<>
{data?.map((section, i) => (
<SectionRow
section={section}
hasSubsections={!!section.has_subsections}
key={`as${section.id}-${i}`}
/>
))}
{!!data && data.length === 0 && (
<div className="AutocompleteWindow__not-found">
<strong>Sekce nenalezena</strong>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,146 @@
import { useQuery, useWidgetProps } from './utils';
import { useEffect, useState, type KeyboardEvent } from 'react';
export type Section = {
name: string;
id: number;
has_subsections: 0 | 1;
}
type SectionRowProps = {
section: { name: string; id: number; full_path?: string };
depth?: number;
hasSubsections?: boolean;
};
function getEndpoint(sectionId: number) {
return `/admin/autocomplete/sections/${sectionId}`;
}
export function SectionRow({ section, hasSubsections, depth = 0 }: SectionRowProps) {
const [showChildren, setShowChildren] = useState(false);
const [shouldFetchChildren, setShouldFetchChildren] = useState(false);
const { data } = useQuery<Section[]>(shouldFetchChildren ? getEndpoint(section.id): undefined);
const { onAddSection, onAddWithSubsections, onSelect, hide, shouldHideAfterSelect } = useWidgetProps();
useEffect(() => {
if (!shouldFetchChildren) {
setShouldFetchChildren(showChildren);
}
}, [showChildren]);
function handleKeyboardNav(ev: KeyboardEvent<HTMLDivElement>) {
switch (ev.key) {
case 'ArrowLeft':
setShowChildren(false);
break;
case 'ArrowRight':
setShowChildren(true);
break;
case 'Enter':
if (ev.ctrlKey) {
if (onAddWithSubsections) {
onAddWithSubsections(section.id);
}
break;
}
if (onAddSection) {
onAddSection(section.id);
}
if (onSelect) {
onSelect(section.id);
}
break;
}
}
return (
<>
<div
tabIndex={-1}
className={`section-row${depth > 0 ? ' subsection' : ''}`}
style={{ paddingLeft: `${depth * 28}px` }}
onKeyUp={handleKeyboardNav}
>
<div
className="SectionRow__content"
onClick={ev => {
if (onSelect) {
ev.stopPropagation();
if (shouldHideAfterSelect) {
hide();
}
onSelect(section.id);
}
}}
>
<div className="SectionRow__left">
{!!hasSubsections && (
<div className="SectionRow__unwrap-button">
<span
onClick={ev => {
ev.stopPropagation();
setShowChildren(prev => !prev);
}}
className={`bi bi-dash-circle opener ${showChildren ? 'minus' : 'plus'}`}
/>
</div>
)}
<div className="SectionRow__name">
<strong>
{section.name}
</strong>
{!!section.full_path && (
<span className="SectionRow__full-path">{section.full_path}</span>
)}
</div>
</div>
<div className="SectionRow__buttons">
{!!onAddSection && (
<button
key="btn-add"
type="button"
className="btn btn-sm btn-primary"
onClick={ev => {
ev.stopPropagation();
onAddSection(section.id);
}}
>
<span className="bi bi-plus-lg m-r-1"></span> Přidat
</button>
)}
{!!onAddWithSubsections && (
<button
type="button"
key="btn-add-all"
className="btn btn-sm btn-secondary"
disabled={!hasSubsections}
onClick={ev => {
ev.stopPropagation();
onAddWithSubsections(section.id);
}}
>
<span className="bi bi-plus-lg m-r-1"></span> Včetně podsekcí
</button>
)}
</div>
</div>
</div>
{showChildren && data?.map((child, i) => (
<SectionRow
section={child}
key={`c${section.id}-${child.id}-${i}`}
depth={depth + 1}
hasSubsections={child.has_subsections === 1}
/>
))}
</>
);
}

View File

@@ -0,0 +1,122 @@
import './styles.scss';
import { memo, useEffect, useState, type KeyboardEvent } from 'react';
import { useCache } from './cache';
import { SectionRow } from './SectionRow';
import { AutocompleteSections } from './Autocomplete';
import type { WidgetProps } from './index';
import { WidgetPropsProvider } from './utils';
function handleKeyboardNav(ev: KeyboardEvent<HTMLDivElement>) {
const { currentTarget } = ev;
switch (ev.key) {
case 'Escape':
(currentTarget.querySelector('.form-control') as HTMLInputElement).focus();
break;
case 'ArrowDown':
if (!document.activeElement?.classList.contains('section-row')) {
const firstFocusTarget = currentTarget.querySelector('.section-row') as HTMLDivElement | undefined;
if (!firstFocusTarget) {
return;
}
firstFocusTarget.focus();
firstFocusTarget.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
const nextSibling = document.activeElement.nextElementSibling as HTMLDivElement | undefined;
if (nextSibling && nextSibling.classList.contains('section-row')) {
nextSibling.focus();
nextSibling.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
break;
case 'ArrowUp':
const prevSibling = document.activeElement?.previousElementSibling as HTMLDivElement | undefined;
if (prevSibling && prevSibling.classList.contains('section-row')) {
prevSibling.focus();
prevSibling.scrollIntoView({ behavior: 'smooth', block: 'center' });
return;
}
break;
}
}
export function SectionsWidget(props: WidgetProps) {
const [shouldShow, setShouldShow] = useState(false);
const [value, setValue] = useState('');
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
const [cache] = useCache();
useEffect(() => {
function handleClick() {
setShouldShow(false);
}
document.addEventListener('click', handleClick);
return () => {
document.removeEventListener('click', handleClick);
}
}, []);
useEffect(() => {
const timeout = setTimeout(() => setDebouncedSearchTerm(value), 300);
return () => clearTimeout(timeout);
}, [value]);
return (
<div
onKeyUp={handleKeyboardNav}
onKeyDown={(ev) => {
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
ev.preventDefault();
}
}}
onClick={e => {
e.stopPropagation();
setShouldShow(true);
}}
>
<input
onClick={() => {
cache._fetchTopLevelSections();
setShouldShow(true);
}}
type="text"
className="form-control"
onChange={(e) => setValue(e.target.value)}
value={value}
placeholder="Vyhledejte sekci"
onFocus={() => {
setShouldShow(true);
setValue('');
}}
/>
<WidgetPropsProvider value={{...props, hide: () => setShouldShow(false), show: () => setShouldShow(true) }}>
{shouldShow && <WidgetPopover searchTerm={debouncedSearchTerm} />}
</WidgetPropsProvider>
</div>
);
}
const WidgetPopover = memo(({ searchTerm }: { searchTerm: string }) => {
const topLevel = useCache('topLevelSections');
return (
<div className="autocomplete-window">
{!searchTerm && topLevel.map((section, i) => (
<SectionRow
section={section}
key={`t${section.id}-${i}`}
hasSubsections={!!section.has_subsections}
/>
))}
{!!searchTerm && <AutocompleteSections searchTerm={searchTerm} />}
</div>
);
});

View File

@@ -0,0 +1,35 @@
import { createContext, useContext } from 'react';
import type { Section } from './SectionRow';
export type Cache = {
topLevelSections: Section[];
_query: Record<string, unknown>;
_fetchTopLevelSections: () => void;
};
type CacheContext = [Cache, (p: Cache | ((prev: Cache) => Cache)) => void];
const CacheContext = createContext<CacheContext>([
{
topLevelSections: [],
_query: {},
_fetchTopLevelSections: () => {},
},
() => {},
]);
export const Provider = CacheContext.Provider;
export function useCache<Key extends undefined>(): CacheContext;
export function useCache<Key extends keyof Cache>(key: Key): Cache[Key];
export function useCache<Key extends keyof Cache>(key?: Key) {
const cacheContext = useContext(CacheContext);
if (key === undefined) {
return cacheContext;
}
return cacheContext[0][key];
}

View File

@@ -0,0 +1,127 @@
import { SectionsWidget } from './SectionsWidget';
import { render, createPortal } from 'react-dom';
import { Fragment, memo, useEffect, useState } from 'react';
import { Provider as CacheContextProvider, type Cache } from './cache';
const EVENT_MOUNT = 'wpj:sections-autocomplete:mount';
export type RuntimeProps = {
runtimeTarget: HTMLElement;
onInit?: () => void;
};
export type WidgetProps = {
// Buttons
onAddSection?: (sectionId: number) => void;
onAddWithSubsections?: (topsectionId: number) => void;
// Row click
onSelect?: (idSection: number) => void;
shouldHideAfterSelect?: boolean;
};
export type MountEvent = CustomEvent<{
mountTarget: HTMLElement;
} & WidgetProps>;
let CacheInitialized = false;
export function RuntimeComponent({ options }: { options: RuntimeProps }) {
const { runtimeTarget, onInit } = options;
const [portals, setPortals] = useState<MountEvent['detail'][]>([]);
const [cache, setCache] = useState<Cache>({
topLevelSections: [],
_query: {},
_fetchTopLevelSections: fetchTopLevelSections,
});
function fetchTopLevelSections() {
if (CacheInitialized) {
return;
}
const url = new URL('/admin/autocomplete/sections', `${window.location.protocol}//${window.location.host}`);
fetch(url.href)
.then(r => r.json())
.then(sections => {
setCache(p => ({ ...p, topLevelSections: sections }));
CacheInitialized = true;
})
.catch(alert);
}
useEffect(() => {
function mountEventHandler(ev: Event | MountEvent) {
if (ev.type !== EVENT_MOUNT) {
return;
}
const mountEvent = ev as MountEvent;
setPortals(previous => [...previous, mountEvent.detail]);
}
runtimeTarget.addEventListener(EVENT_MOUNT, mountEventHandler);
if (onInit) {
onInit();
}
return () => {
runtimeTarget.removeEventListener(EVENT_MOUNT, mountEventHandler);
};
}, []);
return (
<CacheContextProvider value={[cache, setCache]}>
<PortalMounter portals={portals} />
</CacheContextProvider>
);
}
const PortalMounter = memo(({ portals }: { portals: MountEvent['detail'][] }) => {
return (
<>
{portals.map((props, index) => (
<Fragment key={index}>
{createPortal(<SectionsWidget {...props} />, props.mountTarget, index.toString())}
</Fragment>
))}
</>
);
});
export function prepareRuntime(runtimeTarget?: HTMLElement) {
if (!runtimeTarget) {
const elem = document.createElement('meta');
document.body.insertAdjacentElement('beforeend', elem);
runtimeTarget = elem;
}
runtimeTarget.dataset.sectionsAutocompleteRuntime = '';
return new Promise<HTMLElement>((resolve) => {
const options = { runtimeTarget, onInit: () => { resolve(runtimeTarget!) } } as RuntimeProps;
render(
<RuntimeComponent options={options} />,
options.runtimeTarget,
);
});
}
export function mountAutocomplete(target: HTMLElement, options: WidgetProps = { shouldHideAfterSelect: true }) {
const event: MountEvent = new CustomEvent(EVENT_MOUNT, {
detail: {
mountTarget: target,
...options,
},
bubbles: true,
});
const runtimeElement = document.querySelector('[data-sections-autocomplete-runtime]');
if (!runtimeElement) {
throw new Error('SectionsAutocomplete runtime not found');
}
runtimeElement.dispatchEvent(event);
}

View File

@@ -0,0 +1,83 @@
@import "../scss/style.scss";
.autocomplete-window {
@include custom-scrollbar;
position: absolute;
z-index: 2;
width: 100%;
min-height: 400px;
height: 100%;
border-radius: $border-radius-base;
overflow-y: scroll;
display: flex;
flex-direction: column;
background-color: $body-bg;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
.AutocompleteWindow__not-found {
width: 100%;
padding: 1em;
text-align: center;
}
.section-row {
border-bottom: 1px solid $border-color-light;
background-color: $gray-background;
&:focus {
outline: none;
.SectionRow__content {
background-color: $table-bg-hover;
}
}
&.subsection .SectionRow__content {
border-left: 1px solid $border-color-light;
}
}
.SectionRow__content {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: $padding-small-vertical $padding-small-horizontal;
min-height: 46px;
background-color: $body-bg;
&:hover {
background-color: $table-bg-hover;
}
}
.SectionRow__left {
display: flex;
align-items: center;
gap: 8px;
}
.SectionRow__unwrap-button {
color: $primary;
cursor: pointer;
}
.SectionRow__name {
display: flex;
flex-direction: column;
align-items: start;
font-size: $font-size-small;
}
.SectionRow__full-path {
color: $text-muted;
}
.SectionRow__buttons {
display: flex;
justify-content: space-between;
align-items: center;
gap: 7px;
}

View File

@@ -0,0 +1,96 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { useCache } from './cache';
import type { WidgetProps } from './index';
type UseQueryOptions = {
cache: boolean;
cacheKey?: string;
};
export function useQuery<Result>(endpoint: string | undefined, options: UseQueryOptions = { cache: true }) {
const [cache, setCache] = useCache();
const [result, setResult] = useState<Result | undefined>(undefined);
const [fetching, setFetching] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);
useEffect(() => {
let abortController: AbortController | undefined = undefined;
async function handleQuery() {
if (typeof endpoint === 'undefined') {
return;
}
const cacheKey = options.cacheKey ?? endpoint;
if (options.cache && cacheKey in cache._query) {
setResult(cache._query[cacheKey] as Result);
return;
}
abortController = new AbortController();
setFetching(true);
try {
const url = new URL(endpoint, `${window.location.protocol}//${window.location.host}`);
const response = await fetch(url.href, { signal: abortController.signal });
if (!response.ok || response.status !== 200) {
throw new Error('Failed to fetch sections');
}
const data = await response.json();
setResult(data);
setError(undefined);
if (cache) {
setCache(prev => ({
...prev,
_query: {
...prev._query,
[cacheKey]: data,
},
}));
}
} catch (e: unknown) {
if (!(e instanceof Error)) {
e = new Error(String(e));
}
if ((e as Error).name === 'AbortError') {
return;
}
setError(e as Error);
console.error(e);
} finally {
setFetching(false);
}
}
void handleQuery();
return () => {
if (abortController) {
abortController.abort();
}
};
}, [endpoint]);
function clear() {
setResult(undefined);
setFetching(false);
setError(undefined);
}
return { data: result, isFetching: fetching, error, clear };
}
type WidgetPropsContextValue = WidgetProps & {
hide: () => void;
show: () => void;
};
const WidgetPropsContext = createContext<WidgetPropsContextValue | undefined>(undefined);
export const WidgetPropsProvider = WidgetPropsContext.Provider;
export function useWidgetProps() {
return useContext(WidgetPropsContext) as WidgetPropsContextValue;
}