diff --git a/gerbil-manager-web/src/components/AppShell.tsx b/gerbil-manager-web/src/components/AppShell.tsx
index 380abd8..63ec19a 100644
--- a/gerbil-manager-web/src/components/AppShell.tsx
+++ b/gerbil-manager-web/src/components/AppShell.tsx
@@ -4,6 +4,7 @@ import { NavLink, Outlet } from 'react-router-dom'
import { de } from '../strings/de'
import GerbilIcon from './GerbilIcon'
import { BreederSuffixProvider } from './BreederSuffixProvider'
+import { useScrollRestoration } from '../hooks/useScrollRestoration'
import './appShell.css'
interface NavItem {
@@ -52,6 +53,9 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
*/
export default function AppShell() {
const [moreOpen, setMoreOpen] = useState(false)
+ // Scrollposition je Seite erhalten (Zurück-Navigation + Handy-Sperre/Hintergrund) — gilt für
+ // ALLE scrollbaren Listen der App.
+ useScrollRestoration()
return (
diff --git a/gerbil-manager-web/src/hooks/useScrollRestoration.ts b/gerbil-manager-web/src/hooks/useScrollRestoration.ts
new file mode 100644
index 0000000..cc8d5d4
--- /dev/null
+++ b/gerbil-manager-web/src/hooks/useScrollRestoration.ts
@@ -0,0 +1,129 @@
+/**
+ * Globale Scroll-Wiederherstellung (einmal im AppShell gemountet, gilt für ALLE Seiten/Listen).
+ *
+ * Erhält die Fenster-Scrollposition je Route über:
+ * - Zurück/Vorwärts-Navigation (POP) → gemerkte Position wiederherstellen,
+ * - Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) → Position
+ * sicher festhalten und beim Zurückkommen wiederherstellen (sonst springt das Handy oben).
+ * Neue Navigation (PUSH/REPLACE) startet wie gewohnt oben.
+ *
+ * Robust gegen nachladende Listen: nach dem Wiederherstellen wird mehrfach (per rAF, bis ~2.5 s)
+ * nachjustiert, während die Liste in die Höhe wächst — bricht aber ab, sobald die Nutzerin selbst
+ * scrollt (damit wir nie gegen sie ankämpfen). Deep-Links (?focus=, #anchor) haben Vorrang.
+ */
+import { useCallback, useEffect, useRef } from 'react'
+import { useLocation, useNavigationType } from 'react-router-dom'
+
+const PREFIX = 'scroll:'
+const RESTORE_DEADLINE_MS = 2500
+
+export function useScrollRestoration() {
+ const { pathname } = useLocation()
+ const navType = useNavigationType()
+ const pathRef = useRef(pathname)
+ const restoringRef = useRef(false)
+ const rafSaveRef = useRef(null)
+
+ const keyFor = (p: string) => PREFIX + p
+
+ const persist = useCallback(() => {
+ try {
+ sessionStorage.setItem(keyFor(pathRef.current), String(window.scrollY))
+ } catch {
+ /* sessionStorage nicht verfügbar */
+ }
+ }, [])
+
+ const restore = useCallback((path: string, respectDeepLink: boolean) => {
+ if (respectDeepLink) {
+ // Ein Deep-Link (Sprung zu einem Element / Anker) hat Vorrang vor der gemerkten Position.
+ if (window.location.hash) return
+ try {
+ if (new URLSearchParams(window.location.search).has('focus')) return
+ } catch {
+ /* ignorieren */
+ }
+ }
+ let y = NaN
+ try {
+ y = Number(sessionStorage.getItem(keyFor(path)))
+ } catch {
+ /* ignorieren */
+ }
+ if (!Number.isFinite(y) || y <= 0) return
+
+ restoringRef.current = true
+ const deadline = performance.now() + RESTORE_DEADLINE_MS
+ let aborted = false
+ const onUser = () => {
+ aborted = true
+ }
+ const passive = { passive: true } as AddEventListenerOptions
+ window.addEventListener('wheel', onUser, passive)
+ window.addEventListener('touchmove', onUser, passive)
+ window.addEventListener('keydown', onUser)
+ const stop = () => {
+ restoringRef.current = false
+ window.removeEventListener('wheel', onUser)
+ window.removeEventListener('touchmove', onUser)
+ window.removeEventListener('keydown', onUser)
+ }
+ const tick = () => {
+ if (aborted) {
+ stop()
+ return
+ }
+ window.scrollTo(0, y)
+ if (Math.abs(window.scrollY - y) <= 2 || performance.now() > deadline) {
+ stop()
+ return
+ }
+ requestAnimationFrame(tick)
+ }
+ requestAnimationFrame(tick)
+ }, [])
+
+ // Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten.
+ useEffect(() => {
+ pathRef.current = pathname
+ if (navType === 'POP') {
+ restore(pathname, true)
+ } else {
+ restoringRef.current = true
+ requestAnimationFrame(() => {
+ window.scrollTo(0, 0)
+ restoringRef.current = false
+ })
+ }
+ }, [pathname, navType, restore])
+
+ // Laufendes Mitschreiben (rAF-gedrosselt) + Sichern beim Ausblenden + Wiederherstellen beim
+ // Wiederanzeigen (Handy entsperrt / aus dem Hintergrund).
+ useEffect(() => {
+ const onScroll = () => {
+ if (restoringRef.current || document.visibilityState !== 'visible') return
+ if (rafSaveRef.current != null) return
+ rafSaveRef.current = requestAnimationFrame(() => {
+ rafSaveRef.current = null
+ persist()
+ })
+ }
+ const onVisibility = () => {
+ if (document.visibilityState === 'hidden') persist()
+ else restore(pathRef.current, false)
+ }
+ const onPageShow = () => restore(pathRef.current, false)
+
+ window.addEventListener('scroll', onScroll, { passive: true })
+ document.addEventListener('visibilitychange', onVisibility)
+ window.addEventListener('pagehide', persist)
+ window.addEventListener('pageshow', onPageShow)
+ return () => {
+ window.removeEventListener('scroll', onScroll)
+ document.removeEventListener('visibilitychange', onVisibility)
+ window.removeEventListener('pagehide', persist)
+ window.removeEventListener('pageshow', onPageShow)
+ if (rafSaveRef.current != null) cancelAnimationFrame(rafSaveRef.current)
+ }
+ }, [persist, restore])
+}
diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx
index d12df39..95a854f 100644
--- a/gerbil-manager-web/src/pages/TicketsPage.tsx
+++ b/gerbil-manager-web/src/pages/TicketsPage.tsx
@@ -11,17 +11,7 @@
* Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote).
* Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS.
*/
-import {
- createContext,
- Fragment,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
- type ReactNode,
-} from 'react'
+import { createContext, Fragment, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { de } from '../strings/de'
import { ApiError } from '../api/client'
@@ -272,11 +262,6 @@ export default function TicketsPage() {
// selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen.
const [view, setView] = useState('open')
const [userPicked, setUserPicked] = useState(false)
- // Scrollposition über Verlassen/Zurückkehren UND Bildschirm-Sperre/Wiederaufwachen hinweg
- // erhalten (Handy sperrt → Seite wird eingefroren/verworfen → kommt sonst oben an). Siehe die
- // Scroll-Effekte unten (Speichern beim Ausblenden, Wiederherstellen beim Anzeigen + nach Laden).
- const lastScrollRef = useRef(0)
- const restoringRef = useRef(false)
async function handleToggleStatus(ticket: FeedbackTicket) {
try {
@@ -448,93 +433,8 @@ export default function TicketsPage() {
return () => clearTimeout(timer)
}, [focusId, displayed])
- // Scrollposition laufend merken (außer während wir gerade selbst wiederherstellen, damit ein
- // vom Browser beim Aufwachen ausgelöster Sprung nach oben die gemerkte Position nicht killt).
- const SCROLL_KEY = 'tickets-scroll-y'
- const saveScroll = useCallback(() => {
- if (restoringRef.current) return
- lastScrollRef.current = window.scrollY
- try {
- sessionStorage.setItem(SCROLL_KEY, String(window.scrollY))
- } catch {
- /* sessionStorage nicht verfügbar */
- }
- }, [])
- useEffect(() => {
- const onScroll = () => {
- if (document.visibilityState !== 'visible') return
- saveScroll()
- }
- window.addEventListener('scroll', onScroll, { passive: true })
- return () => window.removeEventListener('scroll', onScroll)
- }, [saveScroll])
-
- // Position SICHER festhalten, kurz bevor die Seite eingefroren/verworfen wird (Handy-Sperre,
- // App-Wechsel, Tab schließen) — da feuert evtl. kein Scroll-Event mehr.
- useEffect(() => {
- const persist = () => {
- try {
- sessionStorage.setItem(SCROLL_KEY, String(lastScrollRef.current || window.scrollY))
- } catch {
- /* ignorieren */
- }
- }
- const onVisibility = () => {
- if (document.visibilityState === 'hidden') persist()
- }
- document.addEventListener('visibilitychange', onVisibility)
- window.addEventListener('pagehide', persist)
- return () => {
- document.removeEventListener('visibilitychange', onVisibility)
- window.removeEventListener('pagehide', persist)
- }
- }, [])
-
- // Gemerkte Position wiederherstellen — robust gegen nachwachsende Inhalte (mehrere Versuche per
- // rAF) und gegen den Aufwach-Sprung nach oben. ?focus= hat Vorrang.
- const restoreScroll = useCallback(() => {
- if (focusId) return
- let y = NaN
- try {
- y = Number(sessionStorage.getItem(SCROLL_KEY))
- } catch {
- /* ignorieren */
- }
- if (!Number.isFinite(y) || y <= 0) return
- restoringRef.current = true
- let tries = 0
- const tick = () => {
- window.scrollTo(0, y)
- tries += 1
- const close = Math.abs(window.scrollY - y) <= 2
- const cannotReach = document.documentElement.scrollHeight <= y + window.innerHeight
- if (!close && !cannotReach && tries < 30) {
- requestAnimationFrame(tick)
- } else {
- restoringRef.current = false
- }
- }
- requestAnimationFrame(tick)
- }, [focusId])
-
- // Wiederherstellen nach dem Laden der Tickets (frischer Mount / Reload nach Tab-Verwerfung).
- useEffect(() => {
- if (rows) restoreScroll()
- }, [rows, restoreScroll])
-
- // … und beim Wiederanzeigen der Seite (Handy entsperrt / aus dem Hintergrund) OHNE Reload.
- useEffect(() => {
- const onVisible = () => {
- if (document.visibilityState === 'visible') restoreScroll()
- }
- const onPageShow = () => restoreScroll()
- document.addEventListener('visibilitychange', onVisible)
- window.addEventListener('pageshow', onPageShow)
- return () => {
- document.removeEventListener('visibilitychange', onVisible)
- window.removeEventListener('pageshow', onPageShow)
- }
- }, [restoreScroll])
+ // (Scroll-Wiederherstellung läuft global über useScrollRestoration im AppShell — gilt für alle
+ // Listen, inkl. Handy-Sperre/Hintergrund. ?focus= unten hat hier Vorrang.)
// Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen,
// damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte).