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,
|
||||
"tag": "0008_smiling_lady_mastermind",
|
||||
"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.
|
||||
* Silently ignores duplicates (onConflictDoNothing).
|
||||
* Updates existing annotations with the same userId, experimentId, and datasetEntryId.
|
||||
*/
|
||||
export async function saveAnnotations(
|
||||
ratings: { entryId: number; rating: number; dialectLabel: string }[],
|
||||
@@ -81,7 +81,13 @@ export async function saveAnnotations(
|
||||
await db
|
||||
.insert(annotation)
|
||||
.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 { auth } from '@/lib/auth';
|
||||
import { getAnnotationEntries } from '@/app/actions/annotations';
|
||||
import SingleChoiceView from '@/components/AnnotationViews/SingleChoiceView';
|
||||
import { getExperimentById } from '@/app/actions/experiment';
|
||||
import QualityChoiceView from '@/components/AnnotationViews/QualityChoiceView';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ experimentId: string}>;
|
||||
@@ -55,9 +55,11 @@ export default async function AnnotatePage({ params }: Props) {
|
||||
// Render based on prototype type
|
||||
const renderAnnotationView = () => {
|
||||
switch (prototype) {
|
||||
case 'single-choice':
|
||||
return <SingleChoiceView entries={entries} experimentId={experimentId} />;
|
||||
// Add more prototypes here as needed
|
||||
case 'quality-choice':
|
||||
return <QualityChoiceView entries={entries} experimentId={experimentId} />;
|
||||
case 'binary':
|
||||
//return <BinaryView entries={entries} experimentId={experimentId} />;
|
||||
return <div/>;
|
||||
default:
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-16 text-center">
|
||||
|
||||
@@ -73,7 +73,7 @@ export default async function HomePage() {
|
||||
{/* Progress */}
|
||||
<div className="mt-3">
|
||||
<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>
|
||||
</div>
|
||||
<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 = [
|
||||
{ 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({
|
||||
@@ -18,7 +20,7 @@ export default function EditableExperimentHeader({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [name, setName] = useState(experiment.name);
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -49,7 +51,7 @@ export default function EditableExperimentHeader({
|
||||
|
||||
function handleCancel() {
|
||||
setName(experiment.name);
|
||||
setAnnotationTool(experiment.annotationTool || 'single-choice');
|
||||
setAnnotationTool(experiment.annotationTool || 'quality-choice');
|
||||
setPublished(experiment.published || false);
|
||||
setDescription(experiment.description || '');
|
||||
setError(null);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { useAudio } from './AudioProvider';
|
||||
|
||||
interface WaveformPlayerProps {
|
||||
src: string;
|
||||
durationMs?: number | null;
|
||||
/** When false the player should stop if it was playing */
|
||||
isActive: boolean;
|
||||
onPlay: () => void;
|
||||
onPlay?: () => void;
|
||||
onFullyPlayed: () => void;
|
||||
}
|
||||
|
||||
@@ -16,10 +15,15 @@ const BAR_COUNT = 120;
|
||||
export default function WaveformPlayer({
|
||||
src,
|
||||
durationMs,
|
||||
isActive,
|
||||
onPlay,
|
||||
onFullyPlayed,
|
||||
}: 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 canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const rafRef = useRef<number>(0);
|
||||
@@ -39,6 +43,21 @@ export default function WaveformPlayer({
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -103,7 +122,8 @@ export default function WaveformPlayer({
|
||||
const handlePlay = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
onPlay();
|
||||
setCurrentAudio(playerId);
|
||||
onPlay?.();
|
||||
audio.play();
|
||||
setIsPlaying(true);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
@@ -122,7 +142,7 @@ export default function WaveformPlayer({
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
if (!fullyPlayedRef.current) {
|
||||
fullyPlayedRef.current = true;
|
||||
onFullyPlayed();
|
||||
onFullyPlayed?.();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ export const annotation = sqliteTable(
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`)
|
||||
.$onUpdate(() => sql`(unixepoch())`),
|
||||
}
|
||||
},
|
||||
(table) => ({
|
||||
uniqueAnnotation: uniqueIndex('unique_annotation').on(table.datasetEntryId, table.experimentId, table.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export const annotationRelations = relations(annotation, ({ one }) => ({
|
||||
|
||||
Reference in New Issue
Block a user