Compare commits
3 Commits
v0.1.6.0
...
50ca51b143
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50ca51b143 | ||
|
|
afe6e12afc | ||
|
|
91b12ad859 |
28
README.md
28
README.md
@@ -15,6 +15,7 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- Bearbeitung von Metadaten.
|
- Bearbeitung von Metadaten.
|
||||||
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
- Sortierbare Song-Bibliothek (Titel, Interpret, Hinzugefügt am, Erscheinungsjahr, Aktivierungen, Rating).
|
||||||
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
- Play/Pause-Funktion zum Vorhören in der Bibliothek.
|
||||||
|
- **Kuratoren-Verwaltung:** Erstellen und Verwalten von Kurator-Accounts mit Zuweisung zu Genres und Specials.
|
||||||
- **Cover Art:**
|
- **Cover Art:**
|
||||||
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
- Automatische Extraktion von Cover-Bildern aus MP3-Dateien.
|
||||||
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
- Anzeige des Covers nach Spielende (Sieg/Niederlage).
|
||||||
@@ -42,7 +43,6 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- Live-Vorschau beim Hovern über die Waveform.
|
- Live-Vorschau beim Hovern über die Waveform.
|
||||||
- Playback-Cursor zeigt aktuelle Abspielposition.
|
- Playback-Cursor zeigt aktuelle Abspielposition.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
- Einzelne Segmente zum Testen abspielen.
|
||||||
- Einzelne Segmente zum Testen abspielen.
|
|
||||||
- Manuelle Speicherung mit visueller Bestätigung.
|
- Manuelle Speicherung mit visueller Bestätigung.
|
||||||
- **News & Announcements:**
|
- **News & Announcements:**
|
||||||
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
- Integriertes News-System für Ankündigungen (z.B. neue Specials, Features).
|
||||||
@@ -51,6 +51,24 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
- **Featured News:** Hervorhebung wichtiger Ankündigungen.
|
||||||
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
- Special-Verknüpfung: Direkte Links zu Specials in News-Beiträgen.
|
||||||
- Verwaltung über das Admin-Dashboard.
|
- Verwaltung über das Admin-Dashboard.
|
||||||
|
- **Kurator-System:**
|
||||||
|
- **Kurator-Accounts:** Separate Login-Accounts für Kuratoren (nicht Admins).
|
||||||
|
- **Genre- & Special-Zuweisung:** Kuratoren können einzelnen Genres oder Specials zugewiesen werden.
|
||||||
|
- **Global-Kuratoren:** Optionale globale Kuratoren, die für alle Rätsel zuständig sind.
|
||||||
|
- **Kurator-Dashboard:** Eigene Dashboard-Seite (`/curator` oder `/de/curator`, `/en/curator`) für Kuratoren.
|
||||||
|
- **Song-Verwaltung:** Kuratoren können Songs hochladen, bearbeiten und Genres/Specials zuweisen.
|
||||||
|
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
|
||||||
|
- **Spieler-Kommentare:**
|
||||||
|
- **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden.
|
||||||
|
- **Automatische Zuordnung:** Kommentare werden automatisch an relevante Kuratoren verteilt (Genre-Kuratoren, Special-Kuratoren, Global-Kuratoren).
|
||||||
|
- **Rate-Limiting:** Pro Spieler nur ein Kommentar pro Puzzle möglich.
|
||||||
|
- **Kontext-Informationen:** Kommentare enthalten vollständigen Rätsel-Kontext (Hördle #, Genre/Special, Titel/Artist).
|
||||||
|
- **Kommentar-Verwaltung:** Kuratoren sehen Kommentare in ihrem Dashboard mit Badge für neue/ungelesene Nachrichten.
|
||||||
|
- **Analytics:**
|
||||||
|
- **Plausible Analytics:** Integration mit Plausible Analytics für anonyme Nutzungsstatistiken.
|
||||||
|
- **Automatisches Domain-Tracking:** Unterstützt mehrere Domains mit automatischer Erkennung.
|
||||||
|
- **Privacy-First:** Keine Cookies, kein Cross-Site-Tracking.
|
||||||
|
- 👉 **[Plausible Setup-Dokumentation](docs/PLAUSIBLE_SETUP.md)**
|
||||||
|
|
||||||
## Internationalisierung (i18n)
|
## Internationalisierung (i18n)
|
||||||
|
|
||||||
@@ -139,6 +157,7 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
- `GOTIFY_URL`: URL deines Gotify Servers (z.B. `https://gotify.example.com`)
|
||||||
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
- `GOTIFY_APP_TOKEN`: App Token für Gotify (z.B. `A...`)
|
||||||
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
- `OPENROUTER_API_KEY`: API-Key für OpenRouter (für KI-Kategorisierung, optional)
|
||||||
|
- `NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC`: URL zum Plausible Analytics Script (z.B. `https://plausible.example.com/js/script.js`, optional)
|
||||||
|
|
||||||
2. **Starten:**
|
2. **Starten:**
|
||||||
```bash
|
```bash
|
||||||
@@ -156,7 +175,12 @@ Das Projekt ist für den Betrieb mit Docker optimiert.
|
|||||||
- URL: `/de/admin` oder `/en/admin`
|
- URL: `/de/admin` oder `/en/admin`
|
||||||
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
- Standard-Passwort: `admin123` (Bitte in `docker-compose.yml` ändern! Muss als Hash hinterlegt werden.)
|
||||||
|
|
||||||
5. **Special Curation & Scheduling verwenden:**
|
5. **Kurator-Zugang:**
|
||||||
|
- URL: `/de/curator` oder `/en/curator`
|
||||||
|
- Kurator-Accounts werden vom Admin erstellt und verwaltet.
|
||||||
|
- Kuratoren können Songs hochladen und verwalten, sowie Kommentare von Spielern einsehen.
|
||||||
|
|
||||||
|
6. **Special Curation & Scheduling verwenden:**
|
||||||
- Erstelle ein Special im Admin-Dashboard:
|
- Erstelle ein Special im Admin-Dashboard:
|
||||||
- Gib Name, Max Attempts und Unlock Steps ein.
|
- Gib Name, Max Attempts und Unlock Steps ein.
|
||||||
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
- **Optional:** Setze ein Startdatum (Launch Date) und Enddatum.
|
||||||
|
|||||||
@@ -35,11 +35,15 @@ function curatorCanEditSong(context: StaffContext, song: any, assignments: { gen
|
|||||||
// - `SpecialSong` (mit `specialId`)
|
// - `SpecialSong` (mit `specialId`)
|
||||||
// - `SpecialSong` (mit Relation `special.id`)
|
// - `SpecialSong` (mit Relation `special.id`)
|
||||||
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
|
// sein. Wir normalisieren hier auf reine Zahlen-IDs.
|
||||||
|
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||||
|
// Daher zuerst specialId oder special.id prüfen.
|
||||||
const songSpecialIds = (song.specials || [])
|
const songSpecialIds = (song.specials || [])
|
||||||
.map((s: any) => {
|
.map((s: any) => {
|
||||||
if (s?.id != null) return s.id;
|
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||||
if (s?.specialId != null) return s.specialId;
|
if (s?.specialId != null) return s.specialId;
|
||||||
if (s?.special?.id != null) return s.special.id;
|
if (s?.special?.id != null) return s.special.id;
|
||||||
|
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||||
|
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
.filter((id: any): id is number => typeof id === 'number');
|
.filter((id: any): id is number => typeof id === 'number');
|
||||||
@@ -59,11 +63,15 @@ function curatorCanDeleteSong(context: StaffContext, song: any, assignments: { g
|
|||||||
if (context.role === 'admin') return true;
|
if (context.role === 'admin') return true;
|
||||||
|
|
||||||
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
const songGenreIds = (song.genres || []).map((g: any) => g.id);
|
||||||
|
// WICHTIG: Bei SpecialSong-Objekten ist s.id die SpecialSong-ID, nicht die Special-ID!
|
||||||
|
// Daher zuerst specialId oder special.id prüfen.
|
||||||
const songSpecialIds = (song.specials || [])
|
const songSpecialIds = (song.specials || [])
|
||||||
.map((s: any) => {
|
.map((s: any) => {
|
||||||
if (s?.id != null) return s.id;
|
// Priorität: specialId oder special.id (die tatsächliche Special-ID)
|
||||||
if (s?.specialId != null) return s.specialId;
|
if (s?.specialId != null) return s.specialId;
|
||||||
if (s?.special?.id != null) return s.special.id;
|
if (s?.special?.id != null) return s.special.id;
|
||||||
|
// Nur wenn es direkt ein Special-Objekt ist (nicht SpecialSong), verwende s.id
|
||||||
|
if (s?.id != null && s?.specialId == null && s?.special == null) return s.id;
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
.filter((id: any): id is number => typeof id === 'number');
|
.filter((id: any): id is number => typeof id === 'number');
|
||||||
@@ -382,7 +390,11 @@ export async function PUT(request: Request) {
|
|||||||
where: { id: Number(id) },
|
where: { id: Number(id) },
|
||||||
include: {
|
include: {
|
||||||
genres: true,
|
genres: true,
|
||||||
specials: true,
|
specials: {
|
||||||
|
include: {
|
||||||
|
special: true
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1316,6 +1316,41 @@ export default function CuratorPageClient() {
|
|||||||
: genre.name?.de ?? genre.name?.en}
|
: genre.name?.de ?? genre.name?.en}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
{specials
|
||||||
|
.filter(s => curatorInfo?.specialIds?.includes(s.id))
|
||||||
|
.map(special => (
|
||||||
|
<label
|
||||||
|
key={special.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
padding: '0.15rem 0.4rem',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: editSpecialIds.includes(special.id)
|
||||||
|
? '#fee2e2'
|
||||||
|
: '#f3f4f6',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editSpecialIds.includes(special.id)}
|
||||||
|
onChange={() =>
|
||||||
|
setEditSpecialIds(prev =>
|
||||||
|
prev.includes(special.id)
|
||||||
|
? prev.filter(id => id !== special.id)
|
||||||
|
: [...prev, special.id]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
★{' '}
|
||||||
|
{typeof special.name === 'string'
|
||||||
|
? special.name
|
||||||
|
: special.name?.de ?? special.name?.en}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
|
||||||
{song.genres
|
{song.genres
|
||||||
@@ -1337,21 +1372,26 @@ export default function CuratorPageClient() {
|
|||||||
: g.name?.de ?? g.name?.en}
|
: g.name?.de ?? g.name?.en}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{song.specials.map(s => (
|
{song.specials
|
||||||
<span
|
.filter(
|
||||||
key={`s-${s.id}`}
|
s => !curatorInfo?.specialIds?.includes(s.id)
|
||||||
style={{
|
)
|
||||||
padding: '0.1rem 0.4rem',
|
.map(s => (
|
||||||
borderRadius: '999px',
|
<span
|
||||||
background: '#fee2e2',
|
key={`fixed-s-${s.id}`}
|
||||||
fontSize: '0.8rem',
|
style={{
|
||||||
}}
|
padding: '0.1rem 0.4rem',
|
||||||
>
|
borderRadius: '999px',
|
||||||
{typeof s.name === 'string'
|
background: '#fee2e2',
|
||||||
? s.name
|
fontSize: '0.8rem',
|
||||||
: s.name?.de ?? s.name?.en}
|
}}
|
||||||
</span>
|
>
|
||||||
))}
|
★{' '}
|
||||||
|
{typeof s.name === 'string'
|
||||||
|
? s.name
|
||||||
|
: s.name?.de ?? s.name?.en}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user