feat: das Konto zieht hinter den Namen, und die Ansicht merkt sich
DAS KONTO WAR EIN EIGENER REITER -- gleichrangig neben Galerie und Verlauf, obwohl man sein Passwort zweimal im Jahr aendert und die Galerie taeglich oeffnet. Jetzt haengt es hinter dem eigenen Namen oben rechts, zusammen mit dem Abmelden. Das ist der Platz, an dem jeder danach sucht. Der Pfad /konto BLEIBT bestehen: ein Lesezeichen darauf soll nicht ins Leere fuehren, nur weil der Reiter verschwunden ist. Dafuer gibt es jetzt eine zweite, kleine Liste "versteckt" neben den Reitern -- Seiten mit Adresse, aber ohne Knopf. Das Menue schliesst bei Klick daneben und bei Escape. Ein Menue, das offen bleibt, bis man genau den Knopf wieder trifft, steht im Weg. DIE ANSICHT MERKT SICH. Seitengroesse, Sortierung und Richtung in der Galerie, Seitengroesse im Verlauf: wer 120 pro Seite einstellt, meint das dauerhaft und nicht bis zum naechsten F5. Der Auffrisch-Schalter tat das schon von Hand und geht jetzt denselben Weg. BEWUSST NICHT gemerkt werden Filter -- Ordner, Suche, Medienart. Ein gemerkter Filter fuehrt zu der Frage "warum sehe ich nichts", und die Antwort steht dann in einer Leiste, auf die niemand schaut, weil er sie nicht selbst gesetzt hat. Was die Ansicht formt, wird gemerkt; was die Menge einschraenkt, nicht. localStorage kann werfen (privates Fenster, voller Speicher). Dann verhaelt sich der Haken wie ein gewoehnlicher Zustand, statt die Seite mitzureissen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+11
-22
@@ -6,12 +6,10 @@ import {
|
|||||||
History,
|
History,
|
||||||
Images,
|
Images,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LogOut,
|
|
||||||
PieChart,
|
PieChart,
|
||||||
Settings,
|
Settings,
|
||||||
UserCog,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { run, useStore } from './store'
|
import { useStore } from './store'
|
||||||
import { Anmelden } from './pages/Anmelden'
|
import { Anmelden } from './pages/Anmelden'
|
||||||
import { Start } from './pages/Start'
|
import { Start } from './pages/Start'
|
||||||
import { Galerie } from './pages/Galerie'
|
import { Galerie } from './pages/Galerie'
|
||||||
@@ -23,7 +21,7 @@ import { Verlauf } from './pages/Verlauf'
|
|||||||
import { Api } from './pages/Api'
|
import { Api } from './pages/Api'
|
||||||
import { Meldungen } from './components/Meldungen'
|
import { Meldungen } from './components/Meldungen'
|
||||||
import { gehZu, usePfad } from './router'
|
import { gehZu, usePfad } from './router'
|
||||||
import { Avatar } from './components/Avatar'
|
import { Kontomenue } from './components/Kontomenue'
|
||||||
|
|
||||||
/** Die Breite des Inhalts — an EINER Stelle.
|
/** Die Breite des Inhalts — an EINER Stelle.
|
||||||
*
|
*
|
||||||
@@ -41,10 +39,14 @@ const seiten = [
|
|||||||
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
|
{ pfad: '/speicher', name: 'Speicher', kind: PieChart, Inhalt: Speicher },
|
||||||
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
|
{ pfad: '/tokens', name: 'Token', kind: KeyRound, Inhalt: Tokens },
|
||||||
{ pfad: '/api', name: 'API', kind: Code2, Inhalt: Api },
|
{ pfad: '/api', name: 'API', kind: Code2, Inhalt: Api },
|
||||||
{ pfad: '/konto', name: 'Konto', kind: UserCog, Inhalt: Konto },
|
|
||||||
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
|
{ pfad: '/einstellungen', name: 'Einstellungen', kind: Settings, Inhalt: Einstellungen },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
/** Seiten OHNE Reiter. Sie sind ueber die Adresse und ueber das Kontomenue
|
||||||
|
* erreichbar -- ein Lesezeichen auf /konto soll nicht ins Leere fuehren, nur
|
||||||
|
* weil der Reiter verschwunden ist. */
|
||||||
|
const versteckt = [{ pfad: '/konto', Inhalt: Konto }] as const
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
// Einzeln ausgewählt und nicht als ganzer Laden: `useStore()` ohne
|
// Einzeln ausgewählt und nicht als ganzer Laden: `useStore()` ohne
|
||||||
// Auswahlfunktion abonniert ALLES, und dann zeichnet jede Meldung das
|
// Auswahlfunktion abonniert ALLES, und dann zeichnet jede Meldung das
|
||||||
@@ -52,7 +54,6 @@ export function App() {
|
|||||||
const user = useStore((s) => s.user)
|
const user = useStore((s) => s.user)
|
||||||
const checking = useStore((s) => s.checking)
|
const checking = useStore((s) => s.checking)
|
||||||
const checkSession = useStore((s) => s.checkSession)
|
const checkSession = useStore((s) => s.checkSession)
|
||||||
const logout = useStore((s) => s.logout)
|
|
||||||
const pfad = usePfad()
|
const pfad = usePfad()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -80,8 +81,9 @@ export function App() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ohneReiter = versteckt.find((s) => s.pfad === pfad)
|
||||||
const aktiv = seiten.find((s) => s.pfad === pfad) ?? seiten[0]
|
const aktiv = seiten.find((s) => s.pfad === pfad) ?? seiten[0]
|
||||||
const Inhalt = aktiv.Inhalt
|
const Inhalt = ohneReiter ? ohneReiter.Inhalt : aktiv.Inhalt
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-col">
|
<div className="flex min-h-full flex-col">
|
||||||
@@ -124,21 +126,8 @@ export function App() {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<Kontomenue />
|
||||||
<Avatar name={user.username} url={user.avatar} groesse={26} />
|
|
||||||
<span className="hidden text-sm text-schrift-leise sm:inline">
|
|
||||||
{user.username}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => void run(logout, 'Abgemeldet.')}
|
|
||||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-sm
|
|
||||||
text-schrift-leise hover:text-schrift"
|
|
||||||
aria-label="Abmelden"
|
|
||||||
>
|
|
||||||
<LogOut size={15} />
|
|
||||||
<span className="hidden md:inline">Abmelden</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { api, type Stand } from './api'
|
import { api, type Stand } from './api'
|
||||||
|
import { useMerker } from './merker'
|
||||||
|
|
||||||
// Automatisch auffrischen — aber nicht, indem man alle fünf Sekunden alles neu
|
// Automatisch auffrischen — aber nicht, indem man alle fünf Sekunden alles neu
|
||||||
// lädt.
|
// lädt.
|
||||||
@@ -13,7 +14,6 @@ import { api, type Stand } from './api'
|
|||||||
// Knopf. Eine Liste, die sich beim Klicken selbst neu sortiert, ist der Grund,
|
// Knopf. Eine Liste, die sich beim Klicken selbst neu sortiert, ist der Grund,
|
||||||
// warum Leute Auto-Refresh abschalten.
|
// warum Leute Auto-Refresh abschalten.
|
||||||
|
|
||||||
const SPEICHER_SCHLUESSEL = 'd4rk_media.auffrischen'
|
|
||||||
const TAKT_MS = 5000
|
const TAKT_MS = 5000
|
||||||
|
|
||||||
const gleich = (a: Stand | null, b: Stand | null) =>
|
const gleich = (a: Stand | null, b: Stand | null) =>
|
||||||
@@ -33,16 +33,12 @@ export function useAuffrischen({
|
|||||||
* Hinweis. Etwa: eine Auswahl steht, oder ein Bild ist offen. */
|
* Hinweis. Etwa: eine Auswahl steht, oder ein Bild ist offen. */
|
||||||
blockiert: boolean
|
blockiert: boolean
|
||||||
}) {
|
}) {
|
||||||
const [an, setAn] = useState(() => localStorage.getItem(SPEICHER_SCHLUESSEL) !== 'aus')
|
const [an, setAn] = useMerker('auffrischen', true)
|
||||||
const [wartet, setWartet] = useState(false)
|
const [wartet, setWartet] = useState(false)
|
||||||
const bekannt = useRef<Stand | null>(null)
|
const bekannt = useRef<Stand | null>(null)
|
||||||
const blockiertRef = useRef(blockiert)
|
const blockiertRef = useRef(blockiert)
|
||||||
blockiertRef.current = blockiert
|
blockiertRef.current = blockiert
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem(SPEICHER_SCHLUESSEL, an ? 'an' : 'aus')
|
|
||||||
}, [an])
|
|
||||||
|
|
||||||
/** Nach eigenem Zutun (Löschen, Hochladen) gleichzuziehen — sonst meldet
|
/** Nach eigenem Zutun (Löschen, Hochladen) gleichzuziehen — sonst meldet
|
||||||
* der Fühler die eigene Änderung als „neu". */
|
* der Fühler die eigene Änderung als „neu". */
|
||||||
const merken = useCallback(async () => {
|
const merken = useCallback(async () => {
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { ChevronDown, LogOut, UserCog } from 'lucide-react'
|
||||||
|
import { run, useStore } from '../store'
|
||||||
|
import { gehZu } from '../router'
|
||||||
|
import { Avatar } from './Avatar'
|
||||||
|
|
||||||
|
/** Das Konto hinter dem eigenen Namen.
|
||||||
|
*
|
||||||
|
* Vorher war es ein eigener Reiter neben Galerie und Verlauf — und stand damit
|
||||||
|
* gleichrangig neben Dingen, die man täglich benutzt, obwohl man sein Passwort
|
||||||
|
* zweimal im Jahr ändert. Hinter dem Namen ist der Platz, an dem jeder danach
|
||||||
|
* sucht.
|
||||||
|
*
|
||||||
|
* Der Pfad /konto bleibt bestehen: ein Lesezeichen darauf soll nicht ins Leere
|
||||||
|
* führen, nur weil der Reiter verschwunden ist. */
|
||||||
|
export function Kontomenue() {
|
||||||
|
const user = useStore((s) => s.user)
|
||||||
|
const logout = useStore((s) => s.logout)
|
||||||
|
const [offen, setOffen] = useState(false)
|
||||||
|
const huelle = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Klick daneben und Escape schließen. Ein Menü, das offen bleibt, bis man
|
||||||
|
// genau den Knopf wieder trifft, ist ein Menü, das im Weg steht.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!offen) return
|
||||||
|
const daneben = (e: MouseEvent) => {
|
||||||
|
if (!huelle.current?.contains(e.target as Node)) setOffen(false)
|
||||||
|
}
|
||||||
|
const taste = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOffen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', daneben)
|
||||||
|
window.addEventListener('keydown', taste)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', daneben)
|
||||||
|
window.removeEventListener('keydown', taste)
|
||||||
|
}
|
||||||
|
}, [offen])
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const eintrag =
|
||||||
|
'flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-schrift-leise ' +
|
||||||
|
'transition-colors hover:bg-flaeche-hoch hover:text-schrift'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={huelle} className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setOffen((o) => !o)}
|
||||||
|
aria-expanded={offen}
|
||||||
|
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-sm text-schrift-leise
|
||||||
|
transition-colors hover:bg-flaeche-hoch hover:text-schrift"
|
||||||
|
>
|
||||||
|
<Avatar name={user.username} url={user.avatar} groesse={26} />
|
||||||
|
<span className="hidden max-w-32 truncate sm:inline">{user.username}</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform ${offen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{offen && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 z-50 mt-1 w-56 overflow-hidden rounded-lg border
|
||||||
|
border-rand bg-flaeche shadow-xl shadow-black/40"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-rand px-3 py-2.5">
|
||||||
|
<Avatar name={user.username} url={user.avatar} groesse={32} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium">{user.username}</p>
|
||||||
|
<p className="text-[11px] text-schrift-leise">
|
||||||
|
{user.discord ? 'über Discord' : 'mit Passwort'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={eintrag}
|
||||||
|
onClick={() => {
|
||||||
|
setOffen(false)
|
||||||
|
gehZu('/konto')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserCog size={15} />
|
||||||
|
Konto und Passwort
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`${eintrag} border-t border-rand hover:text-schlecht`}
|
||||||
|
onClick={() => {
|
||||||
|
setOffen(false)
|
||||||
|
void run(logout, 'Abgemeldet.')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut size={15} />
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/** Ein Zustand, der einen Neuladevorgang überlebt.
|
||||||
|
*
|
||||||
|
* Für ANSICHTSSACHEN gedacht: wie viele Kacheln je Seite, wonach sortiert
|
||||||
|
* wird. Wer 120 pro Seite einstellt, meint das dauerhaft und nicht bis zum
|
||||||
|
* nächsten F5.
|
||||||
|
*
|
||||||
|
* BEWUSST NICHT für Filter. Ein gemerkter Ordner- oder Artfilter führt zu der
|
||||||
|
* Frage „warum sehe ich nichts", und die Antwort steht dann in einer Leiste,
|
||||||
|
* auf die niemand schaut, weil er sie nicht selbst gesetzt hat. Was die
|
||||||
|
* Ansicht formt, wird gemerkt; was die Menge einschränkt, nicht.
|
||||||
|
*
|
||||||
|
* localStorage kann werfen — im privaten Fenster mancher Browser, oder wenn
|
||||||
|
* der Speicher voll ist. Dann verhält sich der Haken wie ein gewöhnlicher
|
||||||
|
* Zustand, statt die Seite mitzureißen. */
|
||||||
|
export function useMerker<T>(schluessel: string, standard: T) {
|
||||||
|
const voll = `d4rk_media.${schluessel}`
|
||||||
|
|
||||||
|
const [wert, setWert] = useState<T>(() => {
|
||||||
|
try {
|
||||||
|
const roh = localStorage.getItem(voll)
|
||||||
|
return roh === null ? standard : (JSON.parse(roh) as T)
|
||||||
|
} catch {
|
||||||
|
return standard
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(voll, JSON.stringify(wert))
|
||||||
|
} catch {
|
||||||
|
/* dann eben nur für diese Sitzung */
|
||||||
|
}
|
||||||
|
}, [voll, wert])
|
||||||
|
|
||||||
|
return [wert, setWert] as const
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ import { Hochladen } from '../components/Hochladen'
|
|||||||
import { Ansehen } from '../components/Ansehen'
|
import { Ansehen } from '../components/Ansehen'
|
||||||
import { Verschieben } from '../components/Verschieben'
|
import { Verschieben } from '../components/Verschieben'
|
||||||
import { useAuffrischen } from '../auffrischen'
|
import { useAuffrischen } from '../auffrischen'
|
||||||
|
import { useMerker } from '../merker'
|
||||||
|
|
||||||
const SEITENGROESSEN = [30, 60, 120, 200] as const
|
const SEITENGROESSEN = [30, 60, 120, 200] as const
|
||||||
|
|
||||||
@@ -49,9 +50,11 @@ export function Galerie() {
|
|||||||
const [seite, setSeite] = useState(0)
|
const [seite, setSeite] = useState(0)
|
||||||
const [art, setArt] = useState<Art>('')
|
const [art, setArt] = useState<Art>('')
|
||||||
const [arten, setArten] = useState<Arten | null>(null)
|
const [arten, setArten] = useState<Arten | null>(null)
|
||||||
const [proSeite, setProSeite] = useState(60)
|
// Ansichtssachen ueberleben das Neuladen -- wer 120 einstellt, meint das
|
||||||
const [sortierung, setSortierung] = useState<Sortierung>('geaendert')
|
// dauerhaft und nicht bis zum naechsten F5.
|
||||||
const [richtung, setRichtung] = useState<Richtung>('ab')
|
const [proSeite, setProSeite] = useMerker('galerie.proSeite', 60)
|
||||||
|
const [sortierung, setSortierung] = useMerker<Sortierung>('galerie.sort', 'geaendert')
|
||||||
|
const [richtung, setRichtung] = useMerker<Richtung>('galerie.dir', 'ab')
|
||||||
const [daten, setDaten] = useState<MediaPage | null>(null)
|
const [daten, setDaten] = useState<MediaPage | null>(null)
|
||||||
const [laedt, setLaedt] = useState(true)
|
const [laedt, setLaedt] = useState(true)
|
||||||
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
const [gewaehlt, setGewaehlt] = useState<Set<number>>(new Set())
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useStore } from '../store'
|
|||||||
import { bytes, zeit } from '../format'
|
import { bytes, zeit } from '../format'
|
||||||
import { Karte, Knopf, Leer } from '../components/Bausteine'
|
import { Karte, Knopf, Leer } from '../components/Bausteine'
|
||||||
import { useAuffrischen } from '../auffrischen'
|
import { useAuffrischen } from '../auffrischen'
|
||||||
|
import { useMerker } from '../merker'
|
||||||
import { Avatar } from '../components/Avatar'
|
import { Avatar } from '../components/Avatar'
|
||||||
|
|
||||||
const SEITENGROESSEN = [25, 50, 100, 200] as const
|
const SEITENGROESSEN = [25, 50, 100, 200] as const
|
||||||
@@ -34,7 +35,7 @@ export function Verlauf() {
|
|||||||
const [seite, setSeite] = useState(0)
|
const [seite, setSeite] = useState(0)
|
||||||
const [art, setArt] = useState<EventKind | ''>('')
|
const [art, setArt] = useState<EventKind | ''>('')
|
||||||
const [wer, setWer] = useState('')
|
const [wer, setWer] = useState('')
|
||||||
const [proSeite, setProSeite] = useState(50)
|
const [proSeite, setProSeite] = useMerker('verlauf.proSeite', 50)
|
||||||
const [suche, setSuche] = useState('')
|
const [suche, setSuche] = useState('')
|
||||||
|
|
||||||
// Wie in der Galerie: erst tippen lassen, dann fragen. Sonst geht bei
|
// Wie in der Galerie: erst tippen lassen, dann fragen. Sonst geht bei
|
||||||
|
|||||||
Reference in New Issue
Block a user