Refactor annotation views: replace QualityChoiceView with AnnotationPageView and implement SingleChoiceEntryView and SingleChoiceBinaryEntryView components

This commit is contained in:
averel10
2026-03-20 10:51:13 +01:00
parent f26a0a7906
commit 392e54d1e6
4 changed files with 213 additions and 57 deletions

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { getAnnotationEntries } from '@/app/actions/annotations'; import { getAnnotationEntries } from '@/app/actions/annotations';
import { getExperimentById } from '@/app/actions/experiment'; import { getExperimentById } from '@/app/actions/experiment';
import QualityChoiceView from '@/components/AnnotationViews/QualityChoiceView'; import AnnotationPageView from '@/components/AnnotationViews/AnnotationPageView';
interface Props { interface Props {
params: Promise<{ experimentId: string}>; params: Promise<{ experimentId: string}>;
@@ -52,25 +52,11 @@ export default async function AnnotatePage({ params }: Props) {
); );
} }
// Render based on prototype type return (
const renderAnnotationView = () => { <AnnotationPageView
switch (prototype) { entries={entries}
case 'quality-choice': experimentId={experimentId}
return <QualityChoiceView entries={entries} experimentId={experimentId} />; viewType={prototype || ''}
case 'binary': />
//return <BinaryView entries={entries} experimentId={experimentId} />; );
return <div/>;
default:
return (
<div className="max-w-xl mx-auto py-16 text-center">
<p className="text-gray-600">Unbekannter Annotation-Typ: {prototype}</p>
<Link href="/" className="text-blue-600 hover:underline mt-4 inline-block">
Startseite
</Link>
</div>
);
}
};
return renderAnnotationView();
} }

View File

