diff --git a/web/src/App.tsx b/web/src/App.tsx index 2abc566..df08d0e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { fetchChannels, fetchSounds, playSound, setVolumeLive } from './api'; import type { VoiceChannelInfo, Sound } from './types'; +import { getCookie, setCookie } from './cookies'; export default function App() { const [sounds, setSounds] = useState([]); @@ -13,6 +14,7 @@ export default function App() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [volume, setVolume] = useState(1); + const [favs, setFavs] = useState>({}); useEffect(() => { (async () => { @@ -44,6 +46,19 @@ export default function App() { })(); }, [activeFolder, query]); + // Favoriten aus Cookie laden + useEffect(() => { + const c = getCookie('favs'); + if (c) { + try { setFavs(JSON.parse(c)); } catch {} + } + }, []); + + // Favoriten persistieren + useEffect(() => { + try { setCookie('favs', JSON.stringify(favs)); } catch {} + }, [favs]); + useEffect(() => { if (selected) localStorage.setItem('selectedChannel', selected); }, [selected]); @@ -139,11 +154,25 @@ export default function App() { {error &&
{error}
}
- {filtered.map((s) => ( - - ))} + {filtered.map((s) => { + const key = `${s.relativePath ?? s.fileName}`; + const isFav = !!favs[key]; + return ( +
+ + +
+ ); + })} {filtered.length === 0 &&
Keine Sounds gefunden.
}
{/* footer counter entfällt, da oben sichtbar */} diff --git a/web/src/cookies.ts b/web/src/cookies.ts new file mode 100644 index 0000000..a4b2f2e --- /dev/null +++ b/web/src/cookies.ts @@ -0,0 +1,18 @@ +export function setCookie(name: string, value: string, days = 365): void { + const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString(); + document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`; +} + +export function getCookie(name: string): string | null { + const key = `${encodeURIComponent(name)}=`; + const parts = document.cookie.split(';'); + for (const part of parts) { + const trimmed = part.trim(); + if (trimmed.startsWith(key)) { + return decodeURIComponent(trimmed.slice(key.length)); + } + } + return null; +} + + diff --git a/web/src/styles.css b/web/src/styles.css index 5ba451a..4ad9788 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -33,7 +33,8 @@ header p { opacity: .8; } .error { background: rgba(255, 99, 99, .12); color: #ffd1d1; border: 1px solid rgba(255, 99, 99, .3); padding: 10px 12px; border-radius: 10px; margin-bottom: 12px; } -.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; } +.sound-wrap { position: relative; } .sound { padding: 18px 16px; border-radius: 14px; @@ -48,6 +49,22 @@ header p { opacity: .8; } .sound:hover { filter: brightness(1.06); } .sound:disabled { opacity: 0.6; cursor: not-allowed; } +.fav { + position: absolute; + top: 8px; + right: 10px; + background: rgba(0,0,0,.25); + color: #fff; + border: 1px solid rgba(255,255,255,.2); + border-radius: 999px; + width: 28px; + height: 28px; + display: grid; + place-items: center; + cursor: pointer; +} +.fav.active { background: #eab308; color: #111; border-color: transparent; } + .hint { opacity: .7; padding: 24px 0; } /* footer-info entfernt */