feat: Curator-Hilfe-System implementiert

- Hilfe-Seite /curator/help mit vollständiger Dokumentation (de/en)
- HelpTooltip-Komponente mit Hover- und Click-Modi
- Tooltips bei allen wichtigen Dashboard-Bereichen:
  * Dashboard-Übersicht
  * Upload-Bereich & Genre-Zuweisung
  * Track-Liste (Suche, Filter, Batch-Edit)
  * Kommentar-Verwaltung
- Prominenter Hilfe-Button im Header
- Umfassende Übersetzungen für alle Hilfe-Texte
- Fix: TypeScript-Fehler in batch route behoben
- Fix: Doppelter Browser-Tooltip entfernt (nur noch custom Tooltip)
This commit is contained in:
Hördle Bot
2025-12-04 01:07:45 +01:00
parent a61caa2d13
commit 65425ac15c
8 changed files with 694 additions and 94 deletions

View File

@@ -0,0 +1,8 @@
'use client';
import CuratorHelpInner from '../../../curator/help/page';
export default function CuratorHelpPage() {
return <CuratorHelpInner />;
}

View File

@@ -81,11 +81,12 @@ export async function POST(request: Request) {
let assignments: { genreIds: Set<number>; specialIds: Set<number> } | null = null;
if (context.role === 'curator') {
assignments = await getCuratorAssignments(context.curator.id);
const curatorAssignments = await getCuratorAssignments(context.curator.id);
assignments = curatorAssignments;
// Validate genre/special toggles are within curator's assignments
if (hasGenreToggle) {
const invalidGenre = genreToggleIds.some((id: number) => !assignments.genreIds.has(id));
const invalidGenre = genreToggleIds.some((id: number) => !curatorAssignments.genreIds.has(id));
if (invalidGenre) {
return NextResponse.json(
{ error: 'Curators may only toggle their own genres' },
@@ -95,7 +96,7 @@ export async function POST(request: Request) {
}
if (hasSpecialToggle) {
const invalidSpecial = specialToggleIds.some((id: number) => !assignments.specialIds.has(id));
const invalidSpecial = specialToggleIds.some((id: number) => !curatorAssignments.specialIds.has(id));
if (invalidSpecial) {
return NextResponse.json(
{ error: 'Curators may only toggle their own specials' },

View File

@@ -2,6 +2,8 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslations, useLocale } from 'next-intl';
import { Link } from '@/lib/navigation';
import HelpTooltip from '@/components/HelpTooltip';
interface Genre {
id: number;
@@ -83,6 +85,8 @@ function getCuratorUploadHeaders() {
export default function CuratorPageClient() {
const t = useTranslations('Curator');
const tNav = useTranslations('Navigation');
const tHelp = useTranslations('CuratorHelp');
const locale = useLocale();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -787,7 +791,14 @@ export default function CuratorPageClient() {
}}
>
<div>
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>Kuratoren-Dashboard</h1>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>Kuratoren-Dashboard</h1>
<HelpTooltip
shortText={tHelp('tooltipDashboardShort')}
longText={tHelp('tooltipDashboardLong')}
position="bottom"
/>
</div>
{curatorInfo && (
<p style={{ color: '#4b5563', fontSize: '0.9rem' }}>
{t('loggedInAs', { username: curatorInfo.username })}
@@ -795,20 +806,38 @@ export default function CuratorPageClient() {
</p>
)}
</div>
<button
type="button"
onClick={handleLogout}
style={{
padding: '0.5rem 1rem',
background: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
}}
>
{t('logout')}
</button>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<Link
href={`/${locale}/curator/help`}
style={{
padding: '0.5rem 1rem',
background: '#3b82f6',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontSize: '0.9rem',
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
}}
>
{tHelp('helpButton')}
</Link>
<button
type="button"
onClick={handleLogout}
style={{
padding: '0.5rem 1rem',
background: '#6b7280',
color: 'white',
border: 'none',
borderRadius: '0.375rem',
cursor: 'pointer',
}}
>
{t('logout')}
</button>
</div>
</header>
{loading && <p>{t('loadingData')}</p>}
@@ -825,9 +854,16 @@ export default function CuratorPageClient() {
<section style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.75rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: 0 }}>
{t('commentsTitle')} ({comments.length})
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: 0 }}>
{t('commentsTitle')} ({comments.length})
</h2>
<HelpTooltip
shortText={tHelp('tooltipCommentsShort')}
longText={tHelp('tooltipCommentsLong')}
position="right"
/>
</div>
{hasUnread && (
<span style={{
padding: '0.25rem 0.75rem',
@@ -978,7 +1014,14 @@ export default function CuratorPageClient() {
})()}
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>{t('uploadSectionTitle')}</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.25rem', margin: 0 }}>{t('uploadSectionTitle')}</h2>
<HelpTooltip
shortText={tHelp('tooltipUploadShort')}
longText={tHelp('tooltipUploadLong')}
position="right"
/>
</div>
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
{t('uploadSectionDescription')}
</p>
@@ -1078,7 +1121,14 @@ export default function CuratorPageClient() {
)}
<div>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>{t('assignGenresLabel')}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<div style={{ fontWeight: 500 }}>{t('assignGenresLabel')}</div>
<HelpTooltip
shortText={tHelp('tooltipGenreAssignmentShort')}
longText={tHelp('tooltipGenreAssignmentLong')}
position="right"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres
.filter(g => curatorInfo?.genreIds?.includes(g.id))
@@ -1154,70 +1204,91 @@ export default function CuratorPageClient() {
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1.25rem', marginBottom: '0.75rem' }}>
{t('tracklistTitle', { count: filteredSongs.length })}
</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
<h2 style={{ fontSize: '1.25rem', margin: 0 }}>
{t('tracklistTitle', { count: filteredSongs.length })}
</h2>
<HelpTooltip
shortText={tHelp('tooltipTracklistShort')}
longText={tHelp('tooltipTracklistLong')}
position="right"
/>
</div>
<p style={{ marginBottom: '0.75rem', color: '#4b5563', fontSize: '0.9rem' }}>
{t('tracklistDescription')}
</p>
{/* Suche & Filter */}
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchQuery}
onChange={e => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
style={{
flex: '1',
minWidth: '200px',
padding: '0.4rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
}}
/>
<select
value={selectedFilter}
onChange={e => {
setSelectedFilter(e.target.value);
setCurrentPage(1);
}}
style={{
minWidth: '180px',
padding: '0.4rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
}}
>
<option value="">{t('filterAll')}</option>
<option value="no-global">{t('filterNoGlobal')}</option>
<optgroup label="Genres">
{genres
.filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => (
<option key={genre.id} value={`genre:${genre.id}`}>
{typeof genre.name === 'string'
? genre.name
: genre.name?.de ?? genre.name?.en}
</option>
))}
</optgroup>
<optgroup label="Specials">
{specials
.filter(s => curatorInfo?.specialIds?.includes(s.id))
.map(special => (
<option key={special.id} value={`special:${special.id}`}>
{' '}
{typeof special.name === 'string'
? special.name
: special.name?.de ?? special.name?.en}
</option>
))}
</optgroup>
</select>
<div style={{ marginBottom: '0.75rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: '1', minWidth: '200px', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<input
type="text"
placeholder={t('searchPlaceholder')}
value={searchQuery}
onChange={e => {
setSearchQuery(e.target.value);
setCurrentPage(1);
}}
style={{
flex: '1',
minWidth: '200px',
padding: '0.4rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
}}
/>
<HelpTooltip
shortText={tHelp('tooltipSearchShort')}
longText={tHelp('tooltipSearchLong')}
position="top"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
<select
value={selectedFilter}
onChange={e => {
setSelectedFilter(e.target.value);
setCurrentPage(1);
}}
style={{
minWidth: '180px',
padding: '0.4rem 0.6rem',
borderRadius: '0.25rem',
border: '1px solid #d1d5db',
}}
>
<option value="">{t('filterAll')}</option>
<option value="no-global">{t('filterNoGlobal')}</option>
<optgroup label="Genres">
{genres
.filter(g => curatorInfo?.genreIds?.includes(g.id))
.map(genre => (
<option key={genre.id} value={`genre:${genre.id}`}>
{typeof genre.name === 'string'
? genre.name
: genre.name?.de ?? genre.name?.en}
</option>
))}
</optgroup>
<optgroup label="Specials">
{specials
.filter(s => curatorInfo?.specialIds?.includes(s.id))
.map(special => (
<option key={special.id} value={`special:${special.id}`}>
{' '}
{typeof special.name === 'string'
? special.name
: special.name?.de ?? special.name?.en}
</option>
))}
</optgroup>
</select>
<HelpTooltip
shortText={tHelp('tooltipFilterShort')}
longText={tHelp('tooltipFilterLong')}
position="top"
/>
</div>
{(searchQuery || selectedFilter) && (
<button
type="button"
@@ -1256,9 +1327,16 @@ export default function CuratorPageClient() {
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<strong style={{ fontSize: '1rem' }}>
{t('batchEditTitle') || `Batch Edit: ${selectedSongIds.size} ${selectedSongIds.size === 1 ? 'song' : 'songs'} selected`}
</strong>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<strong style={{ fontSize: '1rem' }}>
{t('batchEditTitle') || `Batch Edit: ${selectedSongIds.size} ${selectedSongIds.size === 1 ? 'song' : 'songs'} selected`}
</strong>
<HelpTooltip
shortText={tHelp('tooltipBatchEditShort')}
longText={tHelp('tooltipBatchEditLong')}
position="right"
/>
</div>
<button
type="button"
onClick={clearSelection}
@@ -1278,9 +1356,16 @@ export default function CuratorPageClient() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* Genre Toggle */}
<div>
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
{t('batchToggleGenres') || 'Toggle Genres'}
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<label style={{ display: 'block', fontWeight: 500, fontSize: '0.9rem', margin: 0 }}>
{t('batchToggleGenres') || 'Toggle Genres'}
</label>
<HelpTooltip
shortText={tHelp('tooltipBatchGenreToggleShort')}
longText={tHelp('tooltipBatchGenreToggleLong')}
position="right"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{genres
.filter(g => curatorInfo?.genreIds?.includes(g.id))
@@ -1319,9 +1404,16 @@ export default function CuratorPageClient() {
{/* Special Toggle */}
<div>
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
{t('batchToggleSpecials') || 'Toggle Specials'}
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<label style={{ display: 'block', fontWeight: 500, fontSize: '0.9rem', margin: 0 }}>
{t('batchToggleSpecials') || 'Toggle Specials'}
</label>
<HelpTooltip
shortText={tHelp('tooltipBatchSpecialToggleShort')}
longText={tHelp('tooltipBatchSpecialToggleLong')}
position="right"
/>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{specials
.filter(s => curatorInfo?.specialIds?.includes(s.id))
@@ -1361,9 +1453,16 @@ export default function CuratorPageClient() {
{/* Artist Change */}
<div>
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.25rem', fontSize: '0.9rem' }}>
{t('batchChangeArtist') || 'Change Artist'}
</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<label style={{ display: 'block', fontWeight: 500, fontSize: '0.9rem', margin: 0 }}>
{t('batchChangeArtist') || 'Change Artist'}
</label>
<HelpTooltip
shortText={tHelp('tooltipBatchArtistShort')}
longText={tHelp('tooltipBatchArtistLong')}
position="right"
/>
</div>
<input
type="text"
value={batchArtist}

View File

@@ -0,0 +1,149 @@
'use client';
import { useTranslations, useLocale } from 'next-intl';
import { Link } from '@/lib/navigation';
export default function CuratorHelpClient() {
const t = useTranslations('CuratorHelp');
const locale = useLocale();
return (
<main style={{ maxWidth: '960px', margin: '2rem auto', padding: '1rem' }}>
<header style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 style={{ fontSize: '1.75rem', marginBottom: '0.25rem' }}>{t('title')}</h1>
<Link
href={`/${locale}/curator`}
style={{
padding: '0.5rem 1rem',
background: '#6b7280',
color: 'white',
textDecoration: 'none',
borderRadius: '0.375rem',
fontSize: '0.9rem',
}}
>
{t('backToDashboard')}
</Link>
</div>
</header>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Einführung */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('introductionTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<p style={{ marginBottom: '1rem' }}>{t('introductionText')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('permissionsTitle')}</h3>
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}>{t('permission1')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('permission2')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('permission3')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('permission4')}</li>
</ul>
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#fef3c7', borderRadius: '0.375rem', border: '1px solid #fbbf24' }}>
<strong>{t('note')}:</strong> {t('permissionNote')}
</p>
</div>
</section>
{/* Song-Upload */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('uploadTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('uploadStepsTitle')}</h3>
<ol style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep1')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep2')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep3')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadStep4')}</li>
</ol>
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('uploadBestPracticesTitle')}</h3>
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice1')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice2')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('uploadBestPractice3')}</li>
</ul>
<p style={{ marginTop: '1rem', padding: '0.75rem', background: '#dbeafe', borderRadius: '0.375rem', border: '1px solid #3b82f6' }}>
<strong>{t('tip')}:</strong> {t('uploadTip')}
</p>
</div>
</section>
{/* Song-Bearbeitung */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('editingTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('singleEditTitle')}</h3>
<p style={{ marginBottom: '1rem' }}>{t('singleEditText')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('batchEditTitle')}</h3>
<p style={{ marginBottom: '1rem' }}>{t('batchEditText')}</p>
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature1')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature2')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature3')}</li>
<li style={{ marginBottom: '0.5rem' }}>{t('batchEditFeature4')}</li>
</ul>
<h3 style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.75rem' }}>{t('genreSpecialAssignmentTitle')}</h3>
<p style={{ marginBottom: '1rem' }}>{t('genreSpecialAssignmentText')}</p>
</div>
</section>
{/* Kommentar-Verwaltung */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('commentsTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<p style={{ marginBottom: '1rem' }}>{t('commentsText')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('commentsActionsTitle')}</h3>
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.5rem' }}><strong>{t('markAsRead')}:</strong> {t('markAsReadText')}</li>
<li style={{ marginBottom: '0.5rem' }}><strong>{t('archive')}:</strong> {t('archiveText')}</li>
</ul>
</div>
</section>
{/* Best Practices */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('bestPracticesTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<ul style={{ marginLeft: '1.5rem', marginBottom: '1rem' }}>
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice1')}</li>
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice2')}</li>
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice3')}</li>
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice4')}</li>
<li style={{ marginBottom: '0.75rem' }}>{t('bestPractice5')}</li>
</ul>
</div>
</section>
{/* Troubleshooting */}
<section>
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', borderBottom: '2px solid #e5e7eb', paddingBottom: '0.5rem' }}>
{t('troubleshootingTitle')}
</h2>
<div style={{ fontSize: '0.95rem', lineHeight: '1.7' }}>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ1')}</h3>
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA1')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ2')}</h3>
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA2')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ3')}</h3>
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA3')}</p>
<h3 style={{ fontSize: '1.1rem', marginTop: '1rem', marginBottom: '0.75rem' }}>{t('troubleshootingQ4')}</h3>
<p style={{ marginBottom: '1rem', marginLeft: '1rem' }}>{t('troubleshootingA4')}</p>
</div>
</section>
</div>
</main>
);
}

View File

@@ -0,0 +1,8 @@
export const dynamic = 'force-dynamic';
import CuratorHelpClient from './CuratorHelpClient';
export default function CuratorHelpPage() {
return <CuratorHelpClient />;
}