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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user