Refactor annotation views: replace QualityChoiceView with AnnotationPageView and implement SingleChoiceEntryView and SingleChoiceBinaryEntryView components
This commit is contained in:
@@ -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<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 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
|
||||
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 (
|
||||
<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) {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto text-center py-20">
|
||||
@@ -113,13 +198,7 @@ export default function QualityChoicePage({ entries, experimentId }: QualityChoi
|
||||
|
||||
{/* ── Sample entry ────────────────────────────────────── */}
|
||||
<div className="flex flex-col gap-6">
|
||||
{currentEntry && (
|
||||
<QualityChoiceEntryView
|
||||
entry={currentEntry}
|
||||
onSave={handleSaveEntry}
|
||||
isSaving={isPending}
|
||||
/>
|
||||
)}
|
||||
{viewElement}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
104
src/components/AnnotationViews/SingleChoiceBinaryEntryView.tsx
Normal file
104
src/components/AnnotationViews/SingleChoiceBinaryEntryView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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<void>;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
export default function QualityChoiceEntryView({
|
||||
export default function SingleChoiceEntryView({
|
||||
entry,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: QualityChoiceEntryViewProps) {
|
||||
ratingOptions,
|
||||
question,
|
||||
}: EntryViewProps) {
|
||||
const [answer, setAnswer] = useState<number | null>(null);
|
||||
const [fullyPlayed, setFullyPlayed] = useState<boolean>(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 */}
|
||||
<div className="mt-4 mb-3">
|
||||
<p className="text-sm font-semibold text-gray-700">
|
||||
Wie authentisch klingt dieses Sample nach dem Dialekt der Region{' '}
|
||||
<span className="text-blue-600">{dialectLabel}</span>?
|
||||
{question}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rating buttons */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{RATING_OPTIONS(dialectLabel).map(({ value, label }) => {
|
||||
{ratingOptions?.map(({ value, label }) => {
|
||||
const selected = answer === value;
|
||||
const disabled = !fullyPlayed || isSaving;
|
||||
return (
|
||||
Reference in New Issue
Block a user