Refactor annotation tools and implement quality choice annotation feature
- Updated the annotation label from "bewertet" to "annotiert" in the HomePage component. - Removed the SingleChoiceView component and replaced it with a new QualityChoiceEntryView component for handling quality choice annotations. - Introduced QualityChoiceView component to manage the flow of quality choice annotations, including navigation and progress tracking. - Updated EditableExperimentHeader to default to 'quality-choice' annotation tool. - Added unique index for annotations in the database schema to prevent duplicate entries. - Enhanced WaveformPlayer to support audio playback state management.
This commit is contained in:
1
drizzle/0009_useful_human_robot.sql
Normal file
1
drizzle/0009_useful_human_robot.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
CREATE UNIQUE INDEX `unique_annotation` ON `annotation` (`dataset_entry_id`,`experiment_id`,`user_id`);
|
||||||
758
drizzle/meta/0009_snapshot.json
Normal file
758
drizzle/meta/0009_snapshot.json
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "73a78a6a-e342-4183-8d27-3a076e9ec87b",
|
||||||
|
"prevId": "d2076a12-9038-4cce-b78c-e3f736effe9c",
|
||||||
|
"tables": {
|
||||||
|
"annotation": {
|
||||||
|
"name": "annotation",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"dataset_entry_id": {
|
||||||
|
"name": "dataset_entry_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"experiment_id": {
|
||||||
|
"name": "experiment_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"name": "rating",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dialect_label": {
|
||||||
|
"name": "dialect_label",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"unique_annotation": {
|
||||||
|
"name": "unique_annotation",
|
||||||
|
"columns": [
|
||||||
|
"dataset_entry_id",
|
||||||
|
"experiment_id",
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"annotation_dataset_entry_id_dataset_entry_id_fk": {
|
||||||
|
"name": "annotation_dataset_entry_id_dataset_entry_id_fk",
|
||||||
|
"tableFrom": "annotation",
|
||||||
|
"tableTo": "dataset_entry",
|
||||||
|
"columnsFrom": [
|
||||||
|
"dataset_entry_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"annotation_experiment_id_experiment_id_fk": {
|
||||||
|
"name": "annotation_experiment_id_experiment_id_fk",
|
||||||
|
"tableFrom": "annotation",
|
||||||
|
"tableTo": "experiment",
|
||||||
|
"columnsFrom": [
|
||||||
|
"experiment_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"account": {
|
||||||
|
"name": "account",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"account_userId_idx": {
|
||||||
|
"name": "account_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"session": {
|
||||||
|
"name": "session",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
},
|
||||||
|
"session_userId_idx": {
|
||||||
|
"name": "session_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user": {
|
||||||
|
"name": "user",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"name": "admin",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"verification_identifier_idx": {
|
||||||
|
"name": "verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
"identifier"
|
||||||
|
],
|
||||||
|
"isUnique": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"dataset_entry": {
|
||||||
|
"name": "dataset_entry",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"external_id": {
|
||||||
|
"name": "external_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"speaker_id": {
|
||||||
|
"name": "speaker_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"model_name": {
|
||||||
|
"name": "model_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"utterance_id": {
|
||||||
|
"name": "utterance_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"utterance_text": {
|
||||||
|
"name": "utterance_text",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dataset_id": {
|
||||||
|
"name": "dataset_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"file_name": {
|
||||||
|
"name": "file_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dialect": {
|
||||||
|
"name": "dialect",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"iteration": {
|
||||||
|
"name": "iteration",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"name": "duration_ms",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rms_value": {
|
||||||
|
"name": "rms_value",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"longest_pause": {
|
||||||
|
"name": "longest_pause",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"utmos_score": {
|
||||||
|
"name": "utmos_score",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"wer_score": {
|
||||||
|
"name": "wer_score",
|
||||||
|
"type": "real",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"dataset_entry_dataset_id_dataset_id_fk": {
|
||||||
|
"name": "dataset_entry_dataset_id_dataset_id_fk",
|
||||||
|
"tableFrom": "dataset_entry",
|
||||||
|
"tableTo": "dataset",
|
||||||
|
"columnsFrom": [
|
||||||
|
"dataset_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"dataset": {
|
||||||
|
"name": "dataset",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"experiment": {
|
||||||
|
"name": "experiment",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"published": {
|
||||||
|
"name": "published",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"annotation_tool": {
|
||||||
|
"name": "annotation_tool",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dataset_id": {
|
||||||
|
"name": "dataset_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(unixepoch())"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"experiment_dataset_id_dataset_id_fk": {
|
||||||
|
"name": "experiment_dataset_id_dataset_id_fk",
|
||||||
|
"tableFrom": "experiment",
|
||||||
|
"tableTo": "dataset",
|
||||||
|
"columnsFrom": [
|
||||||
|
"dataset_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,13 @@
|
|||||||
"when": 1773989236199,
|
"when": 1773989236199,
|
||||||
"tag": "0008_smiling_lady_mastermind",
|
"tag": "0008_smiling_lady_mastermind",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773994442668,
|
||||||
|
"tag": "0009_useful_human_robot",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -66,7 +66,7 @@ export async function getAnnotationEntries(experimentId: number): Promise<Datase
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists a batch of ratings for the authenticated user.
|
* Persists a batch of ratings for the authenticated user.
|
||||||
* Silently ignores duplicates (onConflictDoNothing).
|
* Updates existing annotations with the same userId, experimentId, and datasetEntryId.
|
||||||
*/
|
*/
|
||||||
export async function saveAnnotations(
|
export async function saveAnnotations(
|
||||||
ratings: { entryId: number; rating: number; dialectLabel: string }[],
|
ratings: { entryId: number; rating: number; dialectLabel: string }[],
|
||||||
@@ -81,7 +81,13 @@ export async function saveAnnotations(
|
|||||||
await db
|
await db
|
||||||
.insert(annotation)
|
.insert(annotation)
|
||||||
.values({experimentId, datasetEntryId: entryId, userId: session.user.id, rating, dialectLabel })
|
.values({experimentId, datasetEntryId: entryId, userId: session.user.id, rating, dialectLabel })
|
||||||
.onConflictDoNothing();
|
.onConflictDoUpdate({
|
||||||
|
target: [annotation.userId, annotation.experimentId, annotation.datasetEntryId],
|
||||||
|
set: {
|
||||||
|
rating,
|
||||||
|
dialectLabel,
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { headers } from 'next/headers';
|
|||||||
import Link from 'next/link';
|
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 SingleChoiceView from '@/components/AnnotationViews/SingleChoiceView';
|
|
||||||
import { getExperimentById } from '@/app/actions/experiment';
|
import { getExperimentById } from '@/app/actions/experiment';
|
||||||
|
import QualityChoiceView from '@/components/AnnotationViews/QualityChoiceView';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ experimentId: string}>;
|
params: Promise<{ experimentId: string}>;
|
||||||
@@ -55,9 +55,11 @@ export default async function AnnotatePage({ params }: Props) {
|
|||||||
// Render based on prototype type
|
// Render based on prototype type
|
||||||
const renderAnnotationView = () => {
|
const renderAnnotationView = () => {
|
||||||
switch (prototype) {
|
switch (prototype) {
|
||||||
case 'single-choice':
|
case 'quality-choice':
|
||||||
return <SingleChoiceView entries={entries} experimentId={experimentId} />;
|
return <QualityChoiceView entries={entries} experimentId={experimentId} />;
|
||||||
// Add more prototypes here as needed
|
case 'binary':
|
||||||
|
//return <BinaryView entries={entries} experimentId={experimentId} />;
|
||||||
|
return <div/>;
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="max-w-xl mx-auto py-16 text-center">
|
<div className="max-w-xl mx-auto py-16 text-center">
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export default async function HomePage() {
|
|||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
||||||
<span>{done} / {total} bewertet</span>
|
<span>{done} / {total} annotiert</span>
|
||||||
<span>{pct}%</span>
|
<span>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
|||||||
127
src/components/AnnotationViews/QualityChoiceEntryView.tsx
Normal file
127
src/components/AnnotationViews/QualityChoiceEntryView.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import WaveformPlayer from '../WaveformPlayer';
|
||||||
|
import { type DatasetEntryForAnnotation, DIALECT_LABELS } from '@/lib/dialects';
|
||||||
|
|
||||||
|
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({
|
||||||
|
entry,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
}: QualityChoiceEntryViewProps) {
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
// 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">
|
||||||
|
Wie authentisch klingt dieses Sample nach dem Dialekt der Region{' '}
|
||||||
|
<span className="text-blue-600">{dialectLabel}</span>?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rating buttons */}
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{RATING_OPTIONS(dialectLabel).map(({ value, label }) => {
|
||||||
|
const selected = answer === value;
|
||||||
|
const disabled = !fullyPlayed || isSaving;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => setAnswer(value)}
|
||||||
|
className={`flex items-center gap-3 w-full text-left px-4 py-2.5 rounded-lg border text-sm transition-colors ${
|
||||||
|
disabled
|
||||||
|
? 'opacity-40 cursor-not-allowed border-gray-200 bg-gray-50 text-gray-500'
|
||||||
|
: selected
|
||||||
|
? 'border-blue-500 bg-blue-50 text-blue-800 font-medium'
|
||||||
|
: 'border-gray-200 hover:border-blue-300 hover:bg-blue-50 text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
|
||||||
|
selected
|
||||||
|
? 'border-blue-500 bg-blue-500 text-white'
|
||||||
|
: 'border-gray-300 text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/components/AnnotationViews/QualityChoiceView.tsx
Normal file
126
src/components/AnnotationViews/QualityChoiceView.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo, useTransition } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { saveAnnotations } from '@/app/actions/annotations';
|
||||||
|
import { type DatasetEntryForAnnotation } from '@/lib/dialects';
|
||||||
|
import QualityChoiceEntryView from './QualityChoiceEntryView';
|
||||||
|
|
||||||
|
interface QualityChoiceViewProps {
|
||||||
|
entries: DatasetEntryForAnnotation[];
|
||||||
|
experimentId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QualityChoicePage({ entries, experimentId }: QualityChoiceViewProps) {
|
||||||
|
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
// Find the first unannotated entry to start from
|
||||||
|
const startingIndex = useMemo(() => {
|
||||||
|
const firstUnannotatedIndex = entries.findIndex((e) => e.annotation === null);
|
||||||
|
return firstUnannotatedIndex === -1 ? 0 : firstUnannotatedIndex;
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(startingIndex);
|
||||||
|
|
||||||
|
const currentEntry = entries[currentIndex];
|
||||||
|
const isComplete = entries.every((e) => e.annotation !== null);
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
setCurrentIndex((i) => i - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentIndex + 1 < entries.length) {
|
||||||
|
setCurrentIndex((i) => i + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEntry = async (rating: number) => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const batch = [{ entryId: currentEntry!.id, rating, dialectLabel: currentEntry!.dialect }];
|
||||||
|
await saveAnnotations(batch, experimentId);
|
||||||
|
entries[currentIndex].annotation = rating; // Update local state optimistically
|
||||||
|
handleNext();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate progress: count already-annotated entries
|
||||||
|
const annotatedCount = entries.filter((e) => e.annotation !== null).length;
|
||||||
|
const progressPct = Math.round(((annotatedCount) / entries.length) * 100);
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-xl mx-auto text-center py-20">
|
||||||
|
<div className="text-5xl mb-4">✓</div>
|
||||||
|
<h1 className="text-3xl font-bold text-green-600 mb-3">Fertig!</h1>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
Alle Samples wurden erfolgreich bewertet. Vielen Dank!
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Zurück zur Startseite
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{/* ── Top bar ─────────────────────────────────────────── */}
|
||||||
|
<div className="sticky top-[72px] z-40 bg-white border-b border-gray-200 pt-4 pb-3 mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
← Startseite
|
||||||
|
</Link>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={currentIndex === 0 || isPending}
|
||||||
|
className="text-sm px-3 py-1.5 bg-gray-100 hover:bg-gray-200 disabled:opacity-40 disabled:cursor-not-allowed text-gray-700 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
← Zurück
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={currentIndex >= entries.length - 1 || isPending}
|
||||||
|
className="text-sm px-3 py-1.5 bg-blue-100 hover:bg-blue-200 disabled:opacity-40 disabled:cursor-not-allowed text-blue-700 rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
Weiter →
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||||
|
<div
|
||||||
|
className="bg-blue-500 h-2.5 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="text-xs text-gray-400 mt-1 text-left">{annotatedCount}/{entries.length} annotiert</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-1 text-right">{progressPct}% abgeschlossen</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sample entry ────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
{currentEntry && (
|
||||||
|
<QualityChoiceEntryView
|
||||||
|
entry={currentEntry}
|
||||||
|
onSave={handleSaveEntry}
|
||||||
|
isSaving={isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useEffect, useTransition } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import WaveformPlayer from '../WaveformPlayer';
|
|
||||||
import { saveAnnotations } from '@/app/actions/annotations';
|
|
||||||
import { type DatasetEntryForAnnotation, DIALECT_LABELS } from '@/lib/dialects';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 1;
|
|
||||||
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 SingleChoiceViewProps {
|
|
||||||
entries: DatasetEntryForAnnotation[];
|
|
||||||
experimentId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SingleChoiceView({ entries, experimentId }: SingleChoiceViewProps) {
|
|
||||||
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const [currentPage, setCurrentPage] = useState(0);
|
|
||||||
const [answers, setAnswers] = useState<Record<number, number>>({});
|
|
||||||
const [fullyPlayed, setFullyPlayed] = useState<Set<number>>(new Set());
|
|
||||||
const [activePlayerId, setActivePlayerId] = useState<number | null>(null);
|
|
||||||
const [isComplete, setIsComplete] = useState(false);
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(entries.length / PAGE_SIZE);
|
|
||||||
const pageEntries = entries.slice(currentPage * PAGE_SIZE, (currentPage + 1) * PAGE_SIZE);
|
|
||||||
const progressPct = Math.round((currentPage / totalPages) * 100);
|
|
||||||
|
|
||||||
const allCurrentDone =
|
|
||||||
pageEntries.length > 0 &&
|
|
||||||
pageEntries.every((e) => fullyPlayed.has(e.id) && answers[e.id] !== undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!allCurrentDone || isPending) return;
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
const batch = pageEntries.map((e) => ({ entryId: e.id, rating: answers[e.id], dialectLabel: e.dialect }));
|
|
||||||
startTransition(async () => {
|
|
||||||
await saveAnnotations(batch, experimentId);
|
|
||||||
if (currentPage + 1 >= totalPages) {
|
|
||||||
setIsComplete(true);
|
|
||||||
} else {
|
|
||||||
setCurrentPage((p) => p + 1);
|
|
||||||
setActivePlayerId(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, AUTO_ADVANCE_DELAY_MS);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [allCurrentDone]);
|
|
||||||
|
|
||||||
if (isComplete) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-xl mx-auto text-center py-20">
|
|
||||||
<div className="text-5xl mb-4">✓</div>
|
|
||||||
<h1 className="text-3xl font-bold text-green-600 mb-3">Fertig!</h1>
|
|
||||||
<p className="text-gray-600 mb-8">
|
|
||||||
Alle Samples wurden erfolgreich bewertet. Vielen Dank!
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Zurück zur Startseite
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-3xl mx-auto">
|
|
||||||
{/* ── Top bar ─────────────────────────────────────────── */}
|
|
||||||
<div className="sticky top-[72px] z-40 bg-white border-b border-gray-200 pt-4 pb-3 mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm text-gray-500">
|
|
||||||
Sample {currentPage + 1} von {totalPages}
|
|
||||||
</span>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-sm px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
← Zurück zur Startseite
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
|
||||||
<div
|
|
||||||
className="bg-blue-500 h-2.5 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${progressPct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-1 text-right">{progressPct}% abgeschlossen</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Sample cards ────────────────────────────────────── */}
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
{pageEntries.map((entry, idx) => {
|
|
||||||
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}`;
|
|
||||||
const isListened = fullyPlayed.has(entry.id);
|
|
||||||
const currentRating = answers[entry.id] ?? null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className={`border rounded-xl p-5 bg-white shadow-sm transition-colors duration-300 ${
|
|
||||||
isListened && currentRating !== 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 {currentPage * PAGE_SIZE + idx + 1}
|
|
||||||
</span>
|
|
||||||
{isListened && (
|
|
||||||
<span className="text-xs text-green-600 font-medium">
|
|
||||||
✓ Gehört
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Waveform player */}
|
|
||||||
<WaveformPlayer
|
|
||||||
src={audioSrc}
|
|
||||||
durationMs={entry.durationMs}
|
|
||||||
isActive={activePlayerId === entry.id}
|
|
||||||
onPlay={() => setActivePlayerId(entry.id)}
|
|
||||||
onFullyPlayed={() =>
|
|
||||||
setFullyPlayed((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.add(entry.id);
|
|
||||||
return next;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Must-listen hint */}
|
|
||||||
{!isListened && (
|
|
||||||
<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">
|
|
||||||
Wie authentisch klingt dieses Sample nach dem Dialekt der Region{' '}
|
|
||||||
<span className="text-blue-600">{dialectLabel}</span>?
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rating buttons */}
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{RATING_OPTIONS(dialectLabel).map(({ value, label }) => {
|
|
||||||
const selected = currentRating === value;
|
|
||||||
const disabled = !isListened || isPending;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() =>
|
|
||||||
setAnswers((prev) => ({ ...prev, [entry.id]: value }))
|
|
||||||
}
|
|
||||||
className={`flex items-center gap-3 w-full text-left px-4 py-2.5 rounded-lg border text-sm transition-colors ${
|
|
||||||
disabled
|
|
||||||
? 'opacity-40 cursor-not-allowed border-gray-200 bg-gray-50 text-gray-500'
|
|
||||||
: selected
|
|
||||||
? 'border-blue-500 bg-blue-50 text-blue-800 font-medium'
|
|
||||||
: 'border-gray-200 hover:border-blue-300 hover:bg-blue-50 text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold ${
|
|
||||||
selected
|
|
||||||
? 'border-blue-500 bg-blue-500 text-white'
|
|
||||||
: 'border-gray-300 text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</span>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,9 @@ interface EditableExperimentHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ANNOTATION_TOOLS = [
|
const ANNOTATION_TOOLS = [
|
||||||
{ value: 'single-choice', label: 'Single Choice' },
|
{ value: 'quality-choice', label: 'Quality Choice' },
|
||||||
|
{ value: 'binary', label: 'Binary' },
|
||||||
|
// Future tools can be added here
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function EditableExperimentHeader({
|
export default function EditableExperimentHeader({
|
||||||
@@ -18,7 +20,7 @@ export default function EditableExperimentHeader({
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [name, setName] = useState(experiment.name);
|
const [name, setName] = useState(experiment.name);
|
||||||
const [description, setDescription] = useState(experiment.description || '');
|
const [description, setDescription] = useState(experiment.description || '');
|
||||||
const [annotationTool, setAnnotationTool] = useState(experiment.annotationTool || 'single-choice');
|
const [annotationTool, setAnnotationTool] = useState(experiment.annotationTool || 'quality-choice');
|
||||||
const [published, setPublished] = useState(experiment.published || false);
|
const [published, setPublished] = useState(experiment.published || false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -49,7 +51,7 @@ export default function EditableExperimentHeader({
|
|||||||
|
|
||||||
function handleCancel() {
|
function handleCancel() {
|
||||||
setName(experiment.name);
|
setName(experiment.name);
|
||||||
setAnnotationTool(experiment.annotationTool || 'single-choice');
|
setAnnotationTool(experiment.annotationTool || 'quality-choice');
|
||||||
setPublished(experiment.published || false);
|
setPublished(experiment.published || false);
|
||||||
setDescription(experiment.description || '');
|
setDescription(experiment.description || '');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useAudio } from './AudioProvider';
|
||||||
|
|
||||||
interface WaveformPlayerProps {
|
interface WaveformPlayerProps {
|
||||||
src: string;
|
src: string;
|
||||||
durationMs?: number | null;
|
durationMs?: number | null;
|
||||||
/** When false the player should stop if it was playing */
|
onPlay?: () => void;
|
||||||
isActive: boolean;
|
|
||||||
onPlay: () => void;
|
|
||||||
onFullyPlayed: () => void;
|
onFullyPlayed: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,10 +15,15 @@ const BAR_COUNT = 120;
|
|||||||
export default function WaveformPlayer({
|
export default function WaveformPlayer({
|
||||||
src,
|
src,
|
||||||
durationMs,
|
durationMs,
|
||||||
isActive,
|
|
||||||
onPlay,
|
onPlay,
|
||||||
onFullyPlayed,
|
onFullyPlayed,
|
||||||
}: WaveformPlayerProps) {
|
}: WaveformPlayerProps) {
|
||||||
|
const { currentAudioId, setCurrentAudio } = useAudio();
|
||||||
|
|
||||||
|
// Generate unique ID for this player instance
|
||||||
|
const playerId = useMemo(() => Math.random().toString(36).slice(2), []);
|
||||||
|
const isActive = currentAudioId === playerId;
|
||||||
|
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const rafRef = useRef<number>(0);
|
const rafRef = useRef<number>(0);
|
||||||
@@ -39,6 +43,21 @@ export default function WaveformPlayer({
|
|||||||
}
|
}
|
||||||
}, [isActive, isPlaying]);
|
}, [isActive, isPlaying]);
|
||||||
|
|
||||||
|
// Reset state when src changes and cancel playback
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPeaks([]);
|
||||||
|
setCurrentTime(0);
|
||||||
|
setDuration(durationMs ? durationMs / 1000 : 0);
|
||||||
|
fullyPlayedRef.current = false;
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
}
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
setIsPlaying(false);
|
||||||
|
}, [src, durationMs]);
|
||||||
|
|
||||||
// Decode audio and generate waveform peaks
|
// Decode audio and generate waveform peaks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -103,7 +122,8 @@ export default function WaveformPlayer({
|
|||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
const audio = audioRef.current;
|
const audio = audioRef.current;
|
||||||
if (!audio) return;
|
if (!audio) return;
|
||||||
onPlay();
|
setCurrentAudio(playerId);
|
||||||
|
onPlay?.();
|
||||||
audio.play();
|
audio.play();
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
@@ -122,7 +142,7 @@ export default function WaveformPlayer({
|
|||||||
cancelAnimationFrame(rafRef.current);
|
cancelAnimationFrame(rafRef.current);
|
||||||
if (!fullyPlayedRef.current) {
|
if (!fullyPlayedRef.current) {
|
||||||
fullyPlayedRef.current = true;
|
fullyPlayedRef.current = true;
|
||||||
onFullyPlayed();
|
onFullyPlayed?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ export const annotation = sqliteTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(unixepoch())`)
|
.default(sql`(unixepoch())`)
|
||||||
.$onUpdate(() => sql`(unixepoch())`),
|
.$onUpdate(() => sql`(unixepoch())`),
|
||||||
}
|
},
|
||||||
|
(table) => ({
|
||||||
|
uniqueAnnotation: uniqueIndex('unique_annotation').on(table.datasetEntryId, table.experimentId, table.userId),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const annotationRelations = relations(annotation, ({ one }) => ({
|
export const annotationRelations = relations(annotation, ({ one }) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user