Compare commits

...

14 Commits

Author SHA1 Message Date
Hördle Bot
a4e61de53f chore: bump version to 0.1.6.27 2025-12-06 21:58:29 +01:00
Hördle Bot
73c1c1cf89 fix: restore accidentally deleted admin specials editor page 2025-12-06 21:58:27 +01:00
Hördle Bot
83e1281079 fix: restore deleted curator implementation files 2025-12-06 21:50:59 +01:00
Hördle Bot
2e1f1e599b chore: bump version to 0.1.6.26 2025-12-06 15:39:15 +01:00
Hördle Bot
71c4e2509f feat(admin): add danger zone buttons for resetting ratings and activations
- Added reset all user ratings button to admin danger zone
- Added reset all activations button to admin danger zone
- Created API endpoints: /api/admin/reset-ratings and /api/admin/reset-activations
- Removed old non-localized routes: /app/admin, /app/curator
- Removed unused page.module.css
- All admin functionality now uses localized routes (/[locale]/admin)
2025-12-06 15:38:46 +01:00
Hördle Bot
9cef1c78d3 ... 2025-12-06 14:26:52 +01:00
Hördle Bot
6741eeb7fa feat: Album-Cover-Anzeige in Titelliste mit Tooltip hinzugefügt
- Neue Spalte 'Cover' in der Curator-Titelliste zeigt an, ob ein Album-Cover vorhanden ist
- Tooltip zeigt das Cover-Bild beim Hovern über die Cover-Spalte
- Übersetzungen für DE und EN hinzugefügt
2025-12-06 14:24:00 +01:00
Hördle Bot
71b8e98f23 feat: add hidden flag to specials 2025-12-06 01:35:01 +01:00
Hördle Bot
bc2c0bad59 Bump version to 0.1.6.24 2025-12-06 00:36:02 +01:00
Hördle Bot
812d6ff10d Add timeline display below waveform in waveform editor 2025-12-06 00:36:00 +01:00
Hördle Bot
aed300b1bb Bump version to 0.1.6.23 2025-12-05 23:55:24 +01:00
Hördle Bot
e93b3b9096 Keep playback cursor visible when pausing in waveform editor 2025-12-05 23:55:21 +01:00
Hördle Bot
cdd2ff15d5 Bump version to 0.1.6.22 2025-12-05 22:25:41 +01:00
Hördle Bot
adcfbfa811 Fix pause functionality for waveform editor playback buttons 2025-12-05 22:25:39 +01:00
17 changed files with 938 additions and 1868 deletions

View File