@@ -1,20 +1,93 @@
'use client'; 'use client';
import { useState, useMemo, useTransition } from 'react'; import { useState, useMemo, useTransition, JSX } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { saveAnnotations } from '@/app/actions/annotations'; import { saveAnnotations } from '@/app/actions/annotations';
import { type DatasetEntryForAnnotation } from '@/lib/dialects'; import { DIALECT_LABELS, type DatasetEntryForAnnotation } from '@/lib/dialects';
import QualityChoiceEntryView from './QualityChoiceEntryView'; import SingleChoiceEntryView from './SingleChoiceEntryView';
import SingleChoiceBinaryEntryView from './SingleChoiceBinaryEntryView';
interface QualityChoiceViewProps { export interface EntryViewProps {
entries: DatasetEntryForAnnotation[]; entry: DatasetEntryForAnnotation;
experimentId: number; onSave: (rating: number) => Promise<void>;
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 [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: <div>Wie authentisch klingt dieses Sample nach dem Dialekt der Region <span className="text-blue-600">{DIALECT_LABELS[dialectLabel]}</span>?</div>,
};
case 'binary':
return {
ratingOptions: [
{ value: 0, label: 'Ja' },
{ value: 2, label: 'Unklar' },
{ value: 1, label: 'Nein' },
],
question: <div>Klingt dieses Sample nach dem Dialekt der Region <span className="text-blue-600">{DIALECT_LABELS[dialectLabel]}</span>?</div>,
};
default:
return {
ratingOptions: [{ value: 1, label: '' }],
question: <div />,
};
}
};
// Determine which view component to use based on viewType
const getViewComponent = () => {
const config = getAnnotationConfig(currentEntry.dialect);
switch (viewType) {
case 'quality-choice':
return (
<SingleChoiceEntryView
entry={currentEntry}
onSave={handleSaveEntry}
isSaving={isPending}
ratingOptions={config.ratingOptions}
question={config.question}
/>
);
case 'binary':
return (
<SingleChoiceBinaryEntryView
entry={currentEntry}
onSave={handleSaveEntry}
isSaving={isPending}
ratingOptions={config.ratingOptions}
question={config.question}
/>
);
default:
return null;
}
};
// Find the first unannotated entry to start from // Find the first unannotated entry to start from
const startingIndex = useMemo(() => { const startingIndex = useMemo(() => {
const firstUnannotatedIndex = entries.findIndex((e) => e.annotation === null); const firstUnannotatedIndex = entries.findIndex((e) => e.annotation === null);
@@ -46,11 +119,23 @@ export default function QualityChoicePage({ entries, experimentId }: QualityChoi
handleNext(); handleNext();
}); });
}; };
// Calculate progress: count already-annotated entries // Calculate progress: count already-annotated entries
const annotatedCount = entries.filter((e) => e.annotation !== null).length; const annotatedCount = entries.filter((e) => e.annotation !== null).length;
const progressPct = Math.round(((annotatedCount) / entries.length) * 100); const progressPct = Math.round(((annotatedCount) / entries.length) * 100);
// Check if view type is valid
const viewElement = getViewComponent();
if (!viewElement) {
return (
<div className="max-w-xl mx-auto py-16 text-center">
<p className="text-gray-600">Unbekannter Annotation-Typ: {viewType}</p>
<Link href="/" className="text-blue-600 hover:underline mt-4 inline-block">
Startseite
</Link>
</div>
);
}
if (isComplete) { if (isComplete) {
return ( return (
<div className="max-w-xl mx-auto text-center py-20"> <div className="max-w-xl mx-auto text-center py-20">
@@ -113,13 +198,7 @@ export default function QualityChoicePage({ entries, experimentId }: QualityChoi
{/* ── Sample entry ────────────────────────────────────── */} {/* ── Sample entry ────────────────────────────────────── */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{currentEntry && ( {viewElement}
<QualityChoiceEntryView
entry={currentEntry}
onSave={handleSaveEntry}
isSaving={isPending}
/>
)}
</div> </div>
</div> </div>
); );

View File

@@ -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<number | null>(null);
const [fullyPlayed, setFullyPlayed] = useState<boolean>(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 (
<div
className={`border rounded-xl p-5 bg-white shadow-sm transition-colors duration-300 ${
fullyPlayed && answer !== null ? 'border-green-300' : 'border-gray-200'
}`}
>
{/* Sample header */}
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-medium text-gray-400 uppercase tracking-wide">
Sample - {entry.externalId}
</span>
{fullyPlayed && (
<span className="text-xs text-green-600 font-medium">
Gehört
</span>
)}
</div>
{/* Waveform player */}
<WaveformPlayer
src={audioSrc}
durationMs={entry.durationMs}
onFullyPlayed={() => setFullyPlayed(true)}
/>
{/* Must-listen hint */}
{!fullyPlayed && (
<p className="text-xs text-amber-600 mt-2">
Bitte das Sample vollständig anhören, bevor Sie eine Bewertung abgeben.
</p>
)}
{/* Dialect + question */}
<div className="mt-4 mb-3">
<p className="text-sm font-semibold text-gray-700">
{question}
</p>
</div>
{/* Radio Select */}
<div className="flex items-center gap-2 bg-gray-50 p-1.5 rounded-full border border-gray-200 w-fit mt-4">
{ratingOptions?.map(({ value, label }) => {
const selected = answer === value;
const disabled = !fullyPlayed || isSaving;
return (
<button
key={value}
disabled={disabled}
onClick={() => setAnswer(value)}
className={`px-6 py-2 rounded-full text-sm font-medium transition-all duration-200 ${
disabled
? 'opacity-50 cursor-not-allowed text-gray-400'
: selected
? 'bg-white text-blue-600 shadow-sm border border-blue-200'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{label}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -3,32 +3,20 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import WaveformPlayer from '../WaveformPlayer'; import WaveformPlayer from '../WaveformPlayer';
import { type DatasetEntryForAnnotation, DIALECT_LABELS } from '@/lib/dialects'; import { type DatasetEntryForAnnotation, DIALECT_LABELS } from '@/lib/dialects';
import { type EntryViewProps } from './AnnotationPageView';
const AUTO_ADVANCE_DELAY_MS = 600; const AUTO_ADVANCE_DELAY_MS = 600;
const RATING_OPTIONS = (dialectLabel: string) => [ export default function SingleChoiceEntryView({
{ 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<void>;
isSaving: boolean;
}
export default function QualityChoiceEntryView({
entry, entry,
onSave, onSave,
isSaving, isSaving,
}: QualityChoiceEntryViewProps) { ratingOptions,
question,
}: EntryViewProps) {
const [answer, setAnswer] = useState<number | null>(null); const [answer, setAnswer] = useState<number | null>(null);
const [fullyPlayed, setFullyPlayed] = useState<boolean>(false); const [fullyPlayed, setFullyPlayed] = useState<boolean>(false);
const dialectLabel = DIALECT_LABELS[entry.dialect] ?? entry.dialect;
const fileExt = entry.fileName.substring(entry.fileName.lastIndexOf('.')); const fileExt = entry.fileName.substring(entry.fileName.lastIndexOf('.'));
const audioSrc = `/public/datasets/${entry.datasetId}/${entry.externalId}${fileExt}`; const audioSrc = `/public/datasets/${entry.datasetId}/${entry.externalId}${fileExt}`;
@@ -85,14 +73,13 @@ export default function QualityChoiceEntryView({
{/* Dialect + question */} {/* Dialect + question */}
<div className="mt-4 mb-3"> <div className="mt-4 mb-3">
<p className="text-sm font-semibold text-gray-700"> <p className="text-sm font-semibold text-gray-700">
Wie authentisch klingt dieses Sample nach dem Dialekt der Region{' '} {question}
<span className="text-blue-600">{dialectLabel}</span>?
</p> </p>
</div> </div>
{/* Rating buttons */} {/* Rating buttons */}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{RATING_OPTIONS(dialectLabel).map(({ value, label }) => { {ratingOptions?.map(({ value, label }) => {
const selected = answer === value; const selected = answer === value;
const disabled = !fullyPlayed || isSaving; const disabled = !fullyPlayed || isSaving;
return ( return (