This commit is contained in:
seb
2026-08-03 00:22:46 +02:00
parent 52e4d3d9c9
commit 8577a63b06
8 changed files with 189 additions and 68 deletions

View File

@@ -359,6 +359,18 @@ export const category = {
body: { parentId, categoryIds },
});
},
/**
* PATCH /api/category/:id [VORSCHLAG — Route/response fehlt noch in dashback]
* Body: { parentId } — moves the category under a new parent.
* → 200 { category: { id, parentId, name, slug } }
*/
move(categoryId, parentId) {
return http(`/api/category/${categoryId}`, {
method: 'PATCH',
body: { parentId },
});
},
};
/**

View File

@@ -10,8 +10,8 @@ import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import { Tile, tileBaseSx } from './Tile';
/**
* Top edit band (upper third) with a move handle on the left and a tree
* (drop-into-subcategory) handle on the right.
* Top edit band: left = reorder handle (SortableJS), right = drop-into-subcategory
* handle (Shopify Draggable Droppable).
*/
function TopBand() {
return (
@@ -37,7 +37,7 @@ function TopBand() {
<DragHandleIcon sx={{ fontSize: 18 }} />
</IconButton>
<IconButton
className="sortable-tree"
className="droppable-handle"
size="small"
aria-label="In Unterkategorie ablegen"
sx={{ cursor: 'grab', '&:active': { cursor: 'grabbing' } }}

View File

@@ -23,7 +23,26 @@ body {
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
}
/* Tree-Drag (In-Unterkategorie-ablegen): kein Ghost/Shadow, Kacheln bleiben fest */
.sortable-tree-ghost {
display: none !important;
/* Shopify Droppable: aktuell anvisierte Ziel-Kachel hervorheben */
.tree-drop-target {
outline: 3px solid #1976d2;
outline-offset: -3px;
box-shadow: 0 0 0 4px rgba(25, 118, 210, 0.35);
}
/* Während des Tree-Draggings keine Hover-/Textselektions-Effekte auf den
Ziel-Kacheln: interaktive Kinder (Buttons, Inputs) vom Maus-Event abkoppeln,
damit keine :hover ausgelöst wird und der Cursor durchgängig 'grabbing' bleibt. */
body.tree-dragging {
user-select: none;
-webkit-user-select: none;
cursor: grabbing;
}
body.tree-dragging button,
body.tree-dragging input,
body.tree-dragging textarea,
body.tree-dragging .MuiButtonBase-root,
body.tree-dragging .MuiInputBase-root {
pointer-events: none !important;
}

View File

@@ -6,6 +6,7 @@ import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import { enqueueSnackbar } from 'notistack';
import Sortable from 'sortablejs';
import { Droppable } from '@shopify/draggable';
import BackendError from '../../components/BackendError';
import { NewCategoryTile, CategoryBreadcrumbs } from '../../components/category';
import { NewArticleTile } from '../../components/article';
@@ -29,6 +30,7 @@ import {
import {
categoryDisplayName,
deleteCategory,
moveCategory,
loadCategoryArticles,
loadCategoryTree,
navigateToCategory,
@@ -57,8 +59,10 @@ class ArtikelKategorienPage extends Component {
this.state = { drafts: {} };
this.gridRef = createRef();
this.sortable = null;
this.droppable = null;
this.checkpointOrder = null;
this.treeDragId = null;
this.pendingDropId = null;
this.dropTargetEl = null;
this.handleRenameSubmit = this.handleRenameSubmit.bind(this);
this.handleDraftChange = this.handleDraftChange.bind(this);
}
@@ -68,6 +72,7 @@ class ArtikelKategorienPage extends Component {
this.syncCategoryFromUrl();
this.ensureArticlesLoaded();
this.syncSortable();
this.syncDroppable();
}
componentDidUpdate(prevProps) {
@@ -96,11 +101,13 @@ class ArtikelKategorienPage extends Component {
if (this.props.renameMode !== prevProps.renameMode) {
this.syncSortable();
this.syncDroppable();
}
}
componentWillUnmount() {
this.destroySortable();
this.destroyDroppable();
}
syncSortable = () => {
@@ -114,15 +121,117 @@ class ArtikelKategorienPage extends Component {
}
};
syncDroppable = () => {
if (this.props.renameMode) {
this.destroyDroppable();
this.activateDroppable();
} else {
this.destroyDroppable();
}
};
activateDroppable = () => {
const el = this.gridRef.current;
if (!el || this.droppable) return;
this.pendingDropId = null;
this.droppable = new Droppable(el, {
draggable: '.sortable-item',
dropzone: '.sortable-item',
handle: '.droppable-handle',
delay: 0,
mirror: { constrainDimensions: true },
// Droppable markiert sonst alle Dropzones gleichzeitig mit der
// --active-Klasse. Das deaktivieren wir und heben stattdessen nur das
// aktuell anvisierte Ziel über tree-drop-target hervor.
classes: { 'droppable:active': '', 'droppable:occupied': '' },
});
this.droppable.on('drag:start', this.handleTreeDragStart);
this.droppable.on('drag:over', this.handleDropOver);
this.droppable.on('drag:out', this.handleDropOut);
this.droppable.on('droppable:dropped', this.handleDropped);
this.droppable.on('droppable:returned', this.handleDroppedReturned);
this.droppable.on('drag:stop', this.handleDragStop);
};
handleTreeDragStart = () => {
// Während des Tree-Draggings native Textselektion unterbinden, damit der
// Cursor kein Text-I-Cursor wird und Textareas keine Hover-Selektion zeigen.
document.body.classList.add('tree-dragging');
};
destroyDroppable = () => {
if (this.droppable) {
this.droppable.destroy();
this.droppable = null;
}
this.pendingDropId = null;
this.clearDropTarget();
document.body.classList.remove('tree-dragging');
};
handleDropped = (event) => {
// Reiner DOM-Schutz: verhindert, dass Droppable das Quell-Element per
// appendChild in die Dropzone verschiebt. Das Ziel-Tracking läuft über
// drag:over/drag:out (pendingDropId), nicht über dieses Event.
event.cancel();
};
handleDropOver = (event) => {
const tile =
event?.over && typeof event.over.closest === 'function'
? event.over.closest('.sortable-item')
: null;
if (!tile) return;
// Die gezogene Kachel selbst ist kein gültiges Drop-Ziel.
const sourceId = Number(event?.source?.dataset?.id);
const targetId = Number(tile.dataset.id);
if (sourceId && targetId === sourceId) {
this.pendingDropId = null;
return;
}
this.clearDropTarget();
this.pendingDropId = targetId;
this.dropTargetEl = tile;
tile.classList.add('tree-drop-target');
};
handleDropOut = () => {
this.pendingDropId = null;
this.clearDropTarget();
};
clearDropTarget = () => {
if (this.dropTargetEl) {
this.dropTargetEl.classList.remove('tree-drop-target');
this.dropTargetEl = null;
}
};
handleDroppedReturned = (event) => {
this.pendingDropId = null;
this.clearDropTarget();
event.cancel();
};
handleDragStop = (event) => {
document.body.classList.remove('tree-dragging');
const categoryId = Number(event?.source?.dataset?.id);
const targetId = this.pendingDropId;
this.pendingDropId = null;
this.clearDropTarget();
if (!categoryId || !targetId || categoryId === targetId) return;
void this.props
.moveCategory({ categoryId, parentId: targetId })
.unwrap()
.catch(notifyError);
};
activateSortable = () => {
const el = this.gridRef.current;
if (!el || this.sortable) return;
// Capture-Listener im Grid: läuft vor SortableJS' Listener und vor Reacts
// synthetischem mousedown — erkennt die Drag-Quelle (Tree vs. Move) zuverlässig.
el.addEventListener('mousedown', this.handleGridMouseDownCapture, true);
this.sortable = Sortable.create(el, {
draggable: '.sortable-item',
handle: '.sortable-handle, .sortable-tree',
handle: '.sortable-handle',
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
@@ -133,10 +242,6 @@ class ArtikelKategorienPage extends Component {
};
destroySortable = () => {
const el = this.gridRef.current;
if (el) {
el.removeEventListener('mousedown', this.handleGridMouseDownCapture, true);
}
if (this.sortable) {
this.sortable.destroy();
this.sortable = null;
@@ -153,50 +258,15 @@ class ArtikelKategorienPage extends Component {
.map(Number);
};
handleGridMouseDownCapture = (event) => {
// Native Capture-Phase: läuft VOR SortableJS' Bubbling-Listener und vor
// Reacts synthetischem Event. Das echte DOM-Ziel wird so zuverlässig erkannt.
const target = event.target;
if (!target || typeof target.closest !== 'function') return;
const treeEl = target.closest('.sortable-tree');
const isTree = treeEl != null;
this.treeDragId = isTree
? Number(treeEl?.closest('.sortable-item')?.dataset?.id)
: null;
// Beim Tree-Drag Sortable-Deaktivierung (sort=false) setzen, damit nichts umsortiert.
this.sortable?.option('sort', !isTree);
};
handleSortStart = (evt) => {
const isTree = this.treeDragId != null;
if (isTree) {
// Kein Ghost/Shadow — Kacheln bleiben fest, nur das Ziel wird sichtbar.
this.sortable?.option('ghostClass', 'sortable-tree-ghost');
this.sortable?.option('dragClass', '');
} else {
this.sortable?.option('ghostClass', 'sortable-ghost');
this.sortable?.option('dragClass', 'sortable-drag');
}
handleSortStart = () => {
// Snapshot der Reihenfolge vor dem ersten Drag.
this.checkpointOrder = this.sortableIds();
};
handleSort = () => {
// Sortable-Klassen für den nächsten Drag zurücksetzen.
this.sortable?.option('ghostClass', 'sortable-ghost');
this.sortable?.option('dragClass', 'sortable-drag');
this.sortable?.option('sort', true);
// Tree-Drag (in Unterkategorie ablegen): Geschwister-Reihenfolge nicht ändern.
const ids = this.sortableIds();
const before = this.checkpointOrder ?? [];
this.checkpointOrder = null;
if (this.treeDragId != null) {
// Kacheln visuell an Original-Position zurückdrehen — kein Reorder-Call.
this.treeDragId = null;
if (before.length) this.revertDomOrder(before);
this.clearSortableState();
return;
}
const ids = this.sortableIds();
if (
before.length === 0 ||
before.length !== ids.length ||
@@ -212,24 +282,9 @@ class ArtikelKategorienPage extends Component {
// Reihenfolge im DOM zurückdrehen, ohne React neu zu rendern —
// die Sortable-Instanz bleibt dadurch funktionsfähig.
this.revertDomOrder(before);
this.clearSortableState();
});
};
/**
* Entfernt verbliebene SortableJS-Drag/Chosen/Ghost-Klassen von allen
* Kacheln. Nötig, weil revertDomOrder die DOM-Knoten umsortiert und
* SortableJS sein internes Cleanup dann ggf. nicht mehr korrekt anwendet.
*/
clearSortableState = () => {
const container = this.gridRef.current;
if (!container) return;
const classes = ['sortable-chosen', 'sortable-drag', 'sortable-ghost', 'sortable-tree-ghost'];
for (const el of container.querySelectorAll('.sortable-item')) {
el.classList.remove(...classes);
}
};
handleMoveCategory = (category, direction) => {
const ids = this.sortableIds();
const index = ids.indexOf(category.id);
@@ -545,6 +600,7 @@ const mapDispatchToProps = {
navigateToCategory,
renameCategory,
deleteCategory,
moveCategory,
reorderCategories,
};

View File

@@ -189,6 +189,24 @@ export const reorderCategories = createAsyncThunk(
},
);
/**
* Move a category under a new parent (drop into subcategory).
* Backend: PATCH /api/category/:id Body: { parentId } → { category }.
* (Vorschlag — Route fehlt noch in dashback; siehe TODO.md.)
*/
export const moveCategory = createAsyncThunk(
'category/move',
async ({ categoryId, parentId }, { rejectWithValue }) => {
try {
const data = await category.move(categoryId, parentId);
requireObject(data.category, 'PATCH /api/category/:id.category');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const deleteCategory = createAsyncThunk(
'category/delete',
async (categoryId, { rejectWithValue }) => {
@@ -350,6 +368,14 @@ const categorySlice = createSlice({
}
}
})
.addCase(moveCategory.fulfilled, (state, action) => {
const moved = action.payload;
if (moved == null || state.categories == null) return;
state.categories[String(moved.id)] = {
...(state.categories[String(moved.id)] ?? {}),
...moved,
};
})
.addCase(createArticle.fulfilled, (state, action) => {
const created = action.payload;
if (created == null) return;