@@ -15,6 +15,7 @@ interface Special {
launchDate?: string;
endDate?: string;
curator?: string;
hidden?: boolean;
_count?: {
songs: number;
};
@@ -119,6 +120,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const [newSpecialLaunchDate, setNewSpecialLaunchDate] = useState('');
const [newSpecialEndDate, setNewSpecialEndDate] = useState('');
const [newSpecialCurator, setNewSpecialCurator] = useState('');
const [newSpecialHidden, setNewSpecialHidden] = useState(false);
const [editingSpecialId, setEditingSpecialId] = useState<number | null>(null);
const [editSpecialName, setEditSpecialName] = useState({ de: '', en: '' });
@@ -129,6 +131,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
const [editSpecialLaunchDate, setEditSpecialLaunchDate] = useState('');
const [editSpecialEndDate, setEditSpecialEndDate] = useState('');
const [editSpecialCurator, setEditSpecialCurator] = useState('');
const [editSpecialHidden, setEditSpecialHidden] = useState(false);
// News state
const [news, setNews] = useState<News[]>([]);
@@ -393,6 +396,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
launchDate: newSpecialLaunchDate || null,
endDate: newSpecialEndDate || null,
curator: newSpecialCurator || null,
hidden: newSpecialHidden,
}),
});
if (res.ok) {
@@ -404,6 +408,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
setNewSpecialLaunchDate('');
setNewSpecialEndDate('');
setNewSpecialCurator('');
setNewSpecialHidden(false);
fetchSpecials();
} else {
const errorData = await res.json().catch(() => ({}));
@@ -491,6 +496,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
setEditSpecialLaunchDate(special.launchDate ? new Date(special.launchDate).toISOString().split('T')[0] : '');
setEditSpecialEndDate(special.endDate ? new Date(special.endDate).toISOString().split('T')[0] : '');
setEditSpecialCurator(special.curator || '');
setEditSpecialHidden(special.hidden || false);
};
const saveEditedSpecial = async () => {
@@ -516,6 +522,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
launchDate: editSpecialLaunchDate || null,
endDate: editSpecialEndDate || null,
curator: editSpecialCurator || null,
hidden: editSpecialHidden,
}),
});
if (res.ok) {
@@ -1389,6 +1396,18 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('curator')}</label>
<input type="text" placeholder={t('curator')} value={newSpecialCurator} onChange={e => setNewSpecialCurator(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666', visibility: 'hidden' }}>Hidden</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', height: '38px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={newSpecialHidden}
onChange={e => setNewSpecialHidden(e.target.checked)}
style={{ width: '1rem', height: '1rem' }}
/>
Hidden
</label>
</div>
<button
type="submit"
className="btn-primary"
@@ -1418,7 +1437,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
}}
>
<span>
{getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})
{special.hidden && <span title="Hidden from navigation">👁🗨</span>} {getLocalizedValue(special.name, activeTab)} ({special._count?.songs || 0})
</span>
{special.subtitle && (
<span
@@ -1508,6 +1527,18 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
<label style={{ fontSize: '0.75rem', color: '#666' }}>{t('curator')}</label>
<input type="text" value={editSpecialCurator} onChange={e => setEditSpecialCurator(e.target.value)} className="form-input" />
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<label style={{ fontSize: '0.75rem', color: '#666', visibility: 'hidden' }}>Hidden</label>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', height: '38px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editSpecialHidden}
onChange={e => setEditSpecialHidden(e.target.checked)}
style={{ width: '1rem', height: '1rem' }}
/>
Hidden
</label>
</div>
<button
onClick={saveEditedSpecial}
className="btn-primary"
@@ -2297,6 +2328,7 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
<p style={{ marginBottom: '1rem', color: '#666' }}>
These actions are destructive and cannot be undone.
</p>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
<button
onClick={async () => {
if (window.confirm('⚠️ WARNING: This will delete ALL data from the database (Songs, Genres, Specials, Puzzles) and re-import songs from the uploads folder.\n\nExisting genres and specials will be LOST and must be recreated manually.\n\nAre you sure you want to proceed?')) {
@@ -2330,7 +2362,83 @@ export default function AdminPage({ params }: { params: { locale: string } }) {
Rebuild Database
</button>
<button
onClick={async () => {
if (window.confirm('⚠️ WARNING: This will reset ALL user ratings for all songs to 0.\n\nThis action cannot be undone.\n\nAre you sure you want to proceed?')) {
try {
setMessage('Resetting all ratings...');
const res = await fetch('/api/admin/reset-ratings', {
method: 'POST',
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
alert(data.message);
fetchSongs();
setMessage('');
} else {
alert('Failed to reset ratings. Check server logs.');
setMessage('Reset failed.');
}
} catch (e) {
console.error(e);
alert('Reset failed due to network error.');
setMessage('');
}
}
}}
style={{
padding: '0.75rem 1.5rem',
background: '#f59e0b',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
🔄 Reset All User Ratings
</button>
<button
onClick={async () => {
if (window.confirm('⚠️ WARNING: This will delete ALL daily puzzles (activations) from the database.\n\nThis means all songs will show 0 activations.\n\nThis action cannot be undone.\n\nAre you sure you want to proceed?')) {
try {
setMessage('Resetting all activations...');
const res = await fetch('/api/admin/reset-activations', {
method: 'POST',
headers: getAuthHeaders()
});
if (res.ok) {
const data = await res.json();
alert(data.message);
fetchSongs();
fetchDailyPuzzles();
setMessage('');
} else {
alert('Failed to reset activations. Check server logs.');
setMessage('Reset failed.');
}
} catch (e) {
console.error(e);
alert('Reset failed due to network error.');
setMessage('');
}
}
}}
style={{
padding: '0.75rem 1.5rem',
background: '#f59e0b',
color: 'white',
border: 'none',
borderRadius: '0.25rem',
cursor: 'pointer',
fontWeight: 'bold'
}}
>
🔄 Reset All Activations
</button>
</div>
</div>
</div>
);

View File

@@ -43,7 +43,9 @@ export default async function Home({
const genres = await prisma.genre.findMany({
where: { active: true },
});
const specials = await prisma.special.findMany();
const specials = await prisma.special.findMany({
where: { hidden: false },
});
// Sort in memory
genres.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));

View File

@@ -86,6 +86,7 @@ export default async function SpecialPage({ params }: PageProps) {
specials.sort((a, b) => getLocalizedValue(a.name, locale).localeCompare(getLocalizedValue(b.name, locale)));
const activeSpecials = specials.filter(s => {
if (s.hidden) return false;
const sStarted = !s.launchDate || s.launchDate <= now;
const sEnded = s.endDate && s.endDate < now;
return sStarted && !sEnded;

View File

@@ -1,14 +0,0 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Hördle Admin Dashboard",
description: "Admin dashboard for managing songs and daily puzzles",
};
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
try {
// Delete all daily puzzles (activations)
const result = await prisma.dailyPuzzle.deleteMany({});
return NextResponse.json({
success: true,
message: `Successfully deleted ${result.count} daily puzzles (activations)`,
count: result.count,
});
} catch (error) {
console.error('Error resetting activations:', error);
return NextResponse.json(
{ error: 'Failed to reset activations' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function POST(req: NextRequest) {
try {
// Reset all song ratings to 0
const result = await prisma.song.updateMany({
data: {
averageRating: 0,
ratingCount: 0,
},
});
return NextResponse.json({
success: true,
message: `Successfully reset ratings for ${result.count} songs`,
count: result.count,
});
} catch (error) {
console.error('Error resetting ratings:', error);
return NextResponse.json(
{ error: 'Failed to reset ratings' },
{ status: 500 }
);
}
}

View File

@@ -43,18 +43,20 @@ export async function PUT(
try {
const { id } = await params;
const specialId = parseInt(id);
const { name, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
const { name, maxAttempts, unlockSteps, launchDate, endDate, curator, hidden } = await request.json();
const updateData: any = {};
if (name !== undefined) updateData.name = name;
if (maxAttempts !== undefined) updateData.maxAttempts = maxAttempts;
if (unlockSteps !== undefined) updateData.unlockSteps = typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps);
if (launchDate !== undefined) updateData.launchDate = launchDate ? new Date(launchDate) : null;
if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null;
if (curator !== undefined) updateData.curator = curator || null;
if (hidden !== undefined) updateData.hidden = Boolean(hidden);
const special = await prisma.special.update({
where: { id: specialId },
data: {
name,
maxAttempts,
unlockSteps: typeof unlockSteps === 'string' ? unlockSteps : JSON.stringify(unlockSteps),
launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
}
data: updateData
});
return NextResponse.json(special);

View File

@@ -35,7 +35,7 @@ export async function POST(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator } = await request.json();
const { name, subtitle, maxAttempts = 7, unlockSteps = '[2,4,7,11,16,30,60]', launchDate, endDate, curator, hidden = false } = await request.json();
if (!name) {
return NextResponse.json({ error: 'Name is required' }, { status: 400 });
}
@@ -68,6 +68,7 @@ export async function POST(request: Request) {
launchDate: launchDate ? new Date(launchDate) : null,
endDate: endDate ? new Date(endDate) : null,
curator: curator || null,
hidden: Boolean(hidden),
},
});
return NextResponse.json(special);
@@ -91,7 +92,7 @@ export async function PUT(request: Request) {
const authError = await requireAdminAuth(request as any);
if (authError) return authError;
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator } = await request.json();
const { id, name, subtitle, maxAttempts, unlockSteps, launchDate, endDate, curator, hidden } = await request.json();
if (!id) {
return NextResponse.json({ error: 'ID required' }, { status: 400 });
}
@@ -119,6 +120,7 @@ export async function PUT(request: Request) {
if (launchDate !== undefined) updateData.launchDate = launchDate ? new Date(launchDate) : null;
if (endDate !== undefined) updateData.endDate = endDate ? new Date(endDate) : null;
if (curator !== undefined) updateData.curator = curator || null;
if (hidden !== undefined) updateData.hidden = Boolean(hidden);
const updated = await prisma.special.update({
where: { id: Number(id) },

View File

@@ -22,6 +22,7 @@ interface Song {
filename: string;
createdAt: string;
releaseYear: number | null;
coverImage: string | null;
activations?: number;
puzzles?: any[];
genres: Genre[];
@@ -128,6 +129,7 @@ export default function CuratorPageClient() {
const [itemsPerPage, setItemsPerPage] = useState(10);
const [playingSongId, setPlayingSongId] = useState<number | null>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const [hoveredCoverSongId, setHoveredCoverSongId] = useState<number | null>(null);
// Comments state
const [comments, setComments] = useState<CuratorComment[]>([]);
@@ -1663,6 +1665,7 @@ export default function CuratorPageClient() {
>
{t('columnYear')} {sortField === 'releaseYear' && (sortDirection === 'asc' ? '↑' : '↓')}
</th>
<th style={{ padding: '0.5rem' }}>{t('columnCover')}</th>
<th style={{ padding: '0.5rem' }}>{t('columnGenresSpecials')}</th>
<th
style={{ padding: '0.5rem', cursor: 'pointer' }}
@@ -1778,6 +1781,48 @@ export default function CuratorPageClient() {
'-'
)}
</td>
<td
style={{
padding: '0.5rem',
textAlign: 'center',
position: 'relative',
cursor: song.coverImage ? 'pointer' : 'default'
}}
onMouseEnter={() => song.coverImage && setHoveredCoverSongId(song.id)}
onMouseLeave={() => setHoveredCoverSongId(null)}
>
{song.coverImage ? '✓' : '-'}
{hoveredCoverSongId === song.id && song.coverImage && (
<div
style={{
position: 'absolute',
top: '100%',
left: '50%',
transform: 'translateX(-50%)',
marginTop: '0.5rem',
zIndex: 1000,
padding: '0.5rem',
background: 'white',
border: '1px solid #d1d5db',
borderRadius: '0.5rem',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
pointerEvents: 'none',
}}
>
<img
src={`/uploads/covers/${song.coverImage}`}
alt={`Cover für ${song.title}`}
style={{
width: '200px',
height: '200px',
objectFit: 'cover',
borderRadius: '0.25rem',
display: 'block',
}}
/>
</div>
)}
</td>
<td style={{ padding: '0.5rem' }}>
{isEditing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>

View File

@@ -1,141 +0,0 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
min-height: 100vh;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

View File

@@ -12,11 +12,14 @@ interface WaveformEditorProps {
export default function WaveformEditor({ audioUrl, startTime, duration, unlockSteps, onStartTimeChange }: WaveformEditorProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const timelineRef = useRef<HTMLCanvasElement>(null);
const [audioBuffer, setAudioBuffer] = useState<AudioBuffer | null>(null);
const [audioDuration, setAudioDuration] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [playingSegment, setPlayingSegment] = useState<number | null>(null);
const [isPlayingFullTitle, setIsPlayingFullTitle] = useState(false);
const [pausedPosition, setPausedPosition] = useState<number | null>(null); // Position when paused
const [pausedType, setPausedType] = useState<'selection' | 'title' | null>(null); // Type of playback that was paused
const [zoom, setZoom] = useState(1); // 1 = full view, higher = zoomed in
const [viewOffset, setViewOffset] = useState(0); // Offset in seconds for panning
const [playbackPosition, setPlaybackPosition] = useState<number | null>(null); // Current playback position in seconds
@@ -56,6 +59,80 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
};
}, [audioUrl]);
// Draw timeline
useEffect(() => {
if (!audioDuration || !timelineRef.current) return;
const timeline = timelineRef.current;
const ctx = timeline.getContext('2d');
if (!ctx) return;
const width = timeline.width;
const height = timeline.height;
// Calculate visible range based on zoom and offset (same as waveform)
const visibleDuration = audioDuration / zoom;
const visibleStart = Math.max(0, Math.min(viewOffset, audioDuration - visibleDuration));
const visibleEnd = Math.min(audioDuration, visibleStart + visibleDuration);
// Clear timeline
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, width, height);
// Draw border
ctx.strokeStyle = '#e5e7eb';
ctx.lineWidth = 1;
ctx.strokeRect(0, 0, width, height);
// Calculate appropriate time interval based on visible duration
let timeInterval = 1; // Start with 1 second
if (visibleDuration > 60) timeInterval = 10;
else if (visibleDuration > 30) timeInterval = 5;
else if (visibleDuration > 10) timeInterval = 2;
else if (visibleDuration > 5) timeInterval = 1;
else if (visibleDuration > 1) timeInterval = 0.5;
else timeInterval = 0.1;
// Draw time markers
ctx.strokeStyle = '#9ca3af';
ctx.lineWidth = 1;
ctx.fillStyle = '#374151';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
const startTimeMarker = Math.floor(visibleStart / timeInterval) * timeInterval;
for (let time = startTimeMarker; time <= visibleEnd; time += timeInterval) {
const timePx = ((time - visibleStart) / visibleDuration) * width;
if (timePx >= 0 && timePx <= width) {
// Draw tick mark
ctx.beginPath();
ctx.moveTo(timePx, 0);
ctx.lineTo(timePx, height);
ctx.stroke();
// Draw time label
const timeLabel = time.toFixed(timeInterval < 1 ? 1 : 0);
ctx.fillText(`${timeLabel}s`, timePx, 2);
}
}
// Draw current playback position if playing
if (playbackPosition !== null) {
const playbackPx = ((playbackPosition - visibleStart) / visibleDuration) * width;
if (playbackPx >= 0 && playbackPx <= width) {
ctx.strokeStyle = '#10b981';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(playbackPx, 0);
ctx.lineTo(playbackPx, height);
ctx.stroke();
}
}
}, [audioDuration, zoom, viewOffset, playbackPosition]);
useEffect(() => {
if (!audioBuffer || !canvasRef.current) return;
@@ -234,12 +311,21 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
setHoverPreviewTime(null);
};
const stopPlayback = () => {
const stopPlayback = (savePosition = false) => {
if (savePosition && playbackPosition !== null) {
// Save current position for resume
setPausedPosition(playbackPosition);
// Keep playbackPosition visible (don't set to null) so cursor stays visible
} else {
// Clear paused position if stopping completely
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(null);
}
sourceRef.current?.stop();
setIsPlaying(false);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPlaybackPosition(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
@@ -307,35 +393,55 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
const handlePlayFull = () => {
if (!audioBuffer || !audioContextRef.current) return;
// If full selection playback is already playing, stop it
// If full selection playback is already playing, pause it
if (isPlaying && playingSegment === null && !isPlayingFullTitle) {
stopPlayback();
stopPlayback(true); // Save position
setPausedType('selection');
return;
}
// Stop any current playback (segment, full selection, or full title)
stopPlayback();
// Determine start position (resume from pause or start from beginning)
const resumePosition = pausedType === 'selection' && pausedPosition !== null
? pausedPosition
: startTime;
const remainingDuration = resumePosition >= startTime + duration
? 0
: (startTime + duration) - resumePosition;
if (remainingDuration <= 0) {
// Already finished, reset
setPausedPosition(null);
setPausedType(null);
return;
}
// Start full selection playback
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = startTime;
playbackOffsetRef.current = resumePosition;
source.start(0, startTime, duration);
source.start(0, resumePosition, remainingDuration);
sourceRef.current = source;
setIsPlaying(true);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPlaybackPosition(startTime);
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(resumePosition);
source.onended = () => {
setIsPlaying(false);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPlaybackPosition(null);
setPausedPosition(null);
setPausedType(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
@@ -346,35 +452,55 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
const handlePlayFullTitle = () => {
if (!audioBuffer || !audioContextRef.current) return;
// If full title playback is already playing, stop it
// If full title playback is already playing, pause it
if (isPlaying && isPlayingFullTitle) {
stopPlayback();
stopPlayback(true); // Save position
setPausedType('title');
return;
}
// Stop any current playback (segment, full selection, or full title)
stopPlayback();
// Start full title playback (from 0 to audioDuration)
// Determine start position (resume from pause or start from beginning)
const resumePosition = pausedType === 'title' && pausedPosition !== null
? pausedPosition
: 0;
const remainingDuration = resumePosition >= audioDuration
? 0
: audioDuration - resumePosition;
if (remainingDuration <= 0) {
// Already finished, reset
setPausedPosition(null);
setPausedType(null);
return;
}
// Start full title playback (from resumePosition to audioDuration)
const source = audioContextRef.current.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContextRef.current.destination);
playbackStartTimeRef.current = audioContextRef.current.currentTime;
playbackOffsetRef.current = 0;
playbackOffsetRef.current = resumePosition;
source.start(0, 0, audioDuration);
source.start(0, resumePosition, remainingDuration);
sourceRef.current = source;
setIsPlaying(true);
setPlayingSegment(null);
setIsPlayingFullTitle(true);
setPlaybackPosition(0);
setPausedPosition(null);
setPausedType(null);
setPlaybackPosition(resumePosition);
source.onended = () => {
setIsPlaying(false);
setPlayingSegment(null);
setIsPlayingFullTitle(false);
setPlaybackPosition(null);
setPausedPosition(null);
setPausedType(null);
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
@@ -440,6 +566,7 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
)}
</div>
<div style={{ position: 'relative' }}>
<canvas
ref={canvasRef}
width={800}
@@ -452,9 +579,25 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
height: 'auto',
cursor: 'pointer',
border: '1px solid #e5e7eb',
borderRadius: '0.5rem'
borderRadius: '0.5rem 0.5rem 0 0',
display: 'block'
}}
/>
<canvas
ref={timelineRef}
width={800}
height={30}
style={{
width: '100%',
height: '30px',
border: '1px solid #e5e7eb',
borderTop: 'none',
borderRadius: '0 0 0.5rem 0.5rem',
display: 'block',
background: '#ffffff'
}}
/>
</div>
{/* Playback Controls */}
<div style={{ marginTop: '1rem', display: 'flex', gap: '1rem', alignItems: 'center', flexWrap: 'wrap' }}>
@@ -470,7 +613,11 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
fontWeight: 'bold'
}}
>
{isPlaying && playingSegment === null && !isPlayingFullTitle ? '⏸ Pause' : '▶ Play Full Selection'}
{isPlaying && playingSegment === null && !isPlayingFullTitle
? '⏸ Pause'
: (pausedType === 'selection' && pausedPosition !== null
? '▶ Resume'
: '▶ Play Full Selection')}
</button>
<button
onClick={handlePlayFullTitle}
@@ -484,7 +631,11 @@ export default function WaveformEditor({ audioUrl, startTime, duration, unlockSt
fontWeight: 'bold'
}}
>
{isPlaying && isPlayingFullTitle ? '⏸ Pause' : '▶ Play Full Title'}
{isPlaying && isPlayingFullTitle
? '⏸ Pause'
: (pausedType === 'title' && pausedPosition !== null
? '▶ Resume'
: '▶ Play Full Title')}
</button>
<div style={{ fontSize: '0.875rem', color: '#666' }}>

View File

@@ -231,6 +231,7 @@
"columnTitle": "Titel",
"columnArtist": "Artist",
"columnYear": "Jahr",
"columnCover": "Cover",
"columnGenresSpecials": "Genres / Specials",
"columnAdded": "Hinzugefügt",
"columnActivations": "Aktivierungen",

View File

@@ -231,6 +231,7 @@
"columnTitle": "Title",
"columnArtist": "Artist",
"columnYear": "Year",
"columnCover": "Cover",
"columnGenresSpecials": "Genres / Specials",
"columnAdded": "Added",
"columnActivations": "Activations",

View File

@@ -1,6 +1,6 @@
{
"name": "hoerdle",
"version": "0.1.6.21",
"version": "0.1.6.27",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -0,0 +1,20 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Special" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" JSONB NOT NULL,
"subtitle" JSONB,
"maxAttempts" INTEGER NOT NULL DEFAULT 7,
"unlockSteps" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"launchDate" DATETIME,
"endDate" DATETIME,
"curator" TEXT,
"hidden" BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO "new_Special" ("createdAt", "curator", "endDate", "id", "launchDate", "maxAttempts", "name", "subtitle", "unlockSteps") SELECT "createdAt", "curator", "endDate", "id", "launchDate", "maxAttempts", "name", "subtitle", "unlockSteps" FROM "Special";
DROP TABLE "Special";
ALTER TABLE "new_Special" RENAME TO "Special";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -47,6 +47,7 @@ model Special {
launchDate DateTime?
endDate DateTime?
curator String?
hidden Boolean @default(false)
songs SpecialSong[]
puzzles DailyPuzzle[]
news News[]