diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts
index d0d0630..6603dcf 100644
--- a/web/app/src/icons.ts
+++ b/web/app/src/icons.ts
@@ -23,6 +23,11 @@ export const icons = {
/** Winkel nach oben — eine Ebene zurück im Baum. */
hoch: rahmen(''),
runter: rahmen(''),
+ // Sechs Punkte — das übliche Zeichen für „hier anfassen". Gefüllt,
+ // weil Kreise mit Radius 1 als Umriss nur ein Fleck wären.
+ griff: rahmen(''
+ + ''
+ + '', true),
/** Filmstreifen — für eine Filmmediathek. */
filme: rahmen(''
+ ''),
diff --git a/web/app/src/main.ts b/web/app/src/main.ts
index 94ac729..5326499 100644
--- a/web/app/src/main.ts
+++ b/web/app/src/main.ts
@@ -147,8 +147,16 @@ function leisteBauen(): void {
// die Entscheidung zurück, sie zu trennen.
if (mediatheken.length) {
gruppe('Mediatheken', istAdmin() && mediatheken.length > 1);
+
+ // **Ein eigener Behälter für die Mediatheken.** Beim Ziehen wird gerechnet,
+ // die wievielte Zeile unter dem Zeiger liegt; stünden Überschriften und
+ // „Start" dazwischen, ginge diese Rechnung schief.
+ const liste = document.createElement('div');
+ liste.className = 'nav-liste';
+ leiste.appendChild(liste);
+
mediatheken.forEach((b, i) => eintrag(
- b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i));
+ b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i, liste));
}
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); }
@@ -184,7 +192,7 @@ function leisteBauen(): void {
function eintrag(
text: string, ziel: string, bild: keyof typeof icons,
- anzahl?: number | null, platz?: number,
+ anzahl?: number | null, platz?: number, behaelter?: HTMLElement,
): void {
const b = document.createElement('button');
b.dataset.ziel = ziel;
@@ -192,43 +200,112 @@ function leisteBauen(): void {
+ `${esc(text)}`
+ (anzahl != null && !leisteBearbeiten ? `${anzahl}` : '');
- // Im Bearbeitungsmodus führt die Zeile nirgendwohin — sonst wechselt man
- // beim Zielen auf einen Pfeil versehentlich die Ansicht.
- if (leisteBearbeiten && platz != null) {
- b.classList.add('verschiebbar');
- b.appendChild(pfeil('hoch', platz, platz - 1, platz === 0, text));
- b.appendChild(pfeil('runter', platz, platz + 1, platz === mediatheken.length - 1, text));
+ if (leisteBearbeiten && platz != null && behaelter) {
+ b.classList.add('ziehbar');
+
+ // Der Griff links statt eines Knopfes rechts: er sagt „hier anfassen",
+ // und er nimmt die Zeile nicht als Ganzes in Beschlag.
+ const griff = document.createElement('span');
+ griff.className = 'nav-griff icon';
+ griff.innerHTML = icons.griff;
+ b.prepend(griff);
+
+ // **Die Tastatur bleibt bedienbar.** Ziehen geht damit nicht, und eine
+ // Funktion, die nur mit der Maus erreichbar ist, ist für den, der keine
+ // benutzt, gar keine. Sichtbare Pfeile braucht es dafür nicht.
+ b.setAttribute('aria-roledescription', 'Verschiebbarer Eintrag');
+ b.title = `„${text}" ziehen — oder mit den Pfeiltasten verschieben`;
+ b.onkeydown = e => {
+ if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
+ e.preventDefault();
+ void schieben(platz, platz + (e.key === 'ArrowDown' ? 1 : -1));
+ };
+
+ ziehbarMachen(b, platz, behaelter);
} else {
b.onclick = () => { location.hash = ziel; };
}
- leiste.appendChild(b);
+ (behaelter ?? leiste).appendChild(b);
}
+}
- /** Ein Pfeil zum Schieben.
- *
- * **Zwei Knöpfe statt Ziehen und Ablegen.** Ziehen ist schöner, aber mit
- * der Tastatur unbedienbar und auf dem Telefon ein Glücksspiel.
- */
- function pfeil(
- richtung: 'hoch' | 'runter', von: number, nach: number, gesperrt: boolean, name: string,
- ): HTMLElement {
- const p = document.createElement('span');
- p.className = 'nav-pfeil' + (gesperrt ? ' gesperrt' : '');
- p.setAttribute('role', 'button');
- if (!gesperrt) p.setAttribute('tabindex', '0');
- p.setAttribute('aria-label', `„${name}" nach ${richtung === 'hoch' ? 'oben' : 'unten'}`);
- p.innerHTML = icons.hoch;
- if (richtung === 'runter') p.style.transform = 'rotate(180deg)';
+/** Macht eine Zeile ziehbar.
+ *
+ * **Pointer Events statt HTML5-Drag.** Letzteres kennt kein Telefon, zeichnet
+ * ein Geisterbild, das sich nicht gestalten lässt, und liefert während des
+ * Ziehens keine brauchbaren Koordinaten. Pointer Events bedienen Maus, Finger
+ * und Stift mit demselben Code.
+ *
+ * **Mit Schwelle.** Erst nach sechs Bildpunkten gilt es als Ziehen — sonst
+ * verschiebt jeder leicht wackelige Klick die Reihenfolge.
+ */
+function ziehbarMachen(zeile: HTMLElement, platz: number, behaelter: HTMLElement): void {
+ zeile.addEventListener('pointerdown', start);
- const los = (e: Event) => {
- e.stopPropagation();
- if (gesperrt) return;
- void schieben(von, nach);
+ function start(e: PointerEvent): void {
+ // Nur die linke Maustaste; rechts gehört dem Kontextmenü.
+ if (e.button !== 0) return;
+ e.preventDefault();
+
+ const zeilen = [...behaelter.querySelectorAll('.ziehbar')];
+ const hoehe = zeile.getBoundingClientRect().height
+ + parseFloat(getComputedStyle(behaelter).rowGap || '0');
+ const startY = e.clientY;
+
+ let zieht = false;
+ let ziel = platz;
+
+ zeile.setPointerCapture(e.pointerId);
+
+ const bewegen = (m: PointerEvent) => {
+ const weg = m.clientY - startY;
+
+ if (!zieht) {
+ if (Math.abs(weg) < 6) return;
+ zieht = true;
+ behaelter.classList.add('zieht');
+ zeile.classList.add('wird-gezogen');
+ }
+
+ // Wohin die Zeile fiele, wenn man jetzt losliesse.
+ ziel = Math.max(0, Math.min(zeilen.length - 1, platz + Math.round(weg / hoehe)));
+
+ zeile.style.transform = `translateY(${weg}px)`;
+
+ // Die anderen machen Platz — nach oben oder unten, je nachdem, auf
+ // welcher Seite sie liegen.
+ zeilen.forEach((andere, i) => {
+ if (andere === zeile) return;
+ let versatz = 0;
+ if (platz < ziel && i > platz && i <= ziel) versatz = -hoehe;
+ else if (platz > ziel && i >= ziel && i < platz) versatz = hoehe;
+ andere.style.transform = versatz ? `translateY(${versatz}px)` : '';
+ });
};
- p.onclick = los;
- p.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); los(e); } };
- return p;
+
+ const beenden = () => {
+ zeile.removeEventListener('pointermove', bewegen);
+ zeile.removeEventListener('pointerup', beenden);
+ zeile.removeEventListener('pointercancel', beenden);
+
+ behaelter.classList.remove('zieht');
+ zeilen.forEach(z => { z.style.transform = ''; z.classList.remove('wird-gezogen'); });
+
+ // Ohne Ziehen war es ein Klick — und der führt zur Mediathek, auch im
+ // Bearbeitungsmodus. Sonst wäre die Leiste dort unbenutzbar.
+ if (!zieht) {
+ const weg = zeile.dataset.ziel;
+ if (weg) location.hash = weg;
+ return;
+ }
+
+ if (ziel !== platz) void schieben(platz, ziel);
+ };
+
+ zeile.addEventListener('pointermove', bewegen);
+ zeile.addEventListener('pointerup', beenden);
+ zeile.addEventListener('pointercancel', beenden);
}
}
diff --git a/web/app/src/style.css b/web/app/src/style.css
index 294606f..088af21 100644
--- a/web/app/src/style.css
+++ b/web/app/src/style.css
@@ -181,16 +181,45 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
.nav-bearbeiten.an { color: var(--accent); }
.nav-bearbeiten svg { width: 15px; height: 15px; display: block; }
-/* Im Bearbeitungsmodus trägt die Zeile zwei Pfeile statt der Zahl. */
-#chrome button.verschiebbar { cursor: default; }
-.nav-pfeil {
- display: inline-flex; align-items: center; justify-content: center;
- width: 20px; height: 20px; flex: none;
- border-radius: var(--r-knopf); color: var(--text-muted);
+/* ---------- Umsortieren durch Ziehen ----------
+ *
+ * Der Behälter braucht keine eigene Form; er existiert, damit beim Ziehen
+ * gerechnet werden kann, die wievielte Zeile unter dem Zeiger liegt.
+ */
+.nav-liste { display: flex; flex-direction: column; }
+
+#chrome button.ziehbar {
+ cursor: grab;
+ /* Ohne das übernimmt der Browser die senkrechte Geste und scrollt, statt
+ * die Zeile zu bewegen — auf dem Telefon wäre Ziehen damit unmöglich. */
+ touch-action: none;
+ user-select: none;
+ position: relative;
+}
+#chrome button.ziehbar:active { cursor: grabbing; }
+
+.nav-griff {
+ display: inline-flex; color: var(--text-faint); flex: none; margin-right: -2px;
+}
+.nav-griff svg { width: 14px; height: 14px; }
+#chrome button.ziehbar:hover .nav-griff { color: var(--text-muted); }
+
+/* Die gezogene Zeile liegt über den anderen und wirft einen Schatten —
+ * sonst sieht man nicht, dass sie abgehoben ist. */
+#chrome button.wird-gezogen {
+ z-index: 3; cursor: grabbing;
+ background: var(--elevated); box-shadow: var(--schatten-dialog);
+ opacity: .95;
+}
+
+/* Die weichenden Zeilen gleiten, die gezogene folgt dem Zeiger ohne
+ * Verzögerung — eine Übergangszeit auf ihr machte sie zäh. */
+#chrome .nav-liste.zieht button:not(.wird-gezogen) {
+ transition: transform .14s ease;
+}
+@media (prefers-reduced-motion: reduce) {
+ #chrome .nav-liste.zieht button:not(.wird-gezogen) { transition: none; }
}
-.nav-pfeil:hover { background: var(--raised); color: var(--text); }
-.nav-pfeil.gesperrt { opacity: .25; pointer-events: none; }
-.nav-pfeil svg { width: 14px; height: 14px; }
#chrome button {
display: flex; align-items: center; gap: var(--s-regular);