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

View File

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

173
components/HelpTooltip.tsx Normal file
View File

@@ -0,0 +1,173 @@
'use client';
import { useState, useRef, useEffect } from 'react';
interface HelpTooltipProps {
shortText: string; // Text für Hover
longText: string; // Text für Click/Modal
position?: 'top' | 'bottom' | 'left' | 'right';
}
export default function HelpTooltip({ shortText, longText, position = 'top' }: HelpTooltipProps) {
const [showHover, setShowHover] = useState(false);
const [showModal, setShowModal] = useState(false);
const tooltipRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
tooltipRef.current &&
!tooltipRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setShowModal(false);
}
}
if (showModal) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [showModal]);
const positionStyles = {
top: { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: '0.5rem' },
bottom: { top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: '0.5rem' },
left: { right: '100%', top: '50%', transform: 'translateY(-50%)', marginRight: '0.5rem' },
right: { left: '100%', top: '50%', transform: 'translateY(-50%)', marginLeft: '0.5rem' },
};
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
ref={buttonRef}
type="button"
onClick={() => setShowModal(!showModal)}
onMouseEnter={() => setShowHover(true)}
onMouseLeave={() => setShowHover(false)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: '#6b7280',
fontSize: '1rem',
padding: '0.25rem',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
width: '1.5rem',
height: '1.5rem',
transition: 'background-color 0.2s',
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = '#f3f4f6';
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
aria-label="Help"
>
?
</button>
{/* Hover Tooltip */}
{showHover && !showModal && (
<div
ref={tooltipRef}
style={{
position: 'absolute',
...positionStyles[position],
background: '#1f2937',
color: 'white',
padding: '0.5rem 0.75rem',
borderRadius: '0.375rem',
fontSize: '0.875rem',
whiteSpace: 'normal',
zIndex: 1000,
pointerEvents: 'none',
maxWidth: '250px',
}}
>
{shortText}
<div
style={{
position: 'absolute',
...(position === 'top' && { top: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderTop: '6px solid #1f2937' }),
...(position === 'bottom' && { bottom: '100%', left: '50%', transform: 'translateX(-50%)', borderLeft: '6px solid transparent', borderRight: '6px solid transparent', borderBottom: '6px solid #1f2937' }),
...(position === 'left' && { left: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderLeft: '6px solid #1f2937' }),
...(position === 'right' && { right: '100%', top: '50%', transform: 'translateY(-50%)', borderTop: '6px solid transparent', borderBottom: '6px solid transparent', borderRight: '6px solid #1f2937' }),
}}
/>
</div>
)}
{/* Modal für detaillierte Informationen */}
{showModal && (
<>
{/* Overlay */}
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.5)',
zIndex: 9998,
}}
onClick={() => setShowModal(false)}
/>
{/* Modal Content */}
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'white',
padding: '1.5rem',
borderRadius: '0.5rem',
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.2)',
maxWidth: '500px',
width: '90%',
maxHeight: '80vh',
overflowY: 'auto',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '1rem' }}>
<h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: 'bold' }}>Hilfe</h3>
<button
type="button"
onClick={() => setShowModal(false)}
style={{
background: 'none',
border: 'none',
fontSize: '1.5rem',
cursor: 'pointer',
color: '#6b7280',
padding: '0',
lineHeight: '1',
}}
aria-label="Close"
>
×
</button>
</div>
<div style={{ fontSize: '0.9rem', lineHeight: '1.6', whiteSpace: 'pre-wrap' }}>
{longText}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -274,6 +274,87 @@
"batchUpdateError": "Fehler: {error}", "batchUpdateError": "Fehler: {error}",
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung" "batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung"
}, },
"CuratorHelp": {
"title": "Kurator-Hilfe & Handbuch",
"backToDashboard": "Zurück zum Dashboard",
"helpButton": "Hilfe",
"introductionTitle": "Einführung",
"introductionText": "Als Kurator bist du verantwortlich für die Verwaltung von Songs in deinen zugewiesenen Genres und Specials. Dieses Dashboard ermöglicht es dir, Musik für das Hördle-Spiel hochzuladen, zu bearbeiten und zu organisieren.",
"permissionsTitle": "Deine Berechtigungen",
"permission1": "MP3-Dateien hochladen und deinen Genres zuordnen",
"permission2": "Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind",
"permission3": "Songs löschen, die ausschließlich deinen Genres/Specials zugeordnet sind",
"permission4": "Kommentare von Spielern zu deinen Rätseln einsehen und verwalten",
"note": "Hinweis",
"permissionNote": "Du kannst nur Songs bearbeiten oder löschen, die deinen Genres/Specials zugeordnet sind. Songs, die anderen Kuratoren zugeordnet sind, kannst du nicht ändern.",
"uploadTitle": "Songs hochladen",
"uploadStepsTitle": "Schritt-für-Schritt-Anleitung",
"uploadStep1": "MP3-Dateien in den Upload-Bereich ziehen oder klicken, um Dateien auszuwählen",
"uploadStep2": "Ein oder mehrere Genres auswählen, um sie den hochgeladenen Songs zuzuordnen",
"uploadStep3": "Auf 'Upload starten' klicken, um den Upload-Prozess zu beginnen",
"uploadStep4": "Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus den Dateien",
"uploadBestPracticesTitle": "Best Practices",
"uploadBestPractice1": "Stelle sicher, dass MP3-Dateien korrekte ID3-Tags (Titel, Artist) für die automatische Metadaten-Extraktion haben",
"uploadBestPractice2": "Passende Genres vor dem Upload auswählen, um spätere manuelle Zuordnung zu vermeiden",
"uploadBestPractice3": "Vor dem Upload auf Duplikate prüfen - das System warnt dich, wenn ein Song bereits existiert",
"tip": "Tipp",
"uploadTip": "Alle von Kuratoren hochgeladenen Songs werden automatisch von der globalen Playlist ausgeschlossen. Nur Admins können diese Einstellung ändern.",
"editingTitle": "Songs bearbeiten",
"singleEditTitle": "Einzelne Song-Bearbeitung",
"singleEditText": "Klicke auf den Bearbeiten-Button (✏️) neben einem Song, um Titel, Artist, Erscheinungsjahr, Genres, Specials oder das Exclude-Global-Flag zu ändern. Nur Songs, die du bearbeiten kannst, haben einen aktiven Bearbeiten-Button.",
"batchEditTitle": "Batch-Bearbeitung",
"batchEditText": "Wähle mehrere Songs über die Checkboxen aus, dann nutze die Batch-Bearbeitungs-Toolbar, um Änderungen auf alle ausgewählten Songs gleichzeitig anzuwenden:",
"batchEditFeature1": "Genre Toggle: Genres zu allen ausgewählten Songs hinzufügen oder entfernen",
"batchEditFeature2": "Special Toggle: Specials zu allen ausgewählten Songs hinzufügen oder entfernen",
"batchEditFeature3": "Artist ändern: Gleichen Artist-Namen für alle ausgewählten Songs setzen",
"batchEditFeature4": "Exclude Global Flag: Exclude-Global-Flag setzen oder entfernen (nur Global-Kuratoren)",
"genreSpecialAssignmentTitle": "Genre & Special Zuordnung",
"genreSpecialAssignmentText": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Songs können mehrere Genres und Specials haben. Beim Bearbeiten kannst du Zuordnungen umschalten - wenn ein Genre/Special bereits zugeordnet ist, wird es entfernt; wenn nicht, wird es hinzugefügt.",
"commentsTitle": "Kommentare verwalten",
"commentsText": "Spieler können dir Feedback zu Rätseln in deinen Genres oder Specials senden. Kommentare erscheinen in deinem Dashboard mit einem Badge für ungelesene Nachrichten.",
"commentsActionsTitle": "Verfügbare Aktionen",
"markAsRead": "Als gelesen markieren",
"markAsReadText": "Klicke auf einen Kommentar, um ihn als gelesen zu markieren. Gelesene Kommentare werden mit einem grauen Rahmen angezeigt.",
"archive": "Archivieren",
"archiveText": "Archiviere Kommentare, die du nicht mehr benötigst. Archivierte Kommentare werden aus deiner Ansicht entfernt.",
"bestPracticesTitle": "Best Practices für Kuratoren",
"bestPractice1": "Metadaten korrekt halten: Stelle sicher, dass Song-Titel und Artist-Namen korrekt und konsistent sind",
"bestPractice2": "Passende Genres verwenden: Ordne Songs den relevantesten Genres zu, um Spielern zu helfen, Musik zu entdecken",
"bestPractice3": "Auf Kommentare reagieren: Prüfe Kommentare regelmäßig und berücksichtige Spieler-Feedback beim Kuratieren",
"bestPractice4": "Qualität wahren: Überprüfe hochgeladene Songs auf Audio-Qualität und Metadaten-Genauigkeit",
"bestPractice5": "Batch-Bearbeitung effizient nutzen: Wenn du ähnliche Änderungen an mehreren Songs vornehmen musst, nutze die Batch-Bearbeitung, um Zeit zu sparen",
"troubleshootingTitle": "Troubleshooting",
"troubleshootingQ1": "Warum kann ich einen Song nicht bearbeiten?",
"troubleshootingA1": "Du kannst nur Songs bearbeiten, die mindestens einem deiner Genres oder Specials zugeordnet sind. Wenn ein Song keine Genres/Specials zugeordnet hat, kannst du ihn bearbeiten. Wenn er nur anderen Kuratoren zugeordnet ist, kannst du ihn nicht bearbeiten.",
"troubleshootingQ2": "Warum kann ich einen Song nicht löschen?",
"troubleshootingA2": "Du kannst nur Songs löschen, die ausschließlich deinen Genres/Specials zugeordnet sind. Wenn ein Song Genres oder Specials hat, die anderen Kuratoren zugeordnet sind, kannst du ihn nicht löschen.",
"troubleshootingQ3": "Warum kann ich ein Genre/Special nicht zuordnen?",
"troubleshootingA3": "Du kannst Songs nur Genres und Specials zuordnen, für die du verantwortlich bist. Wende dich an den Admin, wenn du Zugriff auf zusätzliche Genres oder Specials benötigst.",
"troubleshootingQ4": "Warum ist die Exclude-Global-Checkbox deaktiviert?",
"troubleshootingA4": "Nur Global-Kuratoren können das Exclude-Global-Flag ändern. Wenn du diese Berechtigung benötigst, wende dich an den Admin.",
"tooltipDashboardShort": "Übersicht über dein Kuratoren-Dashboard",
"tooltipDashboardLong": "Dies ist dein Haupt-Dashboard, wo du Songs hochladen, deine Track-Liste verwalten und Kommentare von Spielern einsehen kannst. Nutze den Hilfe-Button (❓), um auf das vollständige Handbuch zuzugreifen.",
"tooltipUploadShort": "MP3-Dateien zu deinen Genres hochladen",
"tooltipUploadLong": "Ziehe MP3-Dateien per Drag & Drop oder klicke, um sie auszuwählen. Das System extrahiert automatisch Metadaten (Titel, Artist, Erscheinungsjahr) aus ID3-Tags. Wähle Genres vor dem Upload aus, um Songs automatisch zuzuordnen. Alle Kuratoren-Uploads sind standardmäßig von der globalen Playlist ausgeschlossen.",
"tooltipGenreAssignmentShort": "Genres zu hochgeladenen Songs zuordnen",
"tooltipGenreAssignmentLong": "Wähle ein oder mehrere Genres vor dem Upload aus. Die ausgewählten Genres werden allen erfolgreich hochgeladenen Songs zugeordnet. Du kannst nur Genres zuordnen, für die du verantwortlich bist. Wenn du keine Genres auswählst, kannst du sie später durch Bearbeitung der Songs zuordnen.",
"tooltipTracklistShort": "Deine Songs verwalten",
"tooltipTracklistLong": "Diese Tabelle zeigt alle Songs in deinen Genres und Specials. Du kannst suchen, filtern, sortieren und Songs bearbeiten. Nutze die Checkboxen, um mehrere Songs für die Batch-Bearbeitung auszuwählen. Nur Songs, die du bearbeiten kannst, haben eine aktive Checkbox.",
"tooltipSearchShort": "Nach Titel oder Artist suchen",
"tooltipSearchLong": "Tippe in das Suchfeld, um Songs nach Titel oder Artist-Namen zu filtern. Die Suche ist nicht case-sensitive und findet Teiltexte. Leere das Suchfeld, um alle Songs wieder anzuzeigen.",
"tooltipFilterShort": "Nach Genre, Special oder Global-Flag filtern",
"tooltipFilterLong": "Nutze das Filter-Dropdown, um nur Songs aus einem bestimmten Genre, Special oder Songs, die von der globalen Playlist ausgeschlossen sind, anzuzeigen. Kombiniere mit der Suche für präzisere Filterung.",
"tooltipBatchEditShort": "Mehrere Songs gleichzeitig bearbeiten",
"tooltipBatchEditLong": "Wähle mehrere Songs über Checkboxen aus, dann nutze die Batch-Bearbeitungs-Toolbar, um Änderungen auf alle ausgewählten Songs gleichzeitig anzuwenden. Du kannst Genres/Specials umschalten, Artist-Namen ändern oder das Exclude-Global-Flag ändern (nur Global-Kuratoren).",
"tooltipBatchGenreToggleShort": "Genres hinzufügen oder entfernen",
"tooltipBatchGenreToggleLong": "Wähle Genres zum Umschalten aus. Wenn ein ausgewählter Song das Genre bereits hat, wird es entfernt. Wenn nicht, wird es hinzugefügt. Dies ermöglicht es dir, schnell Genres zu mehreren Songs hinzuzufügen oder zu entfernen.",
"tooltipBatchSpecialToggleShort": "Specials hinzufügen oder entfernen",
"tooltipBatchSpecialToggleLong": "Wähle Specials zum Umschalten aus. Wenn ein ausgewählter Song das Special bereits hat, wird es entfernt. Wenn nicht, wird es hinzugefügt. Du kannst nur Specials umschalten, für die du verantwortlich bist.",
"tooltipBatchArtistShort": "Artist für alle ausgewählten Songs ändern",
"tooltipBatchArtistLong": "Gib einen neuen Artist-Namen ein, um ihn für alle ausgewählten Songs zu setzen. Dies ist nützlich, um Artist-Namen zu korrigieren oder Namenskonventionen über mehrere Songs hinweg zu standardisieren.",
"tooltipCommentsShort": "Spieler-Feedback und Kommentare",
"tooltipCommentsLong": "Spieler können dir Nachrichten zu Rätseln in deinen Genres oder Specials senden. Ungelesene Kommentare sind mit einem blauen Rahmen und Badge hervorgehoben. Klicke auf einen Kommentar, um ihn als gelesen zu markieren, oder archiviere ihn, wenn du ihn nicht mehr benötigst."
},
"About": { "About": {
"title": "Über Hördle & Impressum", "title": "Über Hördle & Impressum",
"intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.", "intro": "Hördle ist ein nicht-kommerzielles, privat betriebenes Hobbyprojekt. Es gibt keine Werbeanzeigen, keine gesponserten Inhalte und keine versteckten Abo-Modelle.",

View File

@@ -274,6 +274,87 @@
"batchUpdateError": "Error: {error}", "batchUpdateError": "Error: {error}",
"batchUpdateNetworkError": "Network error during batch update" "batchUpdateNetworkError": "Network error during batch update"
}, },
"CuratorHelp": {
"title": "Curator Help & Manual",
"backToDashboard": "Back to Dashboard",
"helpButton": "Help",
"introductionTitle": "Introduction",
"introductionText": "As a curator, you are responsible for managing songs within your assigned genres and specials. This dashboard allows you to upload, edit, and organize music for the Hördle game.",
"permissionsTitle": "Your Permissions",
"permission1": "Upload MP3 files and assign them to your genres",
"permission2": "Edit songs that are assigned to at least one of your genres or specials",
"permission3": "Delete songs that are exclusively assigned to your genres/specials",
"permission4": "View and manage comments from players about your puzzles",
"note": "Note",
"permissionNote": "You can only edit or delete songs that are assigned to your genres/specials. Songs assigned to other curators' genres cannot be modified by you.",
"uploadTitle": "Uploading Songs",
"uploadStepsTitle": "Step-by-Step Guide",
"uploadStep1": "Drag MP3 files into the upload area or click to select files",
"uploadStep2": "Select one or more genres to assign to the uploaded songs",
"uploadStep3": "Click 'Start upload' to begin the upload process",
"uploadStep4": "The system will automatically extract metadata (title, artist, release year) from the files",
"uploadBestPracticesTitle": "Best Practices",
"uploadBestPractice1": "Ensure MP3 files have correct ID3 tags (title, artist) for automatic metadata extraction",
"uploadBestPractice2": "Select appropriate genres before uploading to avoid manual assignment later",
"uploadBestPractice3": "Check for duplicates before uploading - the system will warn you if a song already exists",
"tip": "Tip",
"uploadTip": "All songs uploaded by curators are automatically excluded from the global playlist. Only admins can change this setting.",
"editingTitle": "Editing Songs",
"singleEditTitle": "Single Song Editing",
"singleEditText": "Click the edit button (✏️) next to a song to modify its title, artist, release year, genres, specials, or exclude-from-global flag. Only songs you can edit will have an active edit button.",
"batchEditTitle": "Batch Editing",
"batchEditText": "Select multiple songs using the checkboxes, then use the batch edit toolbar to apply changes to all selected songs at once:",
"batchEditFeature1": "Genre Toggle: Add or remove genres from all selected songs",
"batchEditFeature2": "Special Toggle: Add or remove specials from all selected songs",
"batchEditFeature3": "Artist Change: Set the same artist name for all selected songs",
"batchEditFeature4": "Exclude Global Flag: Set or remove the exclude-from-global flag (Global Curators only)",
"genreSpecialAssignmentTitle": "Genre & Special Assignment",
"genreSpecialAssignmentText": "You can only assign songs to genres and specials that you are responsible for. Songs can have multiple genres and specials. When editing, you can toggle assignments - if a genre/special is already assigned, it will be removed; if not, it will be added.",
"commentsTitle": "Managing Comments",
"commentsText": "Players can send you feedback about puzzles in your genres or specials. Comments appear in your dashboard with a badge showing unread messages.",
"commentsActionsTitle": "Available Actions",
"markAsRead": "Mark as Read",
"markAsReadText": "Click on a comment to mark it as read. Read comments are displayed with a gray border.",
"archive": "Archive",
"archiveText": "Archive comments you no longer need. Archived comments are removed from your view.",
"bestPracticesTitle": "Best Practices for Curators",
"bestPractice1": "Keep metadata accurate: Ensure song titles and artist names are correct and consistent",
"bestPractice2": "Use appropriate genres: Assign songs to the most relevant genres to help players discover music",
"bestPractice3": "Respond to comments: Check comments regularly and consider player feedback when curating",
"bestPractice4": "Maintain quality: Review uploaded songs for audio quality and metadata accuracy",
"bestPractice5": "Use batch editing efficiently: When making similar changes to multiple songs, use batch edit to save time",
"troubleshootingTitle": "Troubleshooting",
"troubleshootingQ1": "Why can't I edit a song?",
"troubleshootingA1": "You can only edit songs that are assigned to at least one of your genres or specials. If a song has no genres/specials assigned, you can edit it. If it's assigned to other curators' genres only, you cannot edit it.",
"troubleshootingQ2": "Why can't I delete a song?",
"troubleshootingA2": "You can only delete songs that are exclusively assigned to your genres/specials. If a song has any genres or specials assigned to other curators, you cannot delete it.",
"troubleshootingQ3": "Why can't I assign a genre/special?",
"troubleshootingA3": "You can only assign songs to genres and specials that you are responsible for. Contact the admin if you need access to additional genres or specials.",
"troubleshootingQ4": "Why is the exclude-from-global checkbox disabled?",
"troubleshootingA4": "Only Global Curators can change the exclude-from-global flag. If you need this permission, contact the admin.",
"tooltipDashboardShort": "Overview of your curator dashboard",
"tooltipDashboardLong": "This is your main dashboard where you can upload songs, manage your track list, and view comments from players. Use the help button (❓) to access the full manual.",
"tooltipUploadShort": "Upload MP3 files to your genres",
"tooltipUploadLong": "Drag and drop MP3 files or click to select. The system will automatically extract metadata (title, artist, release year) from ID3 tags. Select genres before uploading to automatically assign songs. All curator uploads are excluded from the global playlist by default.",
"tooltipGenreAssignmentShort": "Assign genres to uploaded songs",
"tooltipGenreAssignmentLong": "Select one or more genres before uploading. The selected genres will be assigned to all successfully uploaded songs. You can only assign genres that you are responsible for. If you don't select any genres, you can assign them later by editing the songs.",
"tooltipTracklistShort": "Manage your songs",
"tooltipTracklistLong": "This table shows all songs in your genres and specials. You can search, filter, sort, and edit songs. Use the checkboxes to select multiple songs for batch editing. Only songs you can edit will have an active checkbox.",
"tooltipSearchShort": "Search by title or artist",
"tooltipSearchLong": "Type in the search box to filter songs by title or artist name. The search is case-insensitive and matches partial text. Clear the search to show all songs again.",
"tooltipFilterShort": "Filter by genre, special, or global flag",
"tooltipFilterLong": "Use the filter dropdown to show only songs from a specific genre, special, or songs excluded from the global playlist. Combine with search for more precise filtering.",
"tooltipBatchEditShort": "Edit multiple songs at once",
"tooltipBatchEditLong": "Select multiple songs using checkboxes, then use the batch edit toolbar to apply changes to all selected songs simultaneously. You can toggle genres/specials, change artist names, or modify the exclude-from-global flag (Global Curators only).",
"tooltipBatchGenreToggleShort": "Add or remove genres",
"tooltipBatchGenreToggleLong": "Select genres to toggle. If a selected song already has the genre, it will be removed. If it doesn't have the genre, it will be added. This allows you to quickly add or remove genres from multiple songs at once.",
"tooltipBatchSpecialToggleShort": "Add or remove specials",
"tooltipBatchSpecialToggleLong": "Select specials to toggle. If a selected song already has the special, it will be removed. If it doesn't have the special, it will be added. You can only toggle specials you are responsible for.",
"tooltipBatchArtistShort": "Change artist for all selected songs",
"tooltipBatchArtistLong": "Enter a new artist name to set it for all selected songs. This is useful for correcting artist names or standardizing naming conventions across multiple songs.",
"tooltipCommentsShort": "Player feedback and comments",
"tooltipCommentsLong": "Players can send you messages about puzzles in your genres or specials. Unread comments are highlighted with a blue border and badge. Click on a comment to mark it as read, or archive it if you no longer need it."
},
"About": { "About": {
"title": "About Hördle & Imprint", "title": "About Hördle & Imprint",
"intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.", "intro": "Hördle is a non-commercial, privately run hobby project. There are no ads, no sponsored content and no hidden subscription models.",