Compare commits
7 Commits
v0.1.6.2
...
curate-spe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7121271a | ||
|
|
12cc81905e | ||
|
|
b46e9e3882 | ||
|
|
332688d693 | ||
|
|
a725694519 | ||
|
|
cdb9803b40 | ||
|
|
7db4e26b2c |
@@ -61,6 +61,8 @@ Eine Web-App inspiriert von Heardle, bei der Nutzer täglich einen Song anhand k
|
|||||||
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
|
- **Kommentar-Verwaltung:** Kuratoren können Spieler-Kommentare zu ihren Rätseln einsehen, als gelesen markieren und archivieren.
|
||||||
- **Spieler-Kommentare:**
|
- **Spieler-Kommentare:**
|
||||||
- **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden.
|
- **Feedback an Kuratoren:** Spieler können nach Abschluss eines Rätsels optional eine Nachricht an die Kuratoren senden.
|
||||||
|
- **KI-gestützte Formulierungshilfe:** Nachrichten können vor dem Absenden auf Wunsch automatisch von einer KI umformuliert/verbessert werden.
|
||||||
|
- **Einklappbares Kommentar-Formular:** Das Nachrichtenformular ist dezent als einklappbarer Bereich eingebunden und stört den Spielfluss nicht.
|
||||||
- **Automatische Zuordnung:** Kommentare werden automatisch an relevante Kuratoren verteilt (Genre-Kuratoren, Special-Kuratoren, Global-Kuratoren).
|
- **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.
|
- **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).
|
- **Kontext-Informationen:** Kommentare enthalten vollständigen Rätsel-Kontext (Hördle #, Genre/Special, Titel/Artist).
|
||||||
|
|||||||
@@ -1320,20 +1320,45 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
|
|||||||
</form>
|
</form>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
{specials.map(special => (
|
{specials.map(special => (
|
||||||
<div key={special.id} style={{
|
<div
|
||||||
|
key={special.id}
|
||||||
|
style={{
|
||||||
background: '#f3f4f6',
|
background: '#f3f4f6',
|
||||||
padding: '0.25rem 0.75rem',
|
padding: '0.25rem 0.75rem',
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
fontSize: '0.875rem'
|
fontSize: '0.875rem',
|
||||||
}}>
|
}}
|
||||||
<span>{getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})</span>
|
>
|
||||||
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {getLocalizedValue(special.subtitle, activeTab)}</span>}
|
<span>
|
||||||
<Link href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>{t('curate')}</Link>
|
{getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})
|
||||||
<button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>{t('edit')}</button>
|
</span>
|
||||||
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">{t('delete')}</button>
|
{special.subtitle && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#666',
|
||||||
|
marginLeft: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
- {getLocalizedValue(special.subtitle, activeTab)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => startEditSpecial(special)}
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
>
|
||||||
|
{t('edit')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSpecial(special.id)}
|
||||||
|
className="btn-danger"
|
||||||
|
>
|
||||||
|
{t('delete')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
7
app/[locale]/admin/specials/[id]/page.tsx
Normal file
7
app/[locale]/admin/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import SpecialEditorPage from '@/app/admin/specials/[id]/page';
|
||||||
|
|
||||||
|
export default SpecialEditorPage;
|
||||||
|
|
||||||
|
|
||||||
7
app/[locale]/curator/specials/[id]/page.tsx
Normal file
7
app/[locale]/curator/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import CuratorSpecialEditorPage from '@/app/curator/specials/[id]/page';
|
||||||
|
|
||||||
|
export default CuratorSpecialEditorPage;
|
||||||
|
|
||||||
|
|
||||||
7
app/[locale]/curator/specials/page.tsx
Normal file
7
app/[locale]/curator/specials/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import CuratorSpecialsPage from '@/app/curator/specials/page';
|
||||||
|
|
||||||
|
export default CuratorSpecialsPage;
|
||||||
|
|
||||||
|
|
||||||
@@ -80,3 +80,30 @@ export async function submitRating(songId: number, rating: number, genre?: strin
|
|||||||
return { success: false, error: 'Failed to submit rating' };
|
return { success: false, error: 'Failed to submit rating' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendCommentNotification(puzzleId: number, message: string, originalMessage?: string, genre?: string | null) {
|
||||||
|
try {
|
||||||
|
const title = `New Curator Comment (Puzzle #${puzzleId})`;
|
||||||
|
let body = message;
|
||||||
|
|
||||||
|
if (originalMessage && originalMessage !== message) {
|
||||||
|
body = `Original: ${originalMessage}\n\nRewritten: ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (genre) {
|
||||||
|
body = `[${genre}] ${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(`${GOTIFY_URL}/message?token=${GOTIFY_APP_TOKEN}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title,
|
||||||
|
message: body,
|
||||||
|
priority: 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending comment notification:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -638,20 +638,45 @@ export default function AdminPage() {
|
|||||||
</form>
|
</form>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||||
{specials.map(special => (
|
{specials.map(special => (
|
||||||
<div key={special.id} style={{
|
<div
|
||||||
|
key={special.id}
|
||||||
|
style={{
|
||||||
background: '#f3f4f6',
|
background: '#f3f4f6',
|
||||||
padding: '0.25rem 0.75rem',
|
padding: '0.25rem 0.75rem',
|
||||||
borderRadius: '999px',
|
borderRadius: '999px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
fontSize: '0.875rem'
|
fontSize: '0.875rem',
|
||||||
}}>
|
}}
|
||||||
<span>{special.name} ({special._count?.songs || 0})</span>
|
>
|
||||||
{special.subtitle && <span style={{ fontSize: '0.75rem', color: '#666', marginLeft: '0.25rem' }}>- {special.subtitle}</span>}
|
<span>
|
||||||
<a href={`/admin/specials/${special.id}`} className="btn-primary" style={{ marginRight: '0.5rem', textDecoration: 'none' }}>Curate</a>
|
{special.name} ({special._count?.songs || 0})
|
||||||
<button onClick={() => startEditSpecial(special)} className="btn-secondary" style={{ marginRight: '0.5rem' }}>Edit</button>
|
</span>
|
||||||
<button onClick={() => handleDeleteSpecial(special.id)} className="btn-danger">Delete</button>
|
{special.subtitle && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
color: '#666',
|
||||||
|
marginLeft: '0.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
- {special.subtitle}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => startEditSpecial(special)}
|
||||||
|
className="btn-secondary"
|
||||||
|
style={{ marginRight: '0.5rem' }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteSpecial(special.id)}
|
||||||
|
className="btn-danger"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,60 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||||
import WaveformEditor from '@/components/WaveformEditor';
|
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
|
||||||
|
|
||||||
interface Song {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
filename: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SpecialSong {
|
|
||||||
id: number;
|
|
||||||
songId: number;
|
|
||||||
startTime: number;
|
|
||||||
order: number | null;
|
|
||||||
song: Song;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Special {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
subtitle?: string;
|
|
||||||
maxAttempts: number;
|
|
||||||
unlockSteps: string;
|
|
||||||
songs: SpecialSong[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SpecialEditorPage() {
|
export default function SpecialEditorPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
const specialId = params.id as string;
|
const specialId = params.id as string;
|
||||||
|
|
||||||
const [special, setSpecial] = useState<Special | null>(null);
|
// Locale aus dem Pfad ableiten (/en/..., /de/...)
|
||||||
const [selectedSongId, setSelectedSongId] = useState<number | null>(null);
|
const localeFromPath = pathname?.split('/')[1] as 'de' | 'en' | undefined;
|
||||||
|
const locale: 'de' | 'en' = localeFromPath === 'de' || localeFromPath === 'en' ? localeFromPath : 'en';
|
||||||
|
|
||||||
|
const [special, setSpecial] = useState<CurateSpecial | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [pendingStartTime, setPendingStartTime] = useState<number | null>(null);
|
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSpecial();
|
|
||||||
}, [specialId]);
|
|
||||||
|
|
||||||
const fetchSpecial = async () => {
|
const fetchSpecial = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/specials/${specialId}`);
|
const res = await fetch(`/api/specials/${specialId}`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setSpecial(data);
|
setSpecial(data);
|
||||||
if (data.songs.length > 0) {
|
|
||||||
setSelectedSongId(data.songs[0].songId);
|
|
||||||
// Initialize pendingStartTime with the current startTime of the first song
|
|
||||||
setPendingStartTime(data.songs[0].startTime);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching special:', error);
|
console.error('Error fetching special:', error);
|
||||||
@@ -63,40 +32,20 @@ export default function SpecialEditorPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStartTimeChange = (newStartTime: number) => {
|
fetchSpecial();
|
||||||
setPendingStartTime(newStartTime);
|
}, [specialId]);
|
||||||
setHasUnsavedChanges(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSaveStartTime = async (songId: number, startTime: number) => {
|
||||||
if (!special || !selectedSongId || pendingStartTime === null) return;
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/specials/${specialId}/songs`, {
|
const res = await fetch(`/api/specials/${specialId}/songs`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ songId: selectedSongId, startTime: pendingStartTime })
|
body: JSON.stringify({ songId, startTime }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (!res.ok) {
|
||||||
// Update local state
|
const errorText = await res.text().catch(() => res.statusText || 'Unknown error');
|
||||||
setSpecial(prev => {
|
console.error('Error updating special song (admin):', res.status, errorText);
|
||||||
if (!prev) return prev;
|
throw new Error(`Failed to save start time: ${errorText}`);
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
songs: prev.songs.map(ss =>
|
|
||||||
ss.songId === selectedSongId ? { ...ss, startTime: pendingStartTime } : ss
|
|
||||||
)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
setPendingStartTime(null); // Reset pending state after saving
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating start time:', error);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -117,116 +66,16 @@ export default function SpecialEditorPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId);
|
|
||||||
const unlockSteps = JSON.parse(special.unlockSteps);
|
|
||||||
const totalDuration = unlockSteps[unlockSteps.length - 1];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
<CurateSpecialEditor
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
special={special}
|
||||||
<button
|
locale={locale}
|
||||||
onClick={() => router.push('/admin')}
|
onBack={() => router.push('/admin')}
|
||||||
style={{
|
onSaveStartTime={handleSaveStartTime}
|
||||||
padding: '0.5rem 1rem',
|
backLabel="← Back to Admin"
|
||||||
background: '#e5e7eb',
|
headerPrefix="Edit Special:"
|
||||||
border: 'none',
|
noSongsSubHint="Go back to the admin dashboard to add songs to this special."
|
||||||
borderRadius: '0.5rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginBottom: '1rem'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
← Back to Admin
|
|
||||||
</button>
|
|
||||||
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
|
||||||
Edit Special: {special.name}
|
|
||||||
</h1>
|
|
||||||
{special.subtitle && (
|
|
||||||
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
|
|
||||||
{special.subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p style={{ color: '#666', marginTop: '0.5rem' }}>
|
|
||||||
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{special.songs.length === 0 ? (
|
|
||||||
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
|
|
||||||
<p>No songs assigned to this special yet.</p>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
|
||||||
Go back to the admin dashboard to add songs to this special.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: '2rem' }}>
|
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
Select Song to Curate
|
|
||||||
</h2>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
|
|
||||||
{special.songs.map(ss => (
|
|
||||||
<div
|
|
||||||
key={ss.songId}
|
|
||||||
onClick={() => setSelectedSongId(ss.songId)}
|
|
||||||
style={{
|
|
||||||
padding: '1rem',
|
|
||||||
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
|
|
||||||
color: selectedSongId === ss.songId ? 'white' : 'black',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
cursor: 'pointer',
|
|
||||||
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
|
|
||||||
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
|
|
||||||
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
|
|
||||||
Start: {ss.startTime}s
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedSpecialSong && (
|
|
||||||
<div>
|
|
||||||
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
|
||||||
Curate: {selectedSpecialSong.song.title}
|
|
||||||
</h2>
|
|
||||||
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
|
||||||
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
|
|
||||||
Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={!hasUnsavedChanges || saving}
|
|
||||||
style={{
|
|
||||||
padding: '0.5rem 1.5rem',
|
|
||||||
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
|
|
||||||
color: hasUnsavedChanges ? 'white' : '#9ca3af',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '0.5rem',
|
|
||||||
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
|
|
||||||
fontWeight: 'bold',
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saving ? '💾 Saving...' : hasUnsavedChanges ? '💾 Save Changes' : '✓ Saved'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<WaveformEditor
|
|
||||||
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
|
|
||||||
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
|
|
||||||
duration={totalDuration}
|
|
||||||
unlockSteps={unlockSteps}
|
|
||||||
onStartTimeChange={handleStartTimeChange}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export async function POST(request: NextRequest) {
|
|||||||
if (rateLimitError) return rateLimitError;
|
if (rateLimitError) return rateLimitError;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { puzzleId, genreId, message, playerIdentifier } = await request.json();
|
const { puzzleId, genreId, message, playerIdentifier, originalMessage } = await request.json();
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!puzzleId || !message || !playerIdentifier) {
|
if (!puzzleId || !message || !playerIdentifier) {
|
||||||
@@ -28,9 +28,9 @@ export async function POST(request: NextRequest) {
|
|||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (trimmedMessage.length > 2000) {
|
if (trimmedMessage.length > 300) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Message too long. Maximum 2000 characters allowed.' },
|
{ error: 'Message too long. Maximum 300 characters allowed.' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -170,6 +170,19 @@ export async function POST(request: NextRequest) {
|
|||||||
return comment;
|
return comment;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Send Gotify notification (fire and forget)
|
||||||
|
const { sendCommentNotification } = await import('@/app/actions');
|
||||||
|
// originalMessage is already available from the initial request.json() call
|
||||||
|
|
||||||
|
// Determine genre name for notification
|
||||||
|
let genreName: string | null = null;
|
||||||
|
if (finalGenreId) {
|
||||||
|
const genreObj = await prisma.genre.findUnique({ where: { id: finalGenreId } });
|
||||||
|
if (genreObj) genreName = genreObj.name as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendCommentNotification(Number(puzzleId), trimmedMessage, originalMessage, genreName || null);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
commentId: result.id
|
commentId: result.id
|
||||||
|
|||||||
58
app/api/curator/specials/[id]/route.ts
Normal file
58
app/api/curator/specials/[id]/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) return error!;
|
||||||
|
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can access this endpoint' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const specialId = Number(id);
|
||||||
|
if (!specialId || Number.isNaN(specialId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid special id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen, ob dieses Special dem Kurator zugeordnet ist
|
||||||
|
const assignment = await prisma.curatorSpecial.findFirst({
|
||||||
|
where: { curatorId: context.curator.id, specialId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: You are not allowed to access this special' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const special = await prisma.special.findUnique({
|
||||||
|
where: { id: specialId },
|
||||||
|
include: {
|
||||||
|
songs: {
|
||||||
|
include: {
|
||||||
|
song: true,
|
||||||
|
},
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!special) {
|
||||||
|
return NextResponse.json({ error: 'Special not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(special);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
69
app/api/curator/specials/[id]/songs/route.ts
Normal file
69
app/api/curator/specials/[id]/songs/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) return error!;
|
||||||
|
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can access this endpoint' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const specialId = Number(id);
|
||||||
|
const { songId, startTime, order } = await request.json();
|
||||||
|
|
||||||
|
if (!specialId || Number.isNaN(specialId)) {
|
||||||
|
return NextResponse.json({ error: 'Invalid special id' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!songId || typeof startTime !== 'number') {
|
||||||
|
return NextResponse.json({ error: 'Missing songId or startTime' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen, ob dieses Special dem Kurator zugeordnet ist
|
||||||
|
const assignment = await prisma.curatorSpecial.findFirst({
|
||||||
|
where: { curatorId: context.curator.id, specialId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!assignment) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Forbidden: You are not allowed to edit this special' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const specialSong = await prisma.specialSong.update({
|
||||||
|
where: {
|
||||||
|
specialId_songId: {
|
||||||
|
specialId,
|
||||||
|
songId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
startTime,
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
song: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(specialSong);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating curator special song:', e);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
47
app/api/curator/specials/route.ts
Normal file
47
app/api/curator/specials/route.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { requireStaffAuth } from '@/lib/auth';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { error, context } = await requireStaffAuth(request);
|
||||||
|
if (error || !context) return error!;
|
||||||
|
|
||||||
|
if (context.role !== 'curator') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Only curators can access this endpoint' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specials, die diesem Kurator zugewiesen sind
|
||||||
|
const assignments = await prisma.curatorSpecial.findMany({
|
||||||
|
where: { curatorId: context.curator.id },
|
||||||
|
select: { specialId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
return NextResponse.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const specialIds = assignments.map(a => a.specialId);
|
||||||
|
|
||||||
|
const specials = await prisma.special.findMany({
|
||||||
|
where: { id: { in: specialIds } },
|
||||||
|
include: {
|
||||||
|
songs: true,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = specials.map(special => ({
|
||||||
|
id: special.id,
|
||||||
|
name: special.name,
|
||||||
|
songCount: special.songs.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
73
app/api/rewrite-message/route.ts
Normal file
73
app/api/rewrite-message/route.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
||||||
|
const OPENROUTER_MODEL = 'anthropic/claude-3.5-haiku';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { message } = await request.json();
|
||||||
|
|
||||||
|
if (!message || typeof message !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message is required and must be a string' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!OPENROUTER_API_KEY) {
|
||||||
|
console.error('OPENROUTER_API_KEY is not configured');
|
||||||
|
// Fallback: return original message if API key is missing
|
||||||
|
return NextResponse.json({ rewrittenMessage: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = `Rewrite the following message to express the COMPLETE OPPOSITE meaning and opinion.
|
||||||
|
If the message is negative or critical, rewrite it to be overwhelmingly positive, praising, and appreciative.
|
||||||
|
If the message is positive, rewrite it to be critical or negative.
|
||||||
|
Maintain the original language (German or English).
|
||||||
|
Return ONLY the rewritten message text, nothing else.
|
||||||
|
|
||||||
|
Message: "${message}"`;
|
||||||
|
|
||||||
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'HTTP-Referer': 'https://hoerdle.elpatron.me',
|
||||||
|
'X-Title': 'Hördle Message Rewriter'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: OPENROUTER_MODEL,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
temperature: 0.7,
|
||||||
|
max_tokens: 500
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('OpenRouter API error:', await response.text());
|
||||||
|
// Fallback: return original message
|
||||||
|
return NextResponse.json({ rewrittenMessage: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
let rewrittenMessage = data.choices?.[0]?.message?.content?.trim() || message;
|
||||||
|
|
||||||
|
// Add suffix
|
||||||
|
rewrittenMessage += " (autocorrected by Polite-Bot)";
|
||||||
|
|
||||||
|
return NextResponse.json({ rewrittenMessage });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error rewriting message:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal Server Error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -807,6 +807,25 @@ export default function CuratorPageClient() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
<Link
|
||||||
|
href="/curator/specials"
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#10b981',
|
||||||
|
color: 'white',
|
||||||
|
textDecoration: 'none',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.25rem',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✨ {t('curateSpecialsButton')}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/curator/help"
|
href="/curator/help"
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
137
app/curator/specials/[id]/page.tsx
Normal file
137
app/curator/specials/[id]/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import CurateSpecialEditor, { CurateSpecial } from '@/components/CurateSpecialEditor';
|
||||||
|
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
|
||||||
|
|
||||||
|
export default function CuratorSpecialEditorPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const urlLocale = pathname?.split('/')[1] as 'de' | 'en' | undefined;
|
||||||
|
const intlLocale = useLocale() as 'de' | 'en';
|
||||||
|
const locale: 'de' | 'en' = urlLocale === 'de' || urlLocale === 'en' ? urlLocale : intlLocale;
|
||||||
|
const t = useTranslations('Curator');
|
||||||
|
|
||||||
|
const specialId = params?.id as string;
|
||||||
|
|
||||||
|
const [special, setSpecial] = useState<CurateSpecial | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSpecial = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch(`/api/curator/specials/${specialId}`, {
|
||||||
|
headers: getCuratorAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (res.status === 403) {
|
||||||
|
setError(t('specialForbidden'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
setError('Failed to load special');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setSpecial(data);
|
||||||
|
} catch (e) {
|
||||||
|
setError('Failed to load special');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (specialId) {
|
||||||
|
fetchSpecial();
|
||||||
|
}
|
||||||
|
}, [specialId, t]);
|
||||||
|
|
||||||
|
const handleSaveStartTime = async (songId: number, startTime: number) => {
|
||||||
|
const res = await fetch(`/api/curator/specials/${specialId}/songs`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
...getCuratorAuthHeaders(),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ songId, startTime }),
|
||||||
|
});
|
||||||
|
if (res.status === 403) {
|
||||||
|
setError(t('specialForbidden'));
|
||||||
|
} else if (!res.ok) {
|
||||||
|
setError('Failed to save changes');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>{t('loadingData')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/${locale}/curator`)}
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: 'none',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToDashboard')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!special) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>{t('specialNotFound')}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/${locale}/curator`)}
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: 'none',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToDashboard')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CurateSpecialEditor
|
||||||
|
special={special}
|
||||||
|
locale={locale}
|
||||||
|
onBack={() => router.push(`/${locale}/curator/specials`)}
|
||||||
|
onSaveStartTime={handleSaveStartTime}
|
||||||
|
backLabel={t('backToCuratorSpecials')}
|
||||||
|
headerPrefix={t('curateSpecialHeaderPrefix')}
|
||||||
|
noSongsHint={t('curateSpecialNoSongs')}
|
||||||
|
noSongsSubHint={t('curateSpecialNoSongsSub')}
|
||||||
|
instructionsText={t('curateSpecialInstructions')}
|
||||||
|
savingLabel={t('saving')}
|
||||||
|
saveChangesLabel={t('saveChanges')}
|
||||||
|
savedLabel={t('saved')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
152
app/curator/specials/page.tsx
Normal file
152
app/curator/specials/page.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useLocale, useTranslations } from 'next-intl';
|
||||||
|
import { Link } from '@/lib/navigation';
|
||||||
|
import { getCuratorAuthHeaders } from '@/lib/curatorAuth';
|
||||||
|
|
||||||
|
type LocalizedString = string | { de: string; en: string };
|
||||||
|
|
||||||
|
interface CuratorSpecialSummary {
|
||||||
|
id: number;
|
||||||
|
name: LocalizedString;
|
||||||
|
songCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CuratorSpecialsPage() {
|
||||||
|
const t = useTranslations('Curator');
|
||||||
|
const locale = useLocale();
|
||||||
|
const [specials, setSpecials] = useState<CuratorSpecialSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSpecials = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch('/api/curator/specials', {
|
||||||
|
headers: getCuratorAuthHeaders(),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setSpecials(data);
|
||||||
|
} else if (res.status === 403) {
|
||||||
|
setError(t('noSpecialPermissions'));
|
||||||
|
} else {
|
||||||
|
setError('Failed to load specials');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError('Failed to load specials');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSpecials();
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>{t('loadingData')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (specials.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
|
<p>{t('noSpecialsInScope')}</p>
|
||||||
|
<div style={{ marginTop: '1.5rem' }}>
|
||||||
|
<Link
|
||||||
|
href="/curator"
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToDashboard')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveLocalized = (value: LocalizedString, locale: string): string => {
|
||||||
|
if (!value) return '';
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
const loc = locale === 'de' || locale === 'en' ? locale : 'en';
|
||||||
|
return value[loc] ?? value.en ?? value.de;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', maxWidth: '900px', margin: '0 auto' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.5rem' }}>
|
||||||
|
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||||
|
{t('curateSpecialsTitle')}
|
||||||
|
</h1>
|
||||||
|
<Link
|
||||||
|
href="/curator"
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: '#111827',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('backToDashboard')}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p style={{ marginBottom: '1.5rem', color: '#4b5563' }}>
|
||||||
|
{t('curateSpecialsDescription')}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||||
|
{specials.map(special => (
|
||||||
|
<Link
|
||||||
|
key={special.id}
|
||||||
|
href={`/curator/specials/${special.id}`}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
borderRadius: '0.75rem',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
background: 'white',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: '0.25rem' }}>
|
||||||
|
{resolveLocalized(special.name, String(locale))}
|
||||||
|
</h2>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#6b7280' }}>
|
||||||
|
{t('curateSpecialSongCount', { count: special.songCount })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '0.75rem', textAlign: 'right', fontSize: '0.875rem', color: '#4f46e5' }}>
|
||||||
|
{t('curateSpecialOpen')}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
212
components/CurateSpecialEditor.tsx
Normal file
212
components/CurateSpecialEditor.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import WaveformEditor from '@/components/WaveformEditor';
|
||||||
|
|
||||||
|
export type LocalizedString = string | { de: string; en: string };
|
||||||
|
|
||||||
|
export interface CurateSpecialSong {
|
||||||
|
id: number;
|
||||||
|
songId: number;
|
||||||
|
startTime: number;
|
||||||
|
order: number | null;
|
||||||
|
song: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurateSpecial {
|
||||||
|
id: number;
|
||||||
|
name: LocalizedString;
|
||||||
|
subtitle?: LocalizedString | null;
|
||||||
|
maxAttempts: number;
|
||||||
|
unlockSteps: string;
|
||||||
|
songs: CurateSpecialSong[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurateSpecialEditorProps {
|
||||||
|
special: CurateSpecial;
|
||||||
|
locale: 'de' | 'en';
|
||||||
|
onBack: () => void;
|
||||||
|
onSaveStartTime: (songId: number, startTime: number) => Promise<void>;
|
||||||
|
backLabel?: string;
|
||||||
|
headerPrefix?: string;
|
||||||
|
noSongsHint?: string;
|
||||||
|
noSongsSubHint?: string;
|
||||||
|
instructionsText?: string;
|
||||||
|
savingLabel?: string;
|
||||||
|
saveChangesLabel?: string;
|
||||||
|
savedLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveLocalized = (value: LocalizedString | null | undefined, locale: 'de' | 'en'): string | undefined => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
return value[locale] ?? value.en ?? value.de;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CurateSpecialEditor({
|
||||||
|
special,
|
||||||
|
locale,
|
||||||
|
onBack,
|
||||||
|
onSaveStartTime,
|
||||||
|
backLabel = '← Back',
|
||||||
|
headerPrefix = 'Edit Special:',
|
||||||
|
noSongsHint = 'No songs assigned to this special yet.',
|
||||||
|
noSongsSubHint = 'Go back to the dashboard to add songs to this special.',
|
||||||
|
instructionsText = 'Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.',
|
||||||
|
savingLabel = '💾 Saving...',
|
||||||
|
saveChangesLabel = '💾 Save Changes',
|
||||||
|
savedLabel = '✓ Saved',
|
||||||
|
}: CurateSpecialEditorProps) {
|
||||||
|
const [selectedSongId, setSelectedSongId] = useState<number | null>(
|
||||||
|
special.songs.length > 0 ? special.songs[0].songId : null
|
||||||
|
);
|
||||||
|
const [pendingStartTime, setPendingStartTime] = useState<number | null>(
|
||||||
|
special.songs.length > 0 ? special.songs[0].startTime : null
|
||||||
|
);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const specialName = resolveLocalized(special.name, locale) ?? `Special #${special.id}`;
|
||||||
|
const specialSubtitle = resolveLocalized(special.subtitle ?? null, locale);
|
||||||
|
|
||||||
|
const unlockSteps = JSON.parse(special.unlockSteps);
|
||||||
|
const totalDuration = unlockSteps[unlockSteps.length - 1];
|
||||||
|
|
||||||
|
const selectedSpecialSong = special.songs.find(ss => ss.songId === selectedSongId) ?? null;
|
||||||
|
|
||||||
|
const handleStartTimeChange = (newStartTime: number) => {
|
||||||
|
setPendingStartTime(newStartTime);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedSongId || pendingStartTime === null) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSaveStartTime(selectedSongId, pendingStartTime);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', maxWidth: '1200px', margin: '0 auto' }}>
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1rem',
|
||||||
|
background: '#e5e7eb',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backLabel}
|
||||||
|
</button>
|
||||||
|
<h1 style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||||
|
{headerPrefix} {specialName}
|
||||||
|
</h1>
|
||||||
|
{specialSubtitle && (
|
||||||
|
<p style={{ fontSize: '1.125rem', color: '#4b5563', marginTop: '0.25rem' }}>
|
||||||
|
{specialSubtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p style={{ color: '#666', marginTop: '0.5rem' }}>
|
||||||
|
Max Attempts: {special.maxAttempts} | Puzzle Duration: {totalDuration}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{special.songs.length === 0 ? (
|
||||||
|
<div style={{ padding: '2rem', background: '#f3f4f6', borderRadius: '0.5rem', textAlign: 'center' }}>
|
||||||
|
<p>{noSongsHint}</p>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#666', marginTop: '0.5rem' }}>
|
||||||
|
{noSongsSubHint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '2rem' }}>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||||
|
Select Song to Curate
|
||||||
|
</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '1rem' }}>
|
||||||
|
{special.songs.map(ss => (
|
||||||
|
<div
|
||||||
|
key={ss.songId}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedSongId(ss.songId);
|
||||||
|
setPendingStartTime(ss.startTime);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '1rem',
|
||||||
|
background: selectedSongId === ss.songId ? '#4f46e5' : '#f3f4f6',
|
||||||
|
color: selectedSongId === ss.songId ? 'white' : 'black',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: selectedSongId === ss.songId ? '2px solid #4f46e5' : '2px solid transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>{ss.song.title}</div>
|
||||||
|
<div style={{ fontSize: '0.875rem', opacity: 0.8 }}>{ss.song.artist}</div>
|
||||||
|
<div style={{ fontSize: '0.75rem', marginTop: '0.5rem', opacity: 0.7 }}>
|
||||||
|
Start: {ss.startTime}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedSpecialSong && (
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', marginBottom: '1rem' }}>
|
||||||
|
Curate: {selectedSpecialSong.song.title}
|
||||||
|
</h2>
|
||||||
|
<div style={{ background: '#f9fafb', padding: '1.5rem', borderRadius: '0.5rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<p style={{ fontSize: '0.875rem', color: '#666', margin: 0 }}>
|
||||||
|
{instructionsText}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasUnsavedChanges || saving}
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 1.5rem',
|
||||||
|
background: hasUnsavedChanges ? '#10b981' : '#e5e7eb',
|
||||||
|
color: hasUnsavedChanges ? 'white' : '#9ca3af',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
cursor: hasUnsavedChanges && !saving ? 'pointer' : 'not-allowed',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
whiteSpace: 'nowrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? savingLabel : hasUnsavedChanges ? saveChangesLabel : savedLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<WaveformEditor
|
||||||
|
audioUrl={`/uploads/${selectedSpecialSong.song.filename}`}
|
||||||
|
startTime={pendingStartTime ?? selectedSpecialSong.startTime}
|
||||||
|
duration={totalDuration}
|
||||||
|
unlockSteps={unlockSteps}
|
||||||
|
onStartTimeChange={handleStartTimeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -65,6 +65,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
const [commentSending, setCommentSending] = useState(false);
|
const [commentSending, setCommentSending] = useState(false);
|
||||||
const [commentSent, setCommentSent] = useState(false);
|
const [commentSent, setCommentSent] = useState(false);
|
||||||
const [commentError, setCommentError] = useState<string | null>(null);
|
const [commentError, setCommentError] = useState<string | null>(null);
|
||||||
|
const [commentCollapsed, setCommentCollapsed] = useState(true);
|
||||||
|
const [rewrittenMessage, setRewrittenMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const updateCountdown = () => {
|
const updateCountdown = () => {
|
||||||
@@ -321,6 +323,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
|
|
||||||
setCommentSending(true);
|
setCommentSending(true);
|
||||||
setCommentError(null);
|
setCommentError(null);
|
||||||
|
setRewrittenMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const playerIdentifier = getOrCreatePlayerId();
|
const playerIdentifier = getOrCreatePlayerId();
|
||||||
@@ -328,6 +331,26 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
throw new Error('Could not get player identifier');
|
throw new Error('Could not get player identifier');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Rewrite message using AI
|
||||||
|
const rewriteResponse = await fetch('/api/rewrite-message', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: commentText.trim() })
|
||||||
|
});
|
||||||
|
|
||||||
|
let finalMessage = commentText.trim();
|
||||||
|
if (rewriteResponse.ok) {
|
||||||
|
const rewriteData = await rewriteResponse.json();
|
||||||
|
if (rewriteData.rewrittenMessage) {
|
||||||
|
finalMessage = rewriteData.rewrittenMessage;
|
||||||
|
// If message was changed significantly (simple check), show it
|
||||||
|
if (finalMessage !== commentText.trim()) {
|
||||||
|
setRewrittenMessage(finalMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Send comment
|
||||||
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
|
// For specials, genreId should be null. For global, also null. For genres, we pass null and let API determine from puzzle
|
||||||
const genreId = isSpecial ? null : null; // API will determine from puzzle
|
const genreId = isSpecial ? null : null; // API will determine from puzzle
|
||||||
|
|
||||||
@@ -339,7 +362,8 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
puzzleId: dailyPuzzle.id,
|
puzzleId: dailyPuzzle.id,
|
||||||
genreId: genreId,
|
genreId: genreId,
|
||||||
message: commentText.trim(),
|
message: finalMessage,
|
||||||
|
originalMessage: commentText.trim() !== finalMessage ? commentText.trim() : undefined,
|
||||||
playerIdentifier: playerIdentifier
|
playerIdentifier: playerIdentifier
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -602,9 +626,24 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
{/* Comment Form */}
|
{/* Comment Form */}
|
||||||
{!commentSent && (
|
{!commentSent && (
|
||||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
|
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(255,255,255,0.5)', borderRadius: '0.5rem' }}>
|
||||||
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', marginBottom: '0.5rem' }}>
|
<div
|
||||||
|
onClick={() => setCommentCollapsed(!commentCollapsed)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: commentCollapsed ? 0 : '1rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3 style={{ fontSize: '1rem', fontWeight: 'bold', margin: 0 }}>
|
||||||
{t('sendComment')}
|
{t('sendComment')}
|
||||||
</h3>
|
</h3>
|
||||||
|
<span>{commentCollapsed ? '▼' : '▲'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!commentCollapsed && (
|
||||||
|
<>
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
|
<p style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', marginBottom: '0.75rem' }}>
|
||||||
{t('commentHelp')}
|
{t('commentHelp')}
|
||||||
</p>
|
</p>
|
||||||
@@ -612,7 +651,7 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
value={commentText}
|
value={commentText}
|
||||||
onChange={(e) => setCommentText(e.target.value)}
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
placeholder={t('commentPlaceholder')}
|
placeholder={t('commentPlaceholder')}
|
||||||
maxLength={2000}
|
maxLength={300}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minHeight: '100px',
|
minHeight: '100px',
|
||||||
@@ -622,13 +661,14 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
resize: 'vertical',
|
resize: 'vertical',
|
||||||
marginBottom: '0.5rem'
|
marginBottom: '0.5rem',
|
||||||
|
display: 'block' // Ensure block display for proper alignment
|
||||||
}}
|
}}
|
||||||
disabled={commentSending}
|
disabled={commentSending}
|
||||||
/>
|
/>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
|
<span style={{ fontSize: '0.75rem', color: 'var(--muted-foreground)' }}>
|
||||||
{commentText.length}/2000
|
{commentText.length}/300
|
||||||
</span>
|
</span>
|
||||||
{commentError && (
|
{commentError && (
|
||||||
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
|
<span style={{ fontSize: '0.75rem', color: 'var(--danger)' }}>
|
||||||
@@ -648,14 +688,22 @@ export default function Game({ dailyPuzzle, genre = null, isSpecial = false, max
|
|||||||
>
|
>
|
||||||
{commentSending ? t('sending') : t('sendComment')}
|
{commentSending ? t('sending') : t('sendComment')}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{commentSent && (
|
{commentSent && (
|
||||||
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
|
<div style={{ marginTop: '1.5rem', padding: '1rem', background: 'rgba(16, 185, 129, 0.1)', borderRadius: '0.5rem', border: '1px solid rgba(16, 185, 129, 0.3)' }}>
|
||||||
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center' }}>
|
<p style={{ fontSize: '0.9rem', color: 'var(--success)', textAlign: 'center', marginBottom: rewrittenMessage ? '0.5rem' : 0 }}>
|
||||||
{t('commentSent')}
|
{t('commentSent')}
|
||||||
</p>
|
</p>
|
||||||
|
{rewrittenMessage && (
|
||||||
|
<div style={{ fontSize: '0.85rem', color: 'var(--muted-foreground)', textAlign: 'center' }}>
|
||||||
|
<p style={{ marginBottom: '0.25rem' }}>{t('commentRewritten')}</p>
|
||||||
|
<p style={{ fontStyle: 'italic' }}>"{rewrittenMessage}"</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
17
lib/curatorAuth.ts
Normal file
17
lib/curatorAuth.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export function getCuratorAuthHeaders() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return {
|
||||||
|
'x-curator-auth': '',
|
||||||
|
'x-curator-username': '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authToken = localStorage.getItem('hoerdle_curator_auth');
|
||||||
|
const username = localStorage.getItem('hoerdle_curator_username') || '';
|
||||||
|
return {
|
||||||
|
'x-curator-auth': authToken || '',
|
||||||
|
'x-curator-username': username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -58,9 +58,11 @@
|
|||||||
"skipBonus": "Bonus überspringen",
|
"skipBonus": "Bonus überspringen",
|
||||||
"notQuite": "Nicht ganz!",
|
"notQuite": "Nicht ganz!",
|
||||||
"sendComment": "Nachricht an Kurator senden",
|
"sendComment": "Nachricht an Kurator senden",
|
||||||
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres...",
|
"sendCommentCollapsed": "Nachricht an Kurator senden",
|
||||||
|
"commentPlaceholder": "Schreibe eine Nachricht an die Kuratoren dieses Genres... Bitte bleibe freundlich und höflich.",
|
||||||
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
|
"commentHelp": "Teile deine Gedanken zum Rätsel mit den Kuratoren. Deine Nachricht wird ihnen angezeigt.",
|
||||||
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
|
"commentSent": "✓ Nachricht gesendet! Vielen Dank für dein Feedback.",
|
||||||
|
"commentRewritten": "Deine Nachricht wurde automatisch freundlicher formuliert:",
|
||||||
"commentError": "Fehler beim Senden der Nachricht",
|
"commentError": "Fehler beim Senden der Nachricht",
|
||||||
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
|
"commentRateLimited": "Du hast bereits eine Nachricht für dieses Rätsel gesendet.",
|
||||||
"sending": "Wird gesendet...",
|
"sending": "Wird gesendet...",
|
||||||
@@ -272,7 +274,25 @@
|
|||||||
"noBatchOperations": "Keine Batch-Operationen angegeben",
|
"noBatchOperations": "Keine Batch-Operationen angegeben",
|
||||||
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
|
"batchUpdateSuccess": "Erfolgreich {success} von {processed} Titeln aktualisiert",
|
||||||
"batchUpdateError": "Fehler: {error}",
|
"batchUpdateError": "Fehler: {error}",
|
||||||
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung"
|
"batchUpdateNetworkError": "Netzwerkfehler bei der Batch-Aktualisierung",
|
||||||
|
"backToDashboard": "Zurück zum Dashboard",
|
||||||
|
"curateSpecialsButton": "Specials kuratieren",
|
||||||
|
"curateSpecialsTitle": "Deine Specials kuratieren",
|
||||||
|
"curateSpecialsDescription": "Hier kannst du die Startzeiten der Songs in deinen zugewiesenen Specials für das Rätsel feinjustieren.",
|
||||||
|
"noSpecialPermissions": "Dir sind keine Specials zugeordnet.",
|
||||||
|
"noSpecialsInScope": "Keine Specials zum Kuratieren vorhanden.",
|
||||||
|
"curateSpecialSongCount": "{count, plural, one {# Song} other {# Songs}} in diesem Special",
|
||||||
|
"curateSpecialOpen": "Öffnen",
|
||||||
|
"specialForbidden": "Du darfst dieses Special nicht bearbeiten.",
|
||||||
|
"specialNotFound": "Special nicht gefunden.",
|
||||||
|
"backToCuratorSpecials": "Zurück zur Special-Übersicht",
|
||||||
|
"curateSpecialHeaderPrefix": "Special kuratieren:",
|
||||||
|
"curateSpecialNoSongs": "Diesem Special sind noch keine Songs zugeordnet.",
|
||||||
|
"curateSpecialNoSongsSub": "Gehe zurück zu deinem Dashboard, um diesem Special Songs zuzuordnen.",
|
||||||
|
"curateSpecialInstructions": "Klicke auf die Waveform, um zu wählen, wo das Rätsel starten soll. Der hervorgehobene Bereich zeigt, was die Spieler hören.",
|
||||||
|
"saving": "💾 Speichere...",
|
||||||
|
"saveChanges": "💾 Änderungen speichern",
|
||||||
|
"saved": "✓ Gespeichert"
|
||||||
},
|
},
|
||||||
"CuratorHelp": {
|
"CuratorHelp": {
|
||||||
"title": "Kurator-Hilfe & Handbuch",
|
"title": "Kurator-Hilfe & Handbuch",
|
||||||
|
|||||||
@@ -58,9 +58,11 @@
|
|||||||
"skipBonus": "Skip Bonus",
|
"skipBonus": "Skip Bonus",
|
||||||
"notQuite": "Not quite!",
|
"notQuite": "Not quite!",
|
||||||
"sendComment": "Send message to curator",
|
"sendComment": "Send message to curator",
|
||||||
"commentPlaceholder": "Write a message to the curators of this genre...",
|
"sendCommentCollapsed": "Send message to curator",
|
||||||
"commentHelp": "Share your thoughts about the puzzle with the curators. Your message will be displayed to them.",
|
"commentPlaceholder": "Write a message to the curators of this genre... Please remain friendly and polite.",
|
||||||
|
"commentHelp": "Share your thoughts on the puzzle with the curators. Your message will be shown to them.",
|
||||||
"commentSent": "✓ Message sent! Thank you for your feedback.",
|
"commentSent": "✓ Message sent! Thank you for your feedback.",
|
||||||
|
"commentRewritten": "Your message was automatically rephrased to be more friendly:",
|
||||||
"commentError": "Error sending message",
|
"commentError": "Error sending message",
|
||||||
"commentRateLimited": "You have already sent a message for this puzzle.",
|
"commentRateLimited": "You have already sent a message for this puzzle.",
|
||||||
"sending": "Sending...",
|
"sending": "Sending...",
|
||||||
@@ -272,7 +274,25 @@
|
|||||||
"noBatchOperations": "No batch operations specified",
|
"noBatchOperations": "No batch operations specified",
|
||||||
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
|
"batchUpdateSuccess": "Successfully updated {success} of {processed} songs",
|
||||||
"batchUpdateError": "Error: {error}",
|
"batchUpdateError": "Error: {error}",
|
||||||
"batchUpdateNetworkError": "Network error during batch update"
|
"batchUpdateNetworkError": "Network error during batch update",
|
||||||
|
"backToDashboard": "Back to dashboard",
|
||||||
|
"curateSpecialsButton": "Curate Specials",
|
||||||
|
"curateSpecialsTitle": "Curate your Specials",
|
||||||
|
"curateSpecialsDescription": "Here you can fine-tune the start times of the songs in your assigned specials for the puzzle.",
|
||||||
|
"noSpecialPermissions": "You do not have any specials assigned to you.",
|
||||||
|
"noSpecialsInScope": "No specials available for you to curate.",
|
||||||
|
"curateSpecialSongCount": "{count, plural, one {# song} other {# songs}} in this special",
|
||||||
|
"curateSpecialOpen": "Open",
|
||||||
|
"specialForbidden": "You are not allowed to edit this special.",
|
||||||
|
"specialNotFound": "Special not found.",
|
||||||
|
"backToCuratorSpecials": "Back to specials overview",
|
||||||
|
"curateSpecialHeaderPrefix": "Curate Special:",
|
||||||
|
"curateSpecialNoSongs": "No songs assigned to this special yet.",
|
||||||
|
"curateSpecialNoSongsSub": "Go back to your dashboard to add songs to this special.",
|
||||||
|
"curateSpecialInstructions": "Click on the waveform to select where the puzzle should start. The highlighted region shows what players will hear.",
|
||||||
|
"saving": "💾 Saving...",
|
||||||
|
"saveChanges": "💾 Save Changes",
|
||||||
|
"saved": "✓ Saved"
|
||||||
},
|
},
|
||||||
"CuratorHelp": {
|
"CuratorHelp": {
|
||||||
"title": "Curator Help & Manual",
|
"title": "Curator Help & Manual",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hoerdle",
|
"name": "hoerdle",
|
||||||
"version": "0.1.6.2",
|
"version": "0.1.6.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
98
scripts/restore-restic.sh
Normal file
98
scripts/restore-restic.sh
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Restic restore script for Hördle deployment
|
||||||
|
# Restores files from the Restic repository created by backup-restic.sh
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/restore-restic.sh [SNAPSHOT] [TARGET_DIR]
|
||||||
|
#
|
||||||
|
# SNAPSHOT : Optional. Restic snapshot reference (ID, tag, or "latest").
|
||||||
|
# Defaults to "latest".
|
||||||
|
# TARGET_DIR : Optional. Directory to restore into.
|
||||||
|
# Defaults to "./restic-restore-<DATE>-<TIME>".
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# scripts/restore-restic.sh
|
||||||
|
# → Restore latest snapshot into a new timestamped directory
|
||||||
|
#
|
||||||
|
# scripts/restore-restic.sh latest ./restore-latest
|
||||||
|
# → Restore latest snapshot into ./restore-latest
|
||||||
|
#
|
||||||
|
# scripts/restore-restic.sh d3adb33f ./restore-commit
|
||||||
|
# → Restore specific snapshot ID into ./restore-commit
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "💾 Restoring from Restic backup..."
|
||||||
|
|
||||||
|
if ! command -v restic >/dev/null 2>&1; then
|
||||||
|
echo "❌ restic nicht im PATH gefunden. Bitte installiere restic oder füge es zum PATH hinzu."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Erforderliche Umgebungsvariablen prüfen
|
||||||
|
if [ -z "$RESTIC_PASSWORD" ]; then
|
||||||
|
echo "❌ RESTIC_PASSWORD ist nicht gesetzt. Abbruch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$RESTIC_AUTH_USER" ] || [ -z "$RESTIC_AUTH_PASSWORD" ]; then
|
||||||
|
echo "❌ RESTIC_AUTH_USER oder RESTIC_AUTH_PASSWORD ist nicht gesetzt. Abbruch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Repository-URL auf Basis des Backup-Skripts
|
||||||
|
RESTIC_REPO="rest:https://${RESTIC_AUTH_USER}:${RESTIC_AUTH_PASSWORD}@restic.elpatron.me/"
|
||||||
|
|
||||||
|
# Passwort für restic exportieren
|
||||||
|
export RESTIC_PASSWORD
|
||||||
|
|
||||||
|
# Snapshot-Referenz und Zielverzeichnis bestimmen
|
||||||
|
SNAPSHOT_REF="${1:-latest}"
|
||||||
|
TIMESTAMP="$(date +%Y-%m-%d_%H-%M-%S)"
|
||||||
|
DEFAULT_TARGET_DIR="./restic-restore-${TIMESTAMP}"
|
||||||
|
TARGET_DIR="${2:-$DEFAULT_TARGET_DIR}"
|
||||||
|
|
||||||
|
echo " Repository : $RESTIC_REPO"
|
||||||
|
echo " Snapshot : $SNAPSHOT_REF"
|
||||||
|
echo " Zielordner : $TARGET_DIR"
|
||||||
|
|
||||||
|
# Prüfen, ob Repository existiert
|
||||||
|
if ! restic -r "$RESTIC_REPO" snapshots >/dev/null 2>&1; then
|
||||||
|
echo "❌ Kein gültiges Restic-Repository gefunden (oder keine Snapshots vorhanden)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Zielverzeichnis vorbereiten
|
||||||
|
if [ -e "$TARGET_DIR" ] && [ ! -d "$TARGET_DIR" ]; then
|
||||||
|
echo "❌ $TARGET_DIR existiert und ist kein Verzeichnis. Abbruch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -d "$TARGET_DIR" ]; then
|
||||||
|
echo " Erstelle Zielverzeichnis $TARGET_DIR ..."
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " Verfügbare Snapshots (gekürzt):"
|
||||||
|
restic -r "$RESTIC_REPO" snapshots --compact || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo " Starte Restic-Restore..."
|
||||||
|
|
||||||
|
RESTIC_EXIT_CODE=0
|
||||||
|
|
||||||
|
# Standard-Restore: gesamtes Repo in Zielverzeichnis
|
||||||
|
# (Das spiegelt die beim Backup gesicherten Pfade unterhalb von TARGET_DIR.)
|
||||||
|
restic -r "$RESTIC_REPO" restore "$SNAPSHOT_REF" \
|
||||||
|
--target "$TARGET_DIR" || RESTIC_EXIT_CODE=$?
|
||||||
|
|
||||||
|
if [ $RESTIC_EXIT_CODE -eq 0 ]; then
|
||||||
|
echo "✅ Restic-Restore erfolgreich abgeschlossen."
|
||||||
|
echo " Wiederhergestellte Daten befinden sich in: $TARGET_DIR"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "⚠️ Restic-Restore fehlgeschlagen (Exit-Code: $RESTIC_EXIT_CODE)."
|
||||||
|
exit $RESTIC_EXIT_CODE
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user