This commit is contained in:
seb
2026-09-29 09:30:43 +02:00
parent c4f8ebb41b
commit 18419b1615
33 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import Sortable from 'sortablejs';
import { notifyError } from '../../utils/notify';
/**
* Encapsulates SortableJS logic for reordering category tiles inside a grid.
*
* Usage:
* this.sortable = new CategorySortable(containerEl, { onReorder });
* this.sortable.activate();
* this.sortable.destroy();
* this.sortable.sortableIds(); // current DOM order
* this.sortable.handleMoveCategory(id, direction); // +1 / -1
*/
export default class CategorySortable {
#instance = null;
#container = null;
#onReorder = null;
#checkpointOrder = null;
constructor(container, { onReorder }) {
this.#container = container;
this.#onReorder = onReorder;
}
activate() {
const el = this.#container;
if (!el || this.#instance) return;
this.#instance = Sortable.create(el, {
draggable: '.sortable-item:not(.not-sortable)',
handle: '.sortable-handle',
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
filter: '.not-sortable, .sortable-handle-btn',
forceFallback: true,
fallbackOnBody: true,
onStart: this.#handleSortStart,
onEnd: this.#handleSort,
});
}
destroy() {
if (this.#instance) {
this.#instance.destroy();
this.#instance = null;
}
this.#checkpointOrder = null;
document.body.classList.remove('tree-dragging');
}
sortableIds() {
const container = this.#container;
if (!container) return [];
return [...container.querySelectorAll('.sortable-item:not(.not-sortable)')]
.map((el) => el.dataset.id)
.filter(Boolean)
.map(Number);
}
handleMoveCategory = (categoryId, direction) => {
const ids = this.sortableIds();
const index = ids.indexOf(categoryId);
if (index === -1) return;
const target = index + direction;
if (target < 0 || target >= ids.length) return;
const next = [...ids];
next[index] = ids[target];
next[target] = categoryId;
void this.#onReorder({ categoryIds: next }).unwrap().catch(notifyError);
};
revertDomOrder = (order) => {
const container = this.#container;
if (!container) return;
const items = [...container.querySelectorAll('.sortable-item')];
const byId = new Map(items.map((el) => [Number(el.dataset.id), el]));
const anchor =
[...container.children].find((el) => !el.classList.contains('sortable-item')) ?? null;
for (const id of order) {
const el = byId.get(id);
if (!el) continue;
if (anchor) container.insertBefore(el, anchor);
else container.appendChild(el);
}
};
// --- private ---
#handleSortStart = () => {
this.#checkpointOrder = this.sortableIds();
document.body.classList.add('tree-dragging');
};
#handleSort = () => {
document.body.classList.remove('tree-dragging');
const ids = this.sortableIds();
const before = this.#checkpointOrder ?? [];
this.#checkpointOrder = null;
if (
before.length === 0 ||
before.length !== ids.length ||
before.every((id, index) => id === ids[index])
) {
return;
}
void this.#onReorder({ categoryIds: ids })
.unwrap()
.catch((err) => {
notifyError(err);
this.revertDomOrder(before);
});
};
}

View File

@@ -0,0 +1,42 @@
import {
ROOT_CATEGORY_NAME,
categorySlug,
getCategoryBreadcrumbs,
getCategoryChildren,
} from '../../utils/categoryUtils';
export { ROOT_CATEGORY_NAME, categorySlug };
export const KATEGORIEN_BASE = '/kategorien';
export function isKategorienPath(pathname) {
return pathname === KATEGORIEN_BASE || pathname.startsWith(`${KATEGORIEN_BASE}/`);
}
function isRootSlug(slug) {
return slug === ROOT_CATEGORY_NAME;
}
export function categoryPathFromCrumbs(crumbs) {
const segments = (crumbs ?? [])
.filter((crumb) => crumb.slug && !isRootSlug(crumb.slug))
.map((crumb) => encodeURIComponent(crumb.slug));
if (segments.length === 0) {
return KATEGORIEN_BASE;
}
return `${KATEGORIEN_BASE}/${segments.join('/')}`;
}
export function buildCategoryPath(categories, rootCategoryId, categoryId) {
const crumbs = getCategoryBreadcrumbs(categories, categoryId, rootCategoryId);
return categoryPathFromCrumbs(crumbs);
}
export {
resolveCategoryIdFromSlugPath,
cleanCategorySlugPath,
} from '../../utils/categoryUtils';
export function defaultCategoryPath() {
return KATEGORIEN_BASE;
}

View File

@@ -0,0 +1,19 @@
import { useLocation, useNavigate, useParams } from 'react-router-dom';
export default function withCategoryRoute(Wrapped) {
function CategoryRouteWrapper(props) {
const navigate = useNavigate();
const location = useLocation();
const { '*': categorySlugPath = '' } = useParams();
return (
<Wrapped
{...props}
navigate={navigate}
location={location}
categorySlugPath={categorySlugPath}
/>
);
}
return CategoryRouteWrapper;
}