feat: Add admin experiment management pages and components
- Implemented ExperimentPage for viewing and editing individual experiments. - Created ExperimentsAdminPage for managing all experiments with a list and a modal for creating new experiments. - Developed AnnotatePage for annotating experiments with different views based on annotation tools. - Added CreateExperimentModal for creating new experiments with dataset selection. - Introduced DeleteExperimentButton for confirming and executing experiment deletions. - Built EditableExperimentHeader for editing experiment details. - Created ExperimentsList component to display a list of experiments with links to their details.
This commit is contained in:
@@ -87,7 +87,7 @@ export async function saveAnnotations(
|
||||
|
||||
/** Returns all datasets (for the home page). */
|
||||
export async function getAllExperiments() {
|
||||
return db.select().from(experiment).orderBy(experiment.id);
|
||||
return db.select().from(experiment).where(eq(experiment.published, true)).orderBy(experiment.id);
|
||||
}
|
||||
|
||||
/** Returns annotation progress for the current user in a dataset. */
|
||||
|
||||
@@ -7,6 +7,24 @@ import { revalidatePath } from 'next/cache';
|
||||
import { removeAllDatasetEntries } from './remove-dataset-entries';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
|
||||
export async function getAllDatasets() {
|
||||
const result = await requireAdmin();
|
||||
if (!result.authenticated || !result.admin) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
const datasets = await db
|
||||
.select()
|
||||
.from(dataset)
|
||||
.orderBy(dataset.name);
|
||||
return datasets;
|
||||
} catch (error) {
|
||||
console.error('Error fetching datasets:', error);
|
||||
throw new Error('Failed to fetch datasets');
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDataset({ name, description }: { name: string; description?: string }) {
|
||||
const result = await requireAdmin();
|
||||
if (!result.authenticated || !result.admin) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
'use server';
|
||||
|
||||
import db from '@/lib/db';
|
||||
import { experiment } from '@/lib/model/experiment';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { auth, requireAdmin } from '@/lib/auth';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export async function createExperiment({
|
||||
name,
|
||||
description,
|
||||
datasetId,
|
||||
annotationTool,
|
||||
published = false
|
||||
}: {
|
||||
name: string;
|
||||
description?: string;
|
||||
datasetId: number;
|
||||
annotationTool: string;
|
||||
published?: boolean;
|
||||
}) {
|
||||
const result = await requireAdmin();
|
||||
if (!result.authenticated || !result.admin) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
throw new Error('Experiment name is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await db.insert(experiment).values({
|
||||
name: name.trim(),
|
||||
description: description?.trim() || null,
|
||||
datasetId,
|
||||
annotationTool: annotationTool.trim(),
|
||||
published,
|
||||
});
|
||||
revalidatePath('/admin/experiments');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error creating experiment:', error);
|
||||
throw new Error('Failed to create experiment');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateExperiment(
|
||||
id: number,
|
||||
updates: { name?: string; description?: string, annotationTool?: string, published?: boolean }
|
||||
) {
|
||||
const result = await requireAdmin();
|
||||
if (!result.authenticated || !result.admin) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
if (updates.name !== undefined && !updates.name.trim()) {
|
||||
throw new Error('Experiment name cannot be empty');
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData: any = {};
|
||||
if (updates.name !== undefined) {
|
||||
updateData.name = updates.name.trim();
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
updateData.description = updates.description.trim() || null;
|
||||
}
|
||||
if (updates.annotationTool !== undefined) {
|
||||
updateData.annotationTool = updates.annotationTool;
|
||||
}
|
||||
if (updates.published !== undefined) {
|
||||
updateData.published = updates.published;
|
||||
}
|
||||
updateData.updatedAt = new Date();
|
||||
|
||||
const result = await db
|
||||
.update(experiment)
|
||||
.set(updateData)
|
||||
.where(eq(experiment.id, id));
|
||||
revalidatePath(`/admin/experiments/${id}`);
|
||||
revalidatePath('/admin/experiments');
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error updating experiment:', error);
|
||||
throw new Error('Failed to update experiment');
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteExperiment(id: number) {
|
||||
const result = await requireAdmin();
|
||||
if (!result.authenticated || !result.admin) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete the experiment
|
||||
await db.delete(experiment).where(eq(experiment.id, id));
|
||||
|
||||
revalidatePath('/admin/experiments');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error deleting experiment:', error);
|
||||
throw new Error('Failed to delete experiment');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExperimentsByDataset(datasetId: number) {
|
||||
const result = await requireAdmin();
|
||||
if (!result.authenticated || !result.admin) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
try {
|
||||
const experiments = await db
|
||||
.select()
|
||||
.from(experiment)
|
||||
.where(eq(experiment.datasetId, datasetId));
|
||||
return experiments;
|
||||
} catch (error) {
|
||||
console.error('Error fetching experiments:', error);
|
||||
throw new Error('Failed to fetch experiments');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getExperimentById(id: number) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error('Nicht angemeldet');
|
||||
|
||||
|
||||
try { const experimentData = await db
|
||||
.select()
|
||||
.from(experiment)
|
||||
.where(
|
||||
and(
|
||||
eq(experiment.id, id),
|
||||
eq(experiment.published, true)
|
||||
))
|
||||
.limit(1);
|
||||
return experimentData[0] || null;
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error fetching experiment:', error);
|
||||
throw new Error('Failed to fetch experiment');
|
||||
}
|
||||
}
|
||||
|
||||
100
src/app/admin/experiments/[id]/page.tsx
Normal file
100
src/app/admin/experiments/[id]/page.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import Link from 'next/link';
|
||||
import db from '@/lib/db';
|
||||
import { experiment } from '@/lib/model/experiment';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import EditableExperimentHeader from '@/components/EditableExperimentHeader';
|
||||
import DeleteExperimentButton from '@/components/DeleteExperimentButton';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
interface ExperimentPageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ExperimentPage({ params }: ExperimentPageProps) {
|
||||
const result = await requireAdmin();
|
||||
|
||||
if (!result.authenticated) {
|
||||
redirect("/user/sign-in");
|
||||
}
|
||||
|
||||
if (!result.admin) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const experimentId = parseInt(id, 10);
|
||||
|
||||
const experiments = await db
|
||||
.select()
|
||||
.from(experiment)
|
||||
.where(eq(experiment.id, experimentId));
|
||||
|
||||
if (experiments.length === 0) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Link
|
||||
href="/admin/experiments"
|
||||
className="text-blue-500 hover:text-blue-600 mb-6 inline-block"
|
||||
>
|
||||
← Back to Experiments
|
||||
</Link>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">Experiment not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const exp = experiments[0];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Link
|
||||
href="/admin/experiments"
|
||||
className="text-blue-500 hover:text-blue-600 mb-6 inline-block"
|
||||
>
|
||||
← Back to Experiments
|
||||
</Link>
|
||||
|
||||
<EditableExperimentHeader experiment={exp} />
|
||||
|
||||
<div className="flex gap-3 mb-6">
|
||||
<DeleteExperimentButton experimentId={experimentId} experimentName={exp.name} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-2xl font-bold mb-4">Experiment Details</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Experiment ID</p>
|
||||
<p className="text-lg font-semibold">{exp.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Dataset ID</p>
|
||||
<p className="text-lg font-semibold">{exp.datasetId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Created</p>
|
||||
<p className="text-lg font-semibold">{new Date(exp.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Last Updated</p>
|
||||
<p className="text-lg font-semibold">{new Date(exp.updatedAt).toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Annotation Tool</p>
|
||||
<p className="text-lg font-semibold">{exp.annotationTool}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/app/admin/experiments/page.tsx
Normal file
37
src/app/admin/experiments/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import Link from 'next/link';
|
||||
import ExperimentsList from '@/components/ExperimentsList';
|
||||
import CreateExperimentModal from '@/components/CreateExperimentModal';
|
||||
import { requireAdmin } from '@/lib/auth';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function ExperimentsAdminPage() {
|
||||
const result = await requireAdmin();
|
||||
|
||||
if (!result.authenticated) {
|
||||
redirect("/user/sign-in");
|
||||
}
|
||||
|
||||
if (!result.admin) {
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 sm:px-6 lg:px-8 py-8">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-blue-500 hover:text-blue-600 mb-6 inline-block"
|
||||
>
|
||||
← Back to Admin
|
||||
</Link>
|
||||
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-4">Experiments Management</h1>
|
||||
<p className="text-gray-600">Manage all experiments across datasets</p>
|
||||
</div>
|
||||
|
||||
<CreateExperimentModal />
|
||||
|
||||
<ExperimentsList />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import DatasetsList from "@/components/DatasetsList";
|
||||
import CreateDatasetModal from "@/components/CreateDatasetModal";
|
||||
import ExperimentsList from "@/components/ExperimentsList";
|
||||
import { AdminTokenForm } from "@/components/AdminTokenForm";
|
||||
import BuildInfo from "@/components/BuildInfo";
|
||||
import { requireAdmin } from "@/lib/auth";
|
||||
@@ -28,6 +29,12 @@ export default async function AdminPage() {
|
||||
>
|
||||
Manage Users
|
||||
</Link>
|
||||
<Link
|
||||
href="/admin/experiments"
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
Manage Experiments
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,6 +47,12 @@ export default async function AdminPage() {
|
||||
<CreateDatasetModal />
|
||||
<DatasetsList />
|
||||
</div>
|
||||
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold mb-4">Experiments</h2>
|
||||
<p className="text-gray-600 mb-4">Manage experiments globally or <Link href="/admin/experiments" className="text-blue-500 hover:text-blue-600 font-semibold">go to the experiments management page</Link></p>
|
||||
<ExperimentsList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
import Link from 'next/link';
|
||||
import { auth } from '@/lib/auth';
|
||||
|
||||
const PROTOTYPES = [
|
||||
{
|
||||
id: 'single-choice',
|
||||
name: 'Single Choice',
|
||||
description: 'Bewerten Sie jedes Sample auf einer Skala von 1–5.',
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ datasetId: string }>;
|
||||
}
|
||||
|
||||
export default async function PrototypeSelectionPage({ params }: Props) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect('/user/sign-in');
|
||||
|
||||
const { datasetId } = await params;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-8">
|
||||
<Link href="/" className="text-sm text-gray-500 hover:text-gray-700 mb-6 inline-block">
|
||||
← Zurück zur Startseite
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-2">Prototyp auswählen</h1>
|
||||
<p className="text-gray-500 mb-8">
|
||||
Wählen Sie einen Annotationsprototyp aus, um mit der Bewertung zu beginnen.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{PROTOTYPES.map((proto) => (
|
||||
<Link
|
||||
key={proto.id}
|
||||
href={`/annotate/${datasetId}/${proto.id}`}
|
||||
className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm hover:border-blue-300 hover:shadow-md transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-800">{proto.name}</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">{proto.description}</p>
|
||||
</div>
|
||||
<span className="text-gray-400 text-lg">→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,17 +4,19 @@ 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';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ experimentId: string; prototype: string }>;
|
||||
params: Promise<{ experimentId: string}>;
|
||||
}
|
||||
|
||||
export default async function AnnotatePage({ params }: Props) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect('/user/sign-in');
|
||||
|
||||
const { experimentId: experimentIdStr, prototype } = await params;
|
||||
const { experimentId: experimentIdStr } = await params;
|
||||
const experimentId = parseInt(experimentIdStr, 10);
|
||||
const prototype = await getExperimentById(experimentId).then(exp => exp?.annotationTool || null);
|
||||
|
||||
if (isNaN(experimentId)) {
|
||||
return (
|
||||
@@ -27,9 +29,6 @@ export default async function AnnotatePage({ params }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (prototype !== 'single-choice') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const entries = await getAnnotationEntries(experimentId);
|
||||
|
||||
@@ -53,5 +52,23 @@ export default async function AnnotatePage({ params }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
return <SingleChoiceView entries={entries} experimentId={experimentId} />;
|
||||
// Render based on prototype type
|
||||
const renderAnnotationView = () => {
|
||||
switch (prototype) {
|
||||
case 'single-choice':
|
||||
return <SingleChoiceView entries={entries} experimentId={experimentId} />;
|
||||
// Add more prototypes here as needed
|
||||
default:
|
||||
return (
|
||||
<div className="max-w-xl mx-auto py-16 text-center">
|
||||
<p className="text-gray-600">Unbekannter Annotation-Typ: {prototype}</p>
|
||||
<Link href="/" className="text-blue-600 hover:underline mt-4 inline-block">
|
||||
← Startseite
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return renderAnnotationView();
|
||||
}
|
||||
Reference in New Issue
Block a user