diff --git a/src/app/annotate/[experimentId]/page.tsx b/src/app/annotate/[experimentId]/page.tsx index d088c22..c3b3c9c 100644 --- a/src/app/annotate/[experimentId]/page.tsx +++ b/src/app/annotate/[experimentId]/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { auth } from '@/lib/auth'; import { getAnnotationEntries } from '@/app/actions/annotations'; import { getExperimentById } from '@/app/actions/experiment'; -import QualityChoiceView from '@/components/AnnotationViews/QualityChoiceView'; +import AnnotationPageView from '@/components/AnnotationViews/AnnotationPageView'; interface Props { params: Promise<{ experimentId: string}>; @@ -52,25 +52,11 @@ export default async function AnnotatePage({ params }: Props) { ); } - // Render based on prototype type - const renderAnnotationView = () => { - switch (prototype) { - case 'quality-choice': - return ; - case 'binary': - //return ; - return
; - default: - return ( -
-

Unbekannter Annotation-Typ: {prototype}

- - ← Startseite - -
- ); - } - }; - - return renderAnnotationView(); + return ( + + ); } diff --git a/src/components/AnnotationViews/QualityChoiceView.tsx b/src/components/AnnotationViews/AnnotationPageView.tsx similarity index 57% rename from src/components/AnnotationViews/QualityChoiceView.tsx rename to src/components/AnnotationViews/AnnotationPageView.tsx index 6eb7939..17c4eb2 100644 --- a/src/components/AnnotationViews/QualityChoiceView.tsx +++ b/src/components/AnnotationViews/AnnotationPageView.tsx @@ -1,20 +1,93 @@ 'use client'; -import { useState, useMemo, useTransition } from 'react'; +import { useState, useMemo, useTransition, JSX } from 'react'; import Link from 'next/link'; import { saveAnnotations } from '@/app/actions/annotations'; -import { type DatasetEntryForAnnotation } from '@/lib/dialects'; -import QualityChoiceEntryView from './QualityChoiceEntryView'; +import { DIALECT_LABELS, type DatasetEntryForAnnotation } from '@/lib/dialects'; +import SingleChoiceEntryView from './SingleChoiceEntryView'; +import SingleChoiceBinaryEntryView from './SingleChoiceBinaryEntryView'; -interface QualityChoiceViewProps { - entries: DatasetEntryForAnnotation[]; - experimentId: number; +export interface EntryViewProps { + entry: DatasetEntryForAnnotation; + onSave: (rating: number) => Promise; + isSaving: boolean; + ratingOptions?: { value: number; label: JSX.Element | string }[]; + question?: JSX.Element | string; } -export default function QualityChoicePage({ entries, experimentId }: QualityChoiceViewProps) { +interface AnnotationPageViewProps { + entries: DatasetEntryForAnnotation[]; + experimentId: number; + viewType: string; +} + +export default function AnnotationPageView({ + entries, + experimentId, + viewType, +}: AnnotationPageViewProps) { const [isPending, startTransition] = useTransition(); + const getAnnotationConfig = (dialectLabel: string) => { + switch (viewType) { + case 'quality-choice': + return { + ratingOptions: [ + { value: 1, label: `Klingt überhaupt nicht nach ${DIALECT_LABELS[dialectLabel]}` }, + { value: 2, label: `Klingt eher nicht nach ${DIALECT_LABELS[dialectLabel]}` }, + { value: 3, label: 'Schwer zu beurteilen' }, + { value: 4, label: `Klingt eher nach ${DIALECT_LABELS[dialectLabel]}` }, + { value: 5, label: `Klingt eindeutig nach ${DIALECT_LABELS[dialectLabel]}` }, + ], + question:
Wie authentisch klingt dieses Sample nach dem Dialekt der Region {DIALECT_LABELS[dialectLabel]}?
, + }; + case 'binary': + return { + ratingOptions: [ + { value: 0, label: 'Ja' }, + { value: 2, label: 'Unklar' }, + { value: 1, label: 'Nein' }, + ], + question:
Klingt dieses Sample nach dem Dialekt der Region {DIALECT_LABELS[dialectLabel]}?
, + }; + default: + return { + ratingOptions: [{ value: 1, label: '' }], + question:
, + }; + } + }; + + // Determine which view component to use based on viewType + const getViewComponent = () => { + const config = getAnnotationConfig(currentEntry.dialect); + switch (viewType) { + case 'quality-choice': + return ( + + ); + case 'binary': + return ( + + ); + default: + return null; + } + }; + // Find the first unannotated entry to start from const startingIndex = useMemo(() => { const firstUnannotatedIndex = entries.findIndex((e) => e.annotation === null); @@ -46,11 +119,23 @@ export default function QualityChoicePage({ entries, experimentId }: QualityChoi handleNext(); }); }; - // Calculate progress: count already-annotated entries const annotatedCount = entries.filter((e) => e.annotation !== null).length; const progressPct = Math.round(((annotatedCount) / entries.length) * 100); + // Check if view type is valid + const viewElement = getViewComponent(); + if (!viewElement) { + return ( +
+

Unbekannter Annotation-Typ: {viewType}

+ + ← Startseite + +
+ ); + } + if (isComplete) { return (
@@ -113,13 +198,7 @@ export default function QualityChoicePage({ entries, experimentId }: QualityChoi {/* ── Sample entry ────────────────────────────────────── */}
- {currentEntry && ( - - )} + {viewElement}
); diff --git a/src/components/AnnotationViews/SingleChoiceBinaryEntryView.tsx b/src/components/AnnotationViews/SingleChoiceBinaryEntryView.tsx new file mode 100644 index 0000000..9345bce --- /dev/null +++ b/src/components/AnnotationViews/SingleChoiceBinaryEntryView.tsx @@ -0,0 +1,104 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import WaveformPlayer from '../WaveformPlayer'; +import { type EntryViewProps } from './AnnotationPageView'; + +const AUTO_ADVANCE_DELAY_MS = 600; + +export default function SingleChoiceBinaryEntryView({ + entry, + onSave, + isSaving, + ratingOptions, + question, +}: EntryViewProps) { + const [answer, setAnswer] = useState(null); + const [fullyPlayed, setFullyPlayed] = useState(false); + + const fileExt = entry.fileName.substring(entry.fileName.lastIndexOf('.')); + const audioSrc = `/public/datasets/${entry.datasetId}/${entry.externalId}${fileExt}`; + + // Auto-save when answer is selected + useEffect(() => { + if (answer === null || isSaving) return; + if (answer === entry.annotation) return; // No change, don't save + + const timer = setTimeout(() => { + onSave(answer); + }, AUTO_ADVANCE_DELAY_MS); + + return () => clearTimeout(timer); + }, [answer, onSave, entry.annotation, isSaving]); + + useEffect(() => { + // Reset state when entry changes + setAnswer(entry.annotation); + setFullyPlayed(entry.annotation !== null); // If already annotated, mark as fully played to allow changing answer + }, [entry]); + + return ( +
+ {/* Sample header */} +
+ + Sample - {entry.externalId} + + {fullyPlayed && ( + + ✓ Gehört + + )} +
+ + {/* Waveform player */} + setFullyPlayed(true)} + /> + + {/* Must-listen hint */} + {!fullyPlayed && ( +

+ Bitte das Sample vollständig anhören, bevor Sie eine Bewertung abgeben. +

+ )} + + {/* Dialect + question */} +
+

+ {question} +

+
+ + {/* Radio Select */} +
+ {ratingOptions?.map(({ value, label }) => { + const selected = answer === value; + const disabled = !fullyPlayed || isSaving; + return ( + + ); + })} +
+
+ ); +} diff --git a/src/components/AnnotationViews/QualityChoiceEntryView.tsx b/src/components/AnnotationViews/SingleChoiceEntryView.tsx similarity index 80% rename from src/components/AnnotationViews/QualityChoiceEntryView.tsx rename to src/components/AnnotationViews/SingleChoiceEntryView.tsx index 6006109..cd3d18d 100644 --- a/src/components/AnnotationViews/QualityChoiceEntryView.tsx +++ b/src/components/AnnotationViews/SingleChoiceEntryView.tsx @@ -3,32 +3,20 @@ import { useState, useEffect } from 'react'; import WaveformPlayer from '../WaveformPlayer'; import { type DatasetEntryForAnnotation, DIALECT_LABELS } from '@/lib/dialects'; +import { type EntryViewProps } from './AnnotationPageView'; const AUTO_ADVANCE_DELAY_MS = 600; -const RATING_OPTIONS = (dialectLabel: string) => [ - { value: 1, label: `Klingt überhaupt nicht nach ${dialectLabel}` }, - { value: 2, label: `Klingt eher nicht nach ${dialectLabel}` }, - { value: 3, label: 'Schwer zu beurteilen' }, - { value: 4, label: `Klingt eher nach ${dialectLabel}` }, - { value: 5, label: `Klingt eindeutig nach ${dialectLabel}` }, -]; - -interface QualityChoiceEntryViewProps { - entry: DatasetEntryForAnnotation; - onSave: (rating: number) => Promise; - isSaving: boolean; -} - -export default function QualityChoiceEntryView({ +export default function SingleChoiceEntryView({ entry, onSave, isSaving, -}: QualityChoiceEntryViewProps) { + ratingOptions, + question, +}: EntryViewProps) { const [answer, setAnswer] = useState(null); const [fullyPlayed, setFullyPlayed] = useState(false); - const dialectLabel = DIALECT_LABELS[entry.dialect] ?? entry.dialect; const fileExt = entry.fileName.substring(entry.fileName.lastIndexOf('.')); const audioSrc = `/public/datasets/${entry.datasetId}/${entry.externalId}${fileExt}`; @@ -85,14 +73,13 @@ export default function QualityChoiceEntryView({ {/* Dialect + question */}

- Wie authentisch klingt dieses Sample nach dem Dialekt der Region{' '} - {dialectLabel}? + {question}

{/* Rating buttons */}
- {RATING_OPTIONS(dialectLabel).map(({ value, label }) => { + {ratingOptions?.map(({ value, label }) => { const selected = answer === value; const disabled = !fullyPlayed || isSaving; return (