diff --git a/backend/aml/base_settings.py b/backend/aml/base_settings.py
index f0087c1dc..5d2d7827d 100644
--- a/backend/aml/base_settings.py
+++ b/backend/aml/base_settings.py
@@ -15,6 +15,9 @@
from corsheaders.defaults import default_headers
import sentry_sdk
+# JWT Settings
+from datetime import timedelta
+
# Workaround for deprecated ugettext_lazy in django-inline-actions
import django
from django.utils.translation import gettext_lazy
@@ -41,6 +44,7 @@
# Application definition
INSTALLED_APPS = [
+ "rest_framework",
"admin_interface",
"modeltranslation", # Must be before django.contrib.admin
"django.contrib.admin",
@@ -207,3 +211,13 @@
MARKUP_SETTINGS = {"markdown": {"safe_mode": False}}
SUBPATH = os.getenv("AML_SUBPATH", "")
+
+# REST framework settings
+REST_FRAMEWORK = {
+ "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",),
+}
+
+SIMPLE_JWT = {
+ "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60),
+ "REFRESH_TOKEN_LIFETIME": timedelta(days=1),
+}
diff --git a/backend/aml/urls.py b/backend/aml/urls.py
index 3ab41b565..3954a9424 100644
--- a/backend/aml/urls.py
+++ b/backend/aml/urls.py
@@ -13,11 +13,17 @@
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
+
from django.contrib import admin
from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static
+from rest_framework_simplejwt.views import (
+ TokenObtainPairView,
+ TokenRefreshView,
+)
+
# Admin strings
admin.site.site_header = "AML Admin"
@@ -26,19 +32,19 @@
# Urls patterns
urlpatterns = [
- path('experiment/', include('experiment.urls')),
- path('question/', include('question.urls')),
- path('participant/', include('participant.urls')),
- path('result/', include('result.urls')),
- path('section/', include('section.urls')),
- path('session/', include('session.urls')),
- path('theme/', include('theme.urls')),
- path('admin_interface/', include('admin_interface.urls')),
- path('admin/', admin.site.urls),
-
+ path("experiment/", include("experiment.urls")),
+ path("question/", include("question.urls")),
+ path("participant/", include("participant.urls")),
+ path("result/", include("result.urls")),
+ path("section/", include("section.urls")),
+ path("session/", include("session.urls")),
+ path("theme/", include("theme.urls")),
+ path("admin_interface/", include("admin_interface.urls")),
+ path("admin/", admin.site.urls),
+ path("api/token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
+ path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
# Sentry debug (uncomment to test Sentry)
# path('sentry-debug/', lambda request: 1 / 0),
-
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
# ^ The static helper function only works in debug mode
# (https://docs.djangoproject.com/en/3.0/howto/static-files/)
@@ -46,11 +52,12 @@
# Prefix all URLS with /server if AML_SUBPATH is set
if settings.SUBPATH:
- urlpatterns = [path('server/', include(urlpatterns))]
+ urlpatterns = [path("server/", include(urlpatterns))]
# Debug toolbar
if settings.DEBUG and not settings.TESTING:
import debug_toolbar
+
urlpatterns += [
path("__debug__/", include(debug_toolbar.urls)),
]
diff --git a/backend/experiment/serializers.py b/backend/experiment/serializers.py
index f2227ca53..7f7e00b19 100644
--- a/backend/experiment/serializers.py
+++ b/backend/experiment/serializers.py
@@ -3,6 +3,7 @@
from django_markup.markup import formatter
from django.utils.translation import activate, get_language
+from rest_framework import serializers
from experiment.actions.consent import Consent
from image.serializers import serialize_image
@@ -10,7 +11,189 @@
from result.models import Result
from session.models import Session
from theme.serializers import serialize_theme
-from .models import Block, Experiment, Phase, SocialMediaConfig
+from .models import Block, Experiment, Phase, SocialMediaConfig, ExperimentTranslatedContent, BlockTranslatedContent
+from section.models import Playlist
+
+
+class ExperimentTranslatedContentSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = ExperimentTranslatedContent
+ fields = ["id", "index", "language", "name", "description", "about_content", "social_media_message"]
+
+
+class BlockTranslatedContentSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = BlockTranslatedContent
+ fields = ["language", "name", "description"]
+
+
+class PlaylistSerializer(serializers.ModelSerializer):
+ id = serializers.IntegerField(required=False)
+
+ class Meta:
+ model = Playlist
+ fields = ["id", "name"]
+
+
+class BlockSerializer(serializers.ModelSerializer):
+ id = serializers.IntegerField(required=False)
+ translated_contents = BlockTranslatedContentSerializer(many=True, required=False, read_only=False)
+ playlists = PlaylistSerializer(many=True, required=False)
+
+ class Meta:
+ model = Block
+ fields = [
+ "id",
+ "index",
+ "slug",
+ "rounds",
+ "bonus_points",
+ "rules",
+ "translated_contents",
+ "playlists", # many to many field
+ ]
+ extra_kwargs = {
+ "slug": {"validators": []},
+ }
+
+ def create(self, validated_data):
+ translated_contents_data = validated_data.pop("translated_contents", [])
+ playlists_data = validated_data.pop("playlists", [])
+ block = Block.objects.create(**validated_data)
+
+ for content_data in translated_contents_data:
+ BlockTranslatedContent.objects.create(block=block, **content_data)
+
+ for playlist_data in playlists_data:
+ playlist = Playlist.objects.get(pk=playlist_data["id"])
+ block.playlists.add(playlist)
+
+ return block
+
+ def update(self, instance, validated_data):
+ translated_contents_data = validated_data.pop("translated_contents", [])
+ playlists_data = validated_data.pop("playlists", [])
+
+ for attr, value in validated_data.items():
+ setattr(instance, attr, value)
+
+ BlockTranslatedContent.objects.filter(block=instance).delete()
+ for content_data in translated_contents_data:
+ BlockTranslatedContent.objects.create(block=instance, **content_data)
+
+ existing_playlist_ids = set()
+ for playlist_data in playlists_data:
+ playlist_id = playlist_data.get("id")
+ if playlist_id:
+ playlist = Playlist.objects.get(pk=playlist_id)
+ instance.playlists.add(playlist)
+ existing_playlist_ids.add(playlist.id)
+
+ # Delete removed playlists
+ instance.playlists.exclude(id__in=existing_playlist_ids).delete()
+
+ instance.save()
+
+ return instance
+
+
+class PhaseSerializer(serializers.ModelSerializer):
+ blocks = BlockSerializer(many=True, required=False)
+
+ class Meta:
+ model = Phase
+ fields = ["id", "index", "dashboard", "randomize", "blocks"]
+
+
+class ExperimentSerializer(serializers.ModelSerializer):
+ translated_content = ExperimentTranslatedContentSerializer(many=True, required=False)
+ phases = PhaseSerializer(many=True, required=False)
+
+ class Meta:
+ model = Experiment
+ fields = ["id", "slug", "active", "translated_content", "phases"]
+
+ def create(self, validated_data):
+ translated_content_data = validated_data.pop("translated_content", [])
+ phases_data = validated_data.pop("phases", [])
+ experiment = Experiment.objects.create(**validated_data)
+
+ for content_data in translated_content_data:
+ ExperimentTranslatedContent.objects.create(experiment=experiment, **content_data)
+
+ for phase_data in phases_data:
+ blocks_data = phase_data.pop("blocks", [])
+ phase = Phase.objects.create(experiment=experiment, **phase_data)
+
+ for block_data in blocks_data:
+ Block.objects.create(phase=phase, **block_data)
+
+ return experiment
+
+ def update(self, instance, validated_data):
+ translated_content_data = validated_data.pop("translated_content", [])
+ phases_data = validated_data.pop("phases", [])
+
+ # Update experiment fields
+ instance.slug = validated_data.get("slug", instance.slug)
+ instance.active = validated_data.get("active", instance.active)
+ instance.save()
+
+ # Update translated content
+ if translated_content_data is not None:
+ existing_content_ids = set()
+
+ # Update or create translated content
+ for content_data in translated_content_data:
+ content_id = content_data.get("id")
+ if content_id:
+ content, _ = ExperimentTranslatedContent.objects.update_or_create(
+ id=content_id, experiment=instance, defaults=content_data
+ )
+ else:
+ content = ExperimentTranslatedContent.objects.create(experiment=instance, **content_data)
+ existing_content_ids.add(content.id)
+
+ # Delete removed content
+ instance.translated_content.exclude(id__in=existing_content_ids).delete()
+
+ # Update phases
+ if phases_data is not None:
+ existing_phase_ids = set()
+
+ # Update or create phases
+ for phase_data in phases_data:
+ blocks_data = phase_data.pop("blocks", [])
+ phase_id = phase_data.get("id")
+
+ if phase_id:
+ phase, _ = Phase.objects.update_or_create(id=phase_id, experiment=instance, defaults=phase_data)
+ else:
+ phase = Phase.objects.create(experiment=instance, **phase_data)
+ existing_phase_ids.add(phase.id)
+
+ # Handle blocks for this phase
+ existing_block_ids = set()
+
+ for block_data in blocks_data:
+ block_id = block_data.get("id")
+
+ if block_id:
+ block_instance = Block.objects.get(pk=block_id)
+ block_serializer = BlockSerializer(block_instance, data=block_data, partial=True)
+ else:
+ block_serializer = BlockSerializer(data=block_data, partial=True)
+ block_serializer.is_valid(raise_exception=True)
+ block = block_serializer.save(phase=phase)
+ existing_block_ids.add(block.id)
+
+ # Delete removed blocks
+ phase.blocks.exclude(id__in=existing_block_ids).delete()
+
+ # Delete removed phases
+ instance.phases.exclude(id__in=existing_phase_ids).delete()
+
+ return instance
def serialize_actions(actions):
diff --git a/backend/experiment/templates/form/experiment-form/.eslintrc.cjs b/backend/experiment/templates/form/experiment-form/.eslintrc.cjs
new file mode 100644
index 000000000..d6c953795
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/.eslintrc.cjs
@@ -0,0 +1,18 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ ],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': [
+ 'warn',
+ { allowConstantExport: true },
+ ],
+ },
+}
diff --git a/backend/experiment/templates/form/experiment-form/.gitignore b/backend/experiment/templates/form/experiment-form/.gitignore
new file mode 100644
index 000000000..a547bf36d
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/backend/experiment/templates/form/experiment-form/README.md b/backend/experiment/templates/form/experiment-form/README.md
new file mode 100644
index 000000000..0d6babedd
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/README.md
@@ -0,0 +1,30 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
+
+- Configure the top-level `parserOptions` property like this:
+
+```js
+export default {
+ // other rules...
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ project: ['./tsconfig.json', './tsconfig.node.json'],
+ tsconfigRootDir: __dirname,
+ },
+}
+```
+
+- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
+- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
+- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
diff --git a/backend/experiment/templates/form/experiment-form/bun.lockb b/backend/experiment/templates/form/experiment-form/bun.lockb
new file mode 100755
index 000000000..a785e61d0
Binary files /dev/null and b/backend/experiment/templates/form/experiment-form/bun.lockb differ
diff --git a/backend/experiment/templates/form/experiment-form/eslint.config.mjs b/backend/experiment/templates/form/experiment-form/eslint.config.mjs
new file mode 100644
index 000000000..2b5389cc6
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/eslint.config.mjs
@@ -0,0 +1,42 @@
+import { fixupConfigRules } from "@eslint/compat";
+import reactRefresh from "eslint-plugin-react-refresh";
+import globals from "globals";
+import tsParser from "@typescript-eslint/parser";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import js from "@eslint/js";
+import { FlatCompat } from "@eslint/eslintrc";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const compat = new FlatCompat({
+ baseDirectory: __dirname,
+ recommendedConfig: js.configs.recommended,
+ allConfig: js.configs.all
+});
+
+export default [{
+ ignores: ["**/dist", "**/.eslintrc.cjs"],
+}, ...fixupConfigRules(compat.extends(
+ "eslint:recommended",
+ "plugin:@typescript-eslint/recommended",
+ "plugin:react-hooks/recommended",
+)), {
+ plugins: {
+ "react-refresh": reactRefresh,
+ },
+
+ languageOptions: {
+ globals: {
+ ...globals.browser,
+ },
+
+ parser: tsParser,
+ },
+
+ rules: {
+ "react-refresh/only-export-components": ["warn", {
+ allowConstantExport: true,
+ }],
+ },
+}];
\ No newline at end of file
diff --git a/backend/experiment/templates/form/experiment-form/index.html b/backend/experiment/templates/form/experiment-form/index.html
new file mode 100644
index 000000000..e4b78eae1
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
diff --git a/backend/experiment/templates/form/experiment-form/package.json b/backend/experiment/templates/form/experiment-form/package.json
new file mode 100644
index 000000000..6e49b48dd
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "experiment-form",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@uiw/react-md-editor": "^4.0.5",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "react-icons": "^5.4.0",
+ "react-markdown": "^9.0.3",
+ "react-router-dom": "^7.0.2",
+ "react-world-flags": "^1.6.0",
+ "remark-gfm": "^4.0.0",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@eslint/compat": "^1.2.4",
+ "@eslint/eslintrc": "^3.2.0",
+ "@eslint/js": "^9.17.0",
+ "@tailwindcss/typography": "^0.5.16",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@typescript-eslint/eslint-plugin": "^7.2.0",
+ "@typescript-eslint/parser": "^7.2.0",
+ "@vitejs/plugin-react-swc": "^3.5.0",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^9.17.0",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "eslint-plugin-react-refresh": "^0.4.6",
+ "globals": "^15.14.0",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.17",
+ "typescript": "^5.2.2",
+ "vite": "^5.2.0"
+ }
+}
diff --git a/backend/experiment/templates/form/experiment-form/postcss.config.js b/backend/experiment/templates/form/experiment-form/postcss.config.js
new file mode 100644
index 000000000..2e7af2b7f
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/backend/experiment/templates/form/experiment-form/public/vite.svg b/backend/experiment/templates/form/experiment-form/public/vite.svg
new file mode 100644
index 000000000..e7b8dfb1b
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/backend/experiment/templates/form/experiment-form/src/App.tsx b/backend/experiment/templates/form/experiment-form/src/App.tsx
new file mode 100644
index 000000000..6426c614f
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/App.tsx
@@ -0,0 +1,92 @@
+import {
+ BrowserRouter as Router,
+ Route,
+ Routes,
+ Link,
+} from "react-router-dom";
+import { AiTwotoneExperiment } from "react-icons/ai";
+import ExperimentsOverview from './components/ExperimentsOverview';
+import ExperimentForm from './components/ExperimentForm';
+import Login from './components/Login';
+import { useState } from 'react';
+import { FiLogOut } from 'react-icons/fi';
+import { Toasts } from './components/Toasts';
+import useBoundStore from "./utils/store";
+
+function App() {
+ const [isCollapsed, setIsCollapsed] = useState(false);
+ const jwt = useBoundStore(state => state.jwt);
+ const setJwt = useBoundStore(state => state.setJwt);
+
+ const handleLogin = (newJwt: string) => {
+ setJwt(newJwt);
+ };
+
+ const handleLogout = () => {
+ setJwt(null);
+ };
+
+ if (!jwt) {
+ return ;
+ } else {
+ try {
+ const payload = JSON.parse(atob(jwt.split('.')[1]));
+ if (payload.exp && Date.now() >= payload.exp * 1000) {
+ handleLogout();
+ return ;
+ }
+ } catch (error) {
+ handleLogout();
+ return ;
+ }
+ }
+
+ return (
+ <>
+
+
+
+
+
+ MUSCLE forms
+
+
+ } />
+ } />
+ } />
+
+
+
+
+
+ >
+ )
+}
+
+export default App
diff --git a/backend/experiment/templates/form/experiment-form/src/assets/react.svg b/backend/experiment/templates/form/experiment-form/src/assets/react.svg
new file mode 100644
index 000000000..6c87de9bb
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/backend/experiment/templates/form/experiment-form/src/components/Accordion.tsx b/backend/experiment/templates/form/experiment-form/src/components/Accordion.tsx
new file mode 100644
index 000000000..b83cf3638
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/Accordion.tsx
@@ -0,0 +1,78 @@
+import React, { useState } from 'react';
+import { FiChevronDown, FiChevronRight } from 'react-icons/fi';
+
+interface AccordionItemProps {
+ title: string;
+ children: React.ReactNode;
+ isOpen?: boolean;
+ onToggle?: () => void;
+}
+
+export const AccordionItem: React.FC = ({
+ title,
+ children,
+ isOpen = false,
+ onToggle,
+}) => {
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault(); // Prevent form submission
+ onToggle?.();
+ };
+
+ return (
+
+
+ {isOpen &&
{children}
}
+
+ );
+};
+
+interface AccordionProps {
+ items: {
+ id: number | string;
+ title: string;
+ content: React.ReactNode;
+ }[];
+ singleOpen?: boolean;
+}
+
+export const Accordion: React.FC = ({ items, singleOpen = false }) => {
+ const [openItems, setOpenItems] = useState>(new Set());
+
+ const toggleItem = (id: number | string) => {
+ setOpenItems((prev) => {
+ const newOpenItems = new Set(prev);
+ if (singleOpen) {
+ newOpenItems.clear();
+ }
+ if (newOpenItems.has(id)) {
+ newOpenItems.delete(id);
+ } else {
+ newOpenItems.add(id);
+ }
+ return newOpenItems;
+ });
+ };
+
+ return (
+
+ {items.map((item) => (
+
toggleItem(item.id)}
+ >
+ {item.content}
+
+ ))}
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/BlockForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/BlockForm.tsx
new file mode 100644
index 000000000..fb984833c
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/BlockForm.tsx
@@ -0,0 +1,128 @@
+import React from 'react';
+import { FormField } from './form/FormField';
+import { Input } from './form/Input';
+import { Select } from './form/Select';
+import { Block, BlockTranslatedContent } from '../types/types';
+import { useBlockRules } from '../hooks/useBlockRules';
+import { FiTrash2 } from 'react-icons/fi';
+import { Button } from './Button';
+import { BlockTranslatedContentForms } from './BlockTranslatedContentForms';
+import { useBlockPlaylists } from '../hooks/useBlockPlaylists';
+
+interface BlockFormProps {
+ block: Block;
+ onChange: (block: Block) => void;
+ onDelete: () => void;
+}
+
+export const BlockForm: React.FC = ({ block, onChange, onDelete }) => {
+ const { rules, loading, error } = useBlockRules();
+ const { playlists, loading: playlistsLoading, error: playlistsError } = useBlockPlaylists();
+
+ const handleChange = (field: keyof Block, value: string | number | BlockTranslatedContent[]) => {
+ onChange({ ...block, [field]: value });
+ };
+
+ return (
+
+
+
Block Settings
+ }
+ onClick={onDelete}
+ >
+ Delete Block
+
+
+
+
+ handleChange('slug', e.target.value)}
+ required
+ />
+
+
+
+ handleChange('index', parseInt(e.target.value))}
+ required
+ />
+
+
+
+
+
+ handleChange('rounds', parseInt(e.target.value))}
+ required
+ />
+
+
+
+ handleChange('bonus_points', parseInt(e.target.value))}
+ />
+
+
+
+
+
+
+ {error && {error}
}
+ {loading && Loading rules...
}
+
+
+
+
+ {playlistsError && {playlistsError}
}
+ {playlistsLoading && Loading playlists...
}
+
+
+
+
handleChange('translated_contents', contents)} // Changed from translated_content
+ />
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForm.tsx
new file mode 100644
index 000000000..5a7d8c51a
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForm.tsx
@@ -0,0 +1,53 @@
+import { ISO_LANGUAGES } from "../constants";
+import { BlockTranslatedContent } from "../types/types";
+import { FormField } from "./form/FormField";
+import { Input } from "./form/Input";
+import { Select } from "./form/Select";
+import { MarkdownEditor } from "./form/MarkdownEditor";
+
+interface BlockTranslatedContentFormProps {
+ content: BlockTranslatedContent;
+ onChange: (content: BlockTranslatedContent) => void;
+}
+
+export function BlockTranslatedContentForm({ content, onChange }: BlockTranslatedContentFormProps) {
+ return (
+
+
+
+
+
+
+
+
+ onChange({ ...content, name: e.target.value })}
+ required
+ />
+
+
+
+
+ onChange({ ...content, description: value })}
+ rows={3}
+ field="Description"
+ />
+
+
+
+ );
+}
diff --git a/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForms.tsx b/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForms.tsx
new file mode 100644
index 000000000..e78a822b1
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/BlockTranslatedContentForms.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { Tabs } from './Tabs';
+import { FiPlus, FiTrash } from 'react-icons/fi';
+import { BlockTranslatedContent } from '../types/types';
+import { Flag } from './Flag';
+import { BlockTranslatedContentForm } from './BlockTranslatedContentForm';
+
+interface BlockTranslatedContentFormsProps {
+ contents: BlockTranslatedContent[];
+ onChange: (contents: BlockTranslatedContent[]) => void;
+}
+
+const defaultContent: BlockTranslatedContent = {
+ index: 0,
+ language: '',
+ name: '',
+ description: '',
+};
+
+export const BlockTranslatedContentForms: React.FC = ({ contents, onChange }) => {
+ const [activeTabIndex, setActiveTabIndex] = React.useState(0);
+
+ const handleAdd = () => {
+ const newContent = {
+ ...defaultContent,
+ index: contents.length,
+ };
+ const updatedContents = [...contents, newContent].map((content, index) => ({ ...content, index }));
+ onChange(updatedContents);
+ setActiveTabIndex(updatedContents.length - 1);
+ };
+
+ const handleRemove = (index: number) => {
+ if (confirm('Are you sure you want to remove this translation?')) {
+ onChange(contents.filter((_, i) => i !== index));
+ setActiveTabIndex(Math.min(index, contents.length - 2));
+ }
+ };
+
+ const handleChange = (index: number, newContent: BlockTranslatedContent) => {
+ const updatedContents = contents.map((content, i) => {
+ if (i === index) {
+ return { ...content, ...newContent };
+ }
+ return content;
+ });
+ onChange(updatedContents);
+ };
+
+ const getTabLabel = (content: BlockTranslatedContent, index: number) => {
+ if (content.language) {
+ return ;
+ }
+ return `Translation ${index + 1}`;
+ };
+
+ const handleTabChange = (tabIndex: string | number) => {
+ if (tabIndex === 'new') {
+ handleAdd();
+ } else {
+ setActiveTabIndex(tabIndex as number);
+ }
+ };
+
+ return (
+
+
+ Translated Content
+
+
+
({
+ id: index,
+ label: getTabLabel(content, index),
+ })),
+ {
+ id: 'new',
+ label: (
+
+
+ New Translation
+
+ ),
+ }
+ ]}
+ wrap={true}
+ activeTab={activeTabIndex}
+ onTabChange={handleTabChange}
+ actions={[
+ {
+ icon: ,
+ title: 'Remove translation',
+ onClick: (tabId) => handleRemove(tabId as number),
+ },
+ ]}
+ />
+
+ {contents.length > 0 && !!contents[activeTabIndex] && (
+ handleChange(activeTabIndex, content)}
+ />
+ )}
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/Button.tsx b/backend/experiment/templates/form/experiment-form/src/components/Button.tsx
new file mode 100644
index 000000000..c0df998ff
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/Button.tsx
@@ -0,0 +1,75 @@
+import React, { ReactNode } from 'react';
+import { Link } from 'react-router-dom';
+
+interface ButtonProps {
+ children: ReactNode;
+ onClick?: (e?: React.MouseEvent) => void;
+ type?: 'button' | 'submit' | 'reset';
+ variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning';
+ size?: 'sm' | 'md' | 'lg';
+ icon?: ReactNode;
+ to?: string;
+ disabled?: boolean;
+ className?: string;
+}
+
+const variantStyles = {
+ primary: 'bg-indigo-600 hover:bg-indigo-700 text-white',
+ secondary: 'bg-gray-100 hover:bg-gray-200 text-gray-800',
+ danger: 'bg-red-500 hover:bg-red-600 text-white',
+ success: 'bg-emerald-500 hover:bg-emerald-600 text-white',
+ warning: 'bg-amber-500 hover:bg-amber-600 text-white',
+};
+
+const sizeStyles = {
+ sm: 'px-3 py-1.5 text-sm',
+ md: 'px-4 py-2',
+ lg: 'px-6 py-3 text-lg',
+};
+
+export const Button: React.FC = ({
+ children,
+ onClick,
+ type = 'button',
+ variant = 'primary',
+ size = 'md',
+ icon,
+ to,
+ disabled = false,
+ className = '',
+}) => {
+ const baseStyle = 'inline-flex items-center justify-center rounded-md font-medium transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed';
+ const styles = `${baseStyle} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`;
+
+ if (onClick) {
+ return (
+
+ );
+ } else if (to) {
+ return (
+
+ {icon && {icon}}
+ {children}
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx
new file mode 100644
index 000000000..ee66d4f09
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/ExperimentForm.tsx
@@ -0,0 +1,265 @@
+import React, { useEffect, useState } from 'react';
+import { createExperimentEntityUrl } from '../config';
+import { useNavigate, useParams, Routes, Route, useLocation } from 'react-router-dom';
+import Page from './Page';
+import { TranslatedContentForms } from './TranslatedContentForms';
+import { FiSave, FiArrowLeft, FiGlobe, FiLayers, FiLoader } from 'react-icons/fi';
+import { Button } from './Button';
+import { Tabs } from './Tabs';
+import { FormField } from './form/FormField';
+import { Input } from './form/Input';
+import { Experiment, TranslatedContent } from '../types/types';
+import { PhasesForm } from './PhasesForm';
+import useFetch from '../hooks/useFetch';
+import { useMutation } from '../hooks/useMutation';
+import useBoundStore from '../utils/store';
+
+interface ExperimentFormProps {
+}
+
+interface UnsavedChanges {
+ main: boolean;
+ translatedContent: boolean;
+ phases: boolean;
+}
+
+const ExperimentForm: React.FC = () => {
+ const { id: experimentId } = useParams<{ id: string }>();
+ const url = createExperimentEntityUrl('experiments', experimentId);
+ const [experimentResource, error, loading] = useFetch(url, 'GET', null);
+ const [saveExperiment, { loading: saveLoading }] = useMutation(
+ url,
+ experimentId ? 'PUT' : 'POST'
+ );
+
+ const experiment = useBoundStore(state => state.experiment);
+ const setExperiment = useBoundStore(state => state.setExperiment);
+ const patchExperiment = useBoundStore(state => state.patchExperiment);
+ const addToast = useBoundStore(state => state.addToast);
+
+ const [success, setSuccess] = useState(false);
+ const [activeTab, setActiveTab] = useState<'translatedContent' | 'phases'>('translatedContent');
+ const [unsavedChanges, setUnsavedChanges] = useState({
+ main: false,
+ translatedContent: false,
+ phases: false,
+ });
+
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const hasUnsavedChanges = unsavedChanges.main || unsavedChanges.translatedContent || unsavedChanges.phases;
+
+ useEffect(() => {
+ if (experimentResource) {
+ setExperiment(experimentResource);
+ navigate(`/experiments/${experimentId}/phases`);
+ } else {
+ setExperiment({
+ slug: '',
+ active: true,
+ translated_content: [],
+ phases: [],
+ });
+ }
+ }, [experimentResource]);
+
+ useEffect(() => {
+ // Set active tab based on URL
+ if (location.pathname.includes('/translated-content')) {
+ setActiveTab('translatedContent');
+ } else if (location.pathname.includes('/phases')) {
+ setActiveTab('phases');
+ }
+ }, [location]);
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if ((event.ctrlKey || event.metaKey) && event.key === 's') {
+ event.preventDefault();
+ handleSubmit(event as unknown as React.FormEvent);
+ }
+ };
+
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [experiment]);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const { name, type, checked, value } = e.target;
+ patchExperiment({
+ [name]: type === 'checkbox' ? checked : value
+ });
+ setUnsavedChanges(prev => ({ ...prev, main: true }));
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSuccess(false);
+
+ try {
+ const savedExperiment = await saveExperiment(experiment);
+ setSuccess(true);
+ setExperiment(savedExperiment);
+ addToast({
+ message: "Experiment saved successfully!",
+ duration: 3000,
+ level: "success"
+ });
+ } catch (err) {
+ addToast({
+ message: "Failed to save experiment. Please try again.",
+ duration: 5000,
+ level: "error"
+ });
+ console.error("Error submitting form:", err);
+ }
+ };
+
+ const handleTranslatedContentChange = (newContents: TranslatedContent[]) => {
+ patchExperiment({ translated_content: newContents });
+ setUnsavedChanges(prev => ({ ...prev, translatedContent: true }));
+ };
+
+ const getTabLabel = (label: string, hasChanges: boolean) => {
+ return hasChanges ? `${label} *` : label;
+ };
+
+ const handleTabChange = (tabId: string) => {
+ const tab = tabId as 'translatedContent' | 'phases';
+ setActiveTab(tab);
+ if (tab === 'translatedContent') {
+ navigate(`/experiments/${experimentId}/translated-content`);
+ } else {
+ navigate(`/experiments/${experimentId}/phases`);
+ }
+ };
+
+ useEffect(() => {
+ if (success) {
+ setUnsavedChanges({ main: false, translatedContent: false, phases: false });
+ }
+ }, [success]);
+
+ if (loading && !success && !error && experimentId) {
+ return Loading...
;
+ }
+
+ if (loading) {
+ return Loading...
;
+ }
+
+ if (!experiment) {
+ return Experiment not found
;
+ }
+
+ return (
+
+ }
+ className="mb-4"
+ onClick={(e: React.MouseEvent) => {
+ if (hasUnsavedChanges) {
+ const confirmLeave = confirm('You have unsaved changes. Are you sure you want to leave?');
+ if (!confirmLeave) {
+ e.preventDefault();
+ return;
+ }
+ }
+ navigate('/experiments');
+ }}
+ >
+ {`Back to Experiments${hasUnsavedChanges ? ' *' : ''}`}
+
+
+
+
+ );
+};
+
+export default ExperimentForm;
diff --git a/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx b/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx
new file mode 100644
index 000000000..1f36423ef
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/ExperimentsOverview.tsx
@@ -0,0 +1,159 @@
+import { createExperimentEntityUrl } from "../config";
+import useFetch from "../hooks/useFetch";
+import { Button } from "./Button";
+import Page from "./Page";
+import { FiPlus, FiEdit2, FiTrash2 } from "react-icons/fi";
+import { Experiment, Phase, TranslatedContent } from "../types/types";
+import React from "react";
+import { Flag } from "./Flag";
+import { Link } from "react-router-dom";
+import useBoundStore from "../utils/store";
+
+const url = createExperimentEntityUrl('experiments');
+
+const PhasesPills: React.FC<{ phases: Phase[] }> = ({ phases }) => {
+ return (
+
+ {phases.sort((a, b) => a.index - b.index).map((phase, idx) => (
+
+ {idx > 0 && }
+ b.slug).join(', ') : 'No blocks'}`}
+ >
+ {phase.blocks.length}
+
+
+ ))}
+
+ );
+};
+
+const LanguagePills: React.FC<{ translations: TranslatedContent[] }> = ({ translations }) => {
+ const languages = [...new Set(translations.map(t => t.language))];
+ return (
+
+ {languages.map(lang => (
+
+ ))}
+
+ );
+};
+
+const ExperimentsOverview = () => {
+ const jwt = useBoundStore(state => state.jwt);
+ const [experiments, error, loading, fetchData] = useFetch(url);
+ const handleDelete = async (id: number) => {
+ if (window.confirm('Are you sure you want to delete this experiment?')) {
+ try {
+ await fetch(createExperimentEntityUrl('experiments', id.toString()), {
+ method: 'DELETE',
+ });
+ // Refresh the page or update the list
+ fetchData();
+ } catch (error) {
+ console.error('Error deleting experiment:', error);
+ }
+ }
+ };
+
+ if (error) return Error: {error}
;
+ const loadingClass = loading ? 'opacity-50 pointer-events-none' : '';
+
+ async function onCreateExperiment() {
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${jwt}`,
+ },
+ body: JSON.stringify({ slug: 'new-experiment', active: false }),
+ });
+ if (response.ok) {
+ fetchData();
+ // navigate to the new experiment
+ const id = (await response.json()).id;
+ window.location.href = `/experiments/${id}`;
+ } else {
+ console.error('Failed to create experiment:', response);
+ }
+ }
+
+
+ return (
+
+
+
+ }
+ >
+ New Experiment
+
+
+
+
+
+
+
+ ID |
+ Slug |
+ Status |
+ Phases |
+ Languages |
+ Actions |
+
+
+
+ {experiments?.map((experiment) => (
+
+
+
+ {experiment.id}
+
+ |
+ {experiment.slug} |
+
+
+ {experiment.active ? 'Active' : 'Inactive'}
+
+ |
+
+
+ |
+
+
+ |
+
+ }
+ className="mr-2"
+ >
+ Edit
+
+
+ |
+
+ ))}
+
+
+
+
+
+ );
+};
+
+export default ExperimentsOverview;
diff --git a/backend/experiment/templates/form/experiment-form/src/components/Flag.tsx b/backend/experiment/templates/form/experiment-form/src/components/Flag.tsx
new file mode 100644
index 000000000..4e9ce0008
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/Flag.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import WorldFlag from 'react-world-flags';
+import { getCountryCode, getLanguageName } from '../utils/languageUtils';
+
+interface FlagProps {
+ languageCode: string;
+ className?: string;
+ showLabel?: boolean;
+}
+
+export const Flag: React.FC = ({ languageCode, className = '', showLabel = false }) => {
+ const countryCode = getCountryCode(languageCode);
+ const languageName = getLanguageName(languageCode);
+
+ return (
+
+ {countryCode}}
+ />
+ {showLabel && {languageName}}
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/Login.tsx b/backend/experiment/templates/form/experiment-form/src/components/Login.tsx
new file mode 100644
index 000000000..fbecfc329
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/Login.tsx
@@ -0,0 +1,74 @@
+import { useState } from 'react';
+import { TOKEN_URL } from '../config';
+
+
+interface LoginProps {
+ onLogin: (jwt: string) => void;
+}
+
+const Login = ({ onLogin }: LoginProps) => {
+ const [username, setUsername] = useState('');
+ const [password, setPassword] = useState('');
+ const [error, setError] = useState('');
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ try {
+ const response = await fetch(TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ username, password }),
+ });
+
+ if (!response.ok) {
+ throw new Error('Invalid credentials');
+ }
+
+ const data = await response.json();
+ onLogin(data.access);
+ } catch (err) {
+ setError('Login failed. Please check your credentials.');
+ }
+ };
+
+ return (
+
+
+
Login
+ {error &&
{error}
}
+
+
+
+ );
+};
+
+export default Login;
diff --git a/backend/experiment/templates/form/experiment-form/src/components/Page.tsx b/backend/experiment/templates/form/experiment-form/src/components/Page.tsx
new file mode 100644
index 000000000..627a7435c
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/Page.tsx
@@ -0,0 +1,30 @@
+import { ReactNode } from 'react';
+import { Link } from 'react-router-dom';
+
+interface PageProps {
+ title: string;
+ children: ReactNode;
+ backTo?: string;
+ backText?: string;
+}
+
+const Page = ({ title, children, backTo, backText = 'Back' }: PageProps) => {
+ return (
+
+
+ {backTo && (
+
+ < {backText}
+
+ )}
+
{title}
+
+ {children}
+
+ );
+};
+
+export default Page;
diff --git a/backend/experiment/templates/form/experiment-form/src/components/PhaseForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/PhaseForm.tsx
new file mode 100644
index 000000000..9315759e6
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/PhaseForm.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { FiTrash2 } from 'react-icons/fi';
+import { Phase } from '../types/types';
+import { FormField } from './form/FormField';
+import { Input } from './form/Input';
+import { Button } from './Button';
+import { useParams } from 'react-router-dom';
+import useBoundStore from '../utils/store';
+
+interface PhaseFormProps {
+ onChange: (phase: Phase) => void;
+ onDelete: () => void;
+}
+
+export const PhaseForm: React.FC = ({ onChange, onDelete }) => {
+
+ const experiment = useBoundStore(state => state.experiment);
+
+ const { phaseIndex } = useParams<{ id: string }>();
+
+ const phase = experiment?.phases[phaseIndex];
+
+ const handleChange = (field: keyof Phase, value: any) => {
+ onChange({ ...phase, [field]: value });
+ };
+
+ if (!phase) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/PhasesForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/PhasesForm.tsx
new file mode 100644
index 000000000..abe59401e
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/PhasesForm.tsx
@@ -0,0 +1,315 @@
+import React, { useState, useEffect } from 'react';
+import { Phase, Selection } from '../types/types';
+import { PhaseForm } from './PhaseForm';
+import { Timeline } from './Timeline';
+import { BlockForm } from './BlockForm';
+import useBoundStore from '../utils/store';
+import { useNavigate, useParams, useLocation, Routes, Route } from 'react-router-dom';
+import { Button } from './Button';
+import { FiPlus } from 'react-icons/fi';
+
+const defaultPhase: Phase = {
+ index: 0,
+ blocks: [],
+ dashboard: false,
+ randomize: false,
+};
+
+interface PhasesFormProps {
+ phases: Phase[];
+ onChange: (phases: Phase[]) => void;
+}
+
+export const PhasesForm: React.FC = ({ phases, onChange }) => {
+ const experiment = useBoundStore(state => state.experiment);
+ const [timelineSelection, setTimelineSelection] = useState(null);
+ const navigate = useNavigate();
+ const params = useParams();
+ const { id: experimentId } = params;
+ const location = useLocation();
+
+
+ useEffect(() => {
+ // Parse URL to set initial selection
+ const match = location.pathname.match(/\/phases\/(\d+)(?:\/blocks\/(\d+))?/);
+
+ if (match) {
+ const [_, phaseIndex, blockIndex] = match;
+ if (blockIndex !== undefined) {
+ // Set block selection
+ setTimelineSelection({
+ type: 'block',
+ phaseIndex: parseInt(phaseIndex),
+ blockIndex: parseInt(blockIndex)
+ });
+ } else {
+ // Set phase selection
+ setTimelineSelection({
+ type: 'phase',
+ phaseIndex: parseInt(phaseIndex)
+ });
+ }
+ } else if (phases.length > 0) {
+
+
+ // Default to first phase if no selection in URL
+ navigate(`/experiments/${experimentId}/phases/0`);
+ }
+ }, [location.pathname, phases]);
+
+ const handleTimelineSelect = (selection: Selection) => {
+
+ setTimelineSelection(selection);
+ const basePath = `/experiments/${experimentId}/phases`;
+ if (selection.type === 'block') {
+ navigate(`${basePath}/${selection.phaseIndex}/blocks/${selection.blockIndex}`);
+ } else {
+ navigate(`${basePath}/${selection.phaseIndex}`);
+ }
+ };
+
+ const addPhaseOrBlock = (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => {
+ if (type === 'phase') {
+ const newPhase: Phase = {
+ ...defaultPhase,
+ index: phaseIndex,
+ blocks: [],
+ };
+
+ let updatedPhases = [...phases];
+
+ updatedPhases
+ .splice(phaseIndex, 0, newPhase)
+
+ updatedPhases = updatedPhases
+ .map((phase, i) => ({ ...phase, index: i }));
+
+ onChange(updatedPhases);
+ setTimelineSelection({ type: 'phase', phaseIndex: phaseIndex });
+ } else {
+
+ const position = blockIndex !== undefined ? blockIndex : phases[phaseIndex].blocks.length;
+
+ const phase = phases[phaseIndex];
+ if (!phase) return;
+ if (!experiment) return;
+
+ const experimentSlug = experiment.slug;
+ const blockSlugArray: [string, number, number] = [experimentSlug, phaseIndex, position];
+ let blockSlug = blockSlugArray.join('-');
+
+ // Check if block slug already exists in the experiment
+ while (phase.blocks.some(b => b.slug === blockSlug)) {
+ blockSlugArray[1] += 1;
+ blockSlug = blockSlugArray.join('-');
+ }
+
+ const newBlock = {
+ index: position,
+ slug: blockSlug,
+ rounds: 10,
+ bonus_points: 0,
+ rules: '',
+ translated_contents: [],
+ playlists: [],
+ };
+
+ // use splice to insert new block at position
+ let newBlocks = [...phase.blocks];
+
+ newBlocks
+ .splice(position, 0, newBlock)
+
+ newBlocks = newBlocks
+ .map((block, i) => ({ ...block, index: i }))
+
+ const updatedPhase = {
+ ...phase,
+ blocks: [...newBlocks],
+ };
+
+ onChange(phases.map((p, i) => i === phaseIndex ? updatedPhase : p));
+ setTimelineSelection({ type: 'block', phaseIndex, blockIndex: position });
+ }
+ };
+
+ const handleAdd = (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => {
+ addPhaseOrBlock(type, phaseIndex, blockIndex);
+
+ // then navigate to the new block (if the index(es) are different than the current selection)
+ if (type === 'phase') {
+ navigate(`/experiments/${experimentId}/phases/${phaseIndex}`);
+ } else {
+ navigate(`/experiments/${experimentId}/phases/${phaseIndex}/blocks/${blockIndex}`);
+ }
+ };
+
+ const deletePhaseOrBlock = (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => {
+ if (type === 'phase') {
+ if (!confirm('Are you sure you want to delete this phase?')) return;
+
+ const updatedPhases = phases.filter((_, i) => i !== phaseIndex)
+ .map((phase, i) => ({ ...phase, index: i }));
+
+ setTimelineSelection(null);
+ onChange(updatedPhases);
+
+ return updatedPhases;
+
+ } else if (blockIndex !== undefined) {
+ if (!confirm('Are you sure you want to delete this block?')) return;
+
+ const phase = phases[phaseIndex];
+ const updatedBlocks = phase.blocks
+ .filter((_, i) => i !== blockIndex)
+ .map((block, i) => ({ ...block, index: i }));
+
+ const updatedPhase = { ...phase, blocks: updatedBlocks };
+ const updatedPhases = phases.map((p, i) => i === phaseIndex ? updatedPhase : p);
+
+ setTimelineSelection(null);
+ onChange(updatedPhases);
+
+ return updatedPhases;
+ }
+ };
+
+ const handleDelete = (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => {
+ const updatedPhases = deletePhaseOrBlock(type, phaseIndex, blockIndex);
+
+ if (!updatedPhases?.length) {
+ navigate(`/experiments/${experimentId}/phases`);
+ return;
+ }
+
+ // navigate to the most appropriate location after deletion
+ if (type === 'phase') {
+ if (phaseIndex === 0) {
+ navigate(`/experiments/${experimentId}/phases/0`);
+ } else {
+ navigate(`/experiments/${experimentId}/phases/${phaseIndex - 1}`);
+ }
+ } else {
+
+ if (blockIndex === undefined) {
+ navigate(`/experiments/${experimentId}/phases/${phaseIndex}`);
+ return;
+ }
+
+ if (blockIndex === 0) {
+ navigate(`/experiments/${experimentId}/phases/${phaseIndex}`);
+ } else {
+ navigate(`/experiments/${experimentId}/phases/${phaseIndex}/blocks/${blockIndex - 1}`);
+ }
+ }
+ };
+
+ // Get selected item details
+ const getSelected = () => {
+
+ if (!timelineSelection) return null;
+
+ if (timelineSelection.type === 'phase') {
+
+ if (timelineSelection.phaseIndex >= phases.length) {
+ console.warn('Phase index out of range (type: phase)');
+ return null;
+ }
+
+ const phase = phases[timelineSelection.phaseIndex];
+ return { type: 'phase', item: phase };
+ }
+
+ if (timelineSelection.type === 'block') {
+
+ if (timelineSelection.phaseIndex >= phases.length) {
+ console.warn('Phase index out of range (type: block)');
+ return null;
+ }
+
+ const phase = phases[timelineSelection.phaseIndex];
+
+ // Check if block index is defined as a number and greater than or equal to 0
+ if (typeof timelineSelection.blockIndex !== 'number' || timelineSelection.blockIndex < 0) {
+ console.warn('Block index not found');
+ return null;
+ }
+
+ if (timelineSelection.blockIndex >= phase.blocks.length) {
+ console.warn('Block index out of range');
+ return null;
+ }
+
+ const block = phase.blocks[timelineSelection.blockIndex];
+
+ return { type: 'block', item: block, phase };
+ }
+
+ return null;
+ };
+
+ const selected = getSelected();
+
+ return (
+
+
+
+ {/* Add new phase button if there are no phases */}
+ {!phases.length && (
+
}
+ onClick={() => handleAdd('phase', phases.length)}
+ >
+ Add Phase
+
+ )}
+
+
+
+
+ {
+ const updatedPhase = {
+ ...selected.phase,
+ blocks: selected.phase.blocks.map((b, i) =>
+ i === timelineSelection.blockIndex ? updatedBlock : b
+ ),
+ };
+ onChange(phases.map((p, i) =>
+ i === timelineSelection.phaseIndex ? updatedPhase : p
+ ));
+ }}
+ onDelete={() => handleDelete('block', timelineSelection.phaseIndex, timelineSelection.blockIndex)}
+ />
+
+
+ )
+ } />
+
+
+
{
+ onChange(phases.map((p, i) =>
+ i === timelineSelection.phaseIndex ? updatedPhase : p
+ ));
+ }}
+ onDelete={() => handleDelete('phase', timelineSelection.phaseIndex)}
+ />
+
+
+ )
+ } />
+
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/Tabs.tsx b/backend/experiment/templates/form/experiment-form/src/components/Tabs.tsx
new file mode 100644
index 000000000..d58c95660
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/Tabs.tsx
@@ -0,0 +1,109 @@
+import React, { useState } from 'react';
+
+interface TabAction {
+ icon: React.ReactNode;
+ title: string;
+ onClick: (tabId: string | number) => void;
+}
+
+interface Tab {
+ id: string | number;
+ label: string;
+ icon?: React.ReactNode; // Add icon support
+}
+
+interface TabsProps {
+ tabs: Tab[];
+ activeTab: string | number;
+ onTabChange: (tabId: string | number) => void;
+ actions?: TabAction[];
+ onReorder?: (startIndex: number, endIndex: number) => void;
+ draggable?: boolean;
+ wrap?: boolean;
+}
+
+export const Tabs: React.FC = ({
+ tabs,
+ activeTab,
+ onTabChange,
+ actions = [],
+ onReorder,
+ draggable = false,
+ wrap = false,
+}) => {
+ const [draggedIndex, setDraggedIndex] = useState(null);
+ const [dragOverIndex, setDragOverIndex] = useState(null);
+
+ const handleDragStart = (e: React.DragEvent, index: number) => {
+ if (tabs[index].id === 'new') return;
+ setDraggedIndex(index);
+ e.dataTransfer.effectAllowed = 'move';
+ };
+
+ const handleDragOver = (e: React.DragEvent, index: number) => {
+ e.preventDefault();
+ if (tabs[index].id === 'new') return;
+ setDragOverIndex(index);
+ };
+
+ const handleDragEnd = () => {
+ if (draggedIndex !== null && dragOverIndex !== null && onReorder) {
+ onReorder(draggedIndex, dragOverIndex);
+ }
+ setDraggedIndex(null);
+ setDragOverIndex(null);
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/Timeline.tsx b/backend/experiment/templates/form/experiment-form/src/components/Timeline.tsx
new file mode 100644
index 000000000..1655c281e
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/Timeline.tsx
@@ -0,0 +1,149 @@
+import React from 'react';
+import { FiPlus, FiCircle, FiBox } from 'react-icons/fi';
+import { Phase, Selection } from '../types/types';
+import { useNavigate, useParams } from 'react-router-dom';
+
+interface TimelineProps {
+ phases: Array;
+ selectedItem: Selection | null;
+ onSelect: (selection: Selection) => void;
+ onAdd: (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => void;
+}
+
+export const Timeline: React.FC = ({
+ phases,
+ selectedItem,
+ onSelect,
+ onAdd,
+}) => {
+ const { id: experimentId } = useParams();
+ const navigate = useNavigate();
+
+ const handleSelect = (selection: Selection) => {
+ const basePath = `/experiments/${experimentId}/phases`;
+ if (selection.type === 'block') {
+ navigate(`${basePath}/${selection.phaseIndex}/blocks/${selection.blockIndex}`);
+ } else {
+ navigate(`${basePath}/${selection.phaseIndex}`);
+ }
+ onSelect(selection);
+ };
+
+ const isSelected = (type: 'phase' | 'block', phaseIndex: number, blockIndex?: number) => {
+ return selectedItem?.type === type &&
+ selectedItem.phaseIndex === phaseIndex &&
+ selectedItem.blockIndex === blockIndex;
+ };
+
+ return (
+
+
+ {phases.map((phase, phaseIndex) => (
+
+ {/* Add button before phase */}
+ {phaseIndex === 0 && (
+
+
onAdd('phase', 0)}
+ type="phase"
+ />
+
+ )}
+
+
+
+ {/* Phase node */}
+
+
+ {/* Blocks for this phase */}
+ {phase.blocks.map((_block, blockIndex) => (
+
+
+ {/* Add button before block */}
+
onAdd('block', phaseIndex, blockIndex)}
+ type="block"
+ />
+
+
+
+
+ ))}
+
+ {/* Add button for new block if no or the last block is selected */}
+
+ {/* Add button after block */}
+
onAdd('block', phaseIndex, phase.blocks.length)}
+ type="block"
+ />
+
+
+ {/* vertical line between the phases equal to the gap size of gap-2 */}
+ {phaseIndex < phases.length - 1 && (
+
+ )}
+
+ {/* Add button after phase */}
+
+
onAdd('phase', phaseIndex + 1)}
+ type="phase"
+ />
+
+
+
+ ))}
+
+
+ );
+};
+
+const AddButton: React.FC<{
+ onClick: () => void;
+ type: 'phase' | 'block';
+}> = ({ onClick, type }) => (
+
+);
diff --git a/backend/experiment/templates/form/experiment-form/src/components/Toast.tsx b/backend/experiment/templates/form/experiment-form/src/components/Toast.tsx
new file mode 100644
index 000000000..3435b0f8a
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/Toast.tsx
@@ -0,0 +1,20 @@
+import { Toast as ToastType } from '../utils/store';
+
+interface ToastProps {
+ toast: ToastType;
+}
+
+export const Toast: React.FC = ({ toast }) => {
+ const bgColorClass = {
+ info: 'bg-gray-800',
+ success: 'bg-green-600',
+ warning: 'bg-yellow-600',
+ error: 'bg-red-600'
+ }[toast.level];
+
+ return (
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/Toasts.tsx b/backend/experiment/templates/form/experiment-form/src/components/Toasts.tsx
new file mode 100644
index 000000000..f5899c13a
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/Toasts.tsx
@@ -0,0 +1,14 @@
+import { useBoundStore } from '../utils/store';
+import { Toast } from './Toast';
+
+export const Toasts: React.FC = () => {
+ const toasts = useBoundStore((state) => state.toasts);
+
+ return (
+
+ {toasts.map((toast, index) => (
+
+ ))}
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForm.tsx b/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForm.tsx
new file mode 100644
index 000000000..4592c6d01
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForm.tsx
@@ -0,0 +1,72 @@
+import { ISO_LANGUAGES } from "../constants";
+import { TranslatedContent } from "../types/types";
+import { FormField } from "./form/FormField";
+import { Input } from "./form/Input";
+import { Select } from "./form/Select";
+import { MarkdownEditor } from "./form/MarkdownEditor";
+
+interface TranslatedContentProps {
+ content: TranslatedContent;
+ onChange: (content: TranslatedContent) => void;
+}
+
+export function TranslatedContentForm({ content, onChange }: TranslatedContentProps) {
+
+ return (
+
+
+
+
+
+
+
+
+
+ onChange({ ...content, name: e.target.value })}
+ required
+ />
+
+
+
+
+ onChange({ ...content, description: value })}
+ rows={3}
+ field="Description"
+ />
+
+
+
+ onChange({ ...content, about_content: value })}
+ rows={8}
+ field="About Content"
+ />
+
+
+
+ onChange({ ...content, social_media_message: e.target.value })}
+ />
+
+
+
+ );
+}
diff --git a/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForms.tsx b/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForms.tsx
new file mode 100644
index 000000000..1f344b9e5
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/TranslatedContentForms.tsx
@@ -0,0 +1,127 @@
+import React, { useEffect } from 'react';
+import { Tabs } from './Tabs';
+import { FiPlus, FiTrash } from 'react-icons/fi';
+import { TranslatedContent } from '../types/types';
+import { Flag } from './Flag';
+import { useNavigate, useParams } from 'react-router-dom';
+import { TranslatedContentForm } from './TranslatedContentForm';
+
+interface TranslatedContentFormsProps {
+ contents: TranslatedContent[];
+ onChange: (contents: TranslatedContent[]) => void;
+}
+
+const defaultContent: TranslatedContent = {
+ index: 0,
+ language: '',
+ name: '',
+ description: '',
+ about_content: '',
+ social_media_message: 'I scored {points} points in {experiment_name}!',
+};
+
+export const TranslatedContentForms: React.FC = ({ contents, onChange }) => {
+ const navigate = useNavigate();
+ const { id: experimentId, language } = useParams();
+
+
+ // Find index of current language in contents
+ const languageInContents = contents.find(content => content.language === language);
+ const potentialContentIndex = parseInt(language ?? '0', 10);
+ const activeTabIndex = languageInContents ? contents.indexOf(languageInContents) : potentialContentIndex;
+
+ useEffect(() => {
+ // If we have contents but no language in URL, redirect to first language
+ if (contents.length > 0 && !language) {
+ const firstContent = contents[0];
+ navigate(`/experiments/${experimentId}/translated-content/${firstContent.language || '0'}`);
+ }
+ }, [contents, language, experimentId]);
+
+ const handleAdd = () => {
+ const newContent = {
+ ...defaultContent,
+ index: contents.length,
+ };
+ const updatedContents = [...contents, newContent].map((content, index) => ({ ...content, index }));
+
+ onChange(updatedContents);
+ // Navigate to the new content's tab
+ navigate(`/experiments/${experimentId}/translated-content/${updatedContents.length - 1}`);
+ };
+
+ const handleRemove = (index: number) => {
+ if (confirm('Are you sure you want to remove this translation?')) {
+ onChange(contents.filter((_, i) => i !== index));
+ // Navigate to the closest tab
+ const nextIndex = Math.min(index, contents.length - 2);
+ navigate(`/experiments/${experimentId}/translated-content/${nextIndex}`);
+ }
+ };
+
+ const handleChange = (index: number, newContent: TranslatedContent) => {
+ const updatedContents = contents.map((content, i) => {
+ if (i === index) {
+ return { ...content, ...newContent };
+ }
+ return content;
+ });
+ onChange(updatedContents);
+ };
+
+ const getTabLabel = (content: TranslatedContent, index: number) => {
+ if (content.language) {
+ return ;
+ }
+ return `Translation ${index + 1}`;
+ };
+
+ const handleTabChange = (tabIndex: string | number) => {
+ if (tabIndex === 'new') {
+ handleAdd();
+ } else {
+ const content = contents[tabIndex as number];
+ navigate(`/experiments/${experimentId}/translated-content/${content.language || tabIndex}`);
+ }
+ };
+
+ return (
+
+
+ Translated Content
+
+
+
({
+ id: index,
+ label: getTabLabel(content, index),
+ })),
+ {
+ id: 'new',
+ label: (
+
+
+ New Translation
+
+ ),
+ }
+ ]}
+ wrap={true}
+ activeTab={activeTabIndex}
+ onTabChange={handleTabChange}
+ actions={[
+ {
+ icon: ,
+ title: 'Remove translation',
+ onClick: (tabId) => handleRemove(tabId as number),
+ },
+ ]}
+ />
+
+ {contents.length > 0 && !!contents[activeTabIndex] && (
+ handleChange(activeTabIndex, content)} />
+ )}
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/form/Checkbox.tsx b/backend/experiment/templates/form/experiment-form/src/components/form/Checkbox.tsx
new file mode 100644
index 000000000..fa6d789f1
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/form/Checkbox.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+
+interface CheckboxProps extends Omit, 'type'> {
+ label?: string;
+}
+
+export const Checkbox: React.FC = ({ className = '', label, ...props }) => {
+ return (
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/form/FormField.tsx b/backend/experiment/templates/form/experiment-form/src/components/form/FormField.tsx
new file mode 100644
index 000000000..a0e8eb0c0
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/form/FormField.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+
+interface FormFieldProps {
+ label: string;
+ error?: string;
+ className?: string;
+ children: React.ReactNode;
+}
+
+export const FormField: React.FC = ({
+ label,
+ error,
+ className = '',
+ children
+}) => {
+ return (
+
+
+ {error &&
{error}
}
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/form/Input.tsx b/backend/experiment/templates/form/experiment-form/src/components/form/Input.tsx
new file mode 100644
index 000000000..f4632ccd6
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/form/Input.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+interface InputProps extends React.InputHTMLAttributes {
+ fullWidth?: boolean;
+}
+
+export const Input: React.FC = ({ className = '', fullWidth = true, ...props }) => {
+ return (
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/form/MarkdownEditor.tsx b/backend/experiment/templates/form/experiment-form/src/components/form/MarkdownEditor.tsx
new file mode 100644
index 000000000..c9bf8b6bb
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/form/MarkdownEditor.tsx
@@ -0,0 +1,53 @@
+import { useState } from 'react';
+import ReactMarkdown from 'react-markdown';
+import remarkGfm from 'remark-gfm';
+
+interface MarkdownEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ rows?: number;
+ field?: string;
+}
+
+export function MarkdownEditor({ field, value, onChange, rows = 10 }: MarkdownEditorProps) {
+ const [isPreview, setIsPreview] = useState(false);
+
+ const placeholder = `# ${field ? field : 'Markdown input'}\n\n**Write your _markdown_ content here...**`;
+
+ return (
+
+
+
+
+
+
+ {isPreview ? (
+
+ {value}
+
+ ) : (
+
+ );
+}
diff --git a/backend/experiment/templates/form/experiment-form/src/components/form/Select.tsx b/backend/experiment/templates/form/experiment-form/src/components/form/Select.tsx
new file mode 100644
index 000000000..bf96adbac
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/form/Select.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+interface SelectProps extends React.SelectHTMLAttributes {
+ fullWidth?: boolean;
+}
+
+export const Select: React.FC = ({ className = '', fullWidth = true, ...props }) => {
+ return (
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/components/form/Textarea.tsx b/backend/experiment/templates/form/experiment-form/src/components/form/Textarea.tsx
new file mode 100644
index 000000000..2a6847cc4
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/components/form/Textarea.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+interface TextareaProps extends React.TextareaHTMLAttributes {
+ fullWidth?: boolean;
+}
+
+export const Textarea: React.FC = ({ className = '', fullWidth = true, ...props }) => {
+ return (
+
+ );
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/config.ts b/backend/experiment/templates/form/experiment-form/src/config.ts
new file mode 100644
index 000000000..5249d42ce
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/config.ts
@@ -0,0 +1,18 @@
+export const BASE_URL = 'http://localhost:8000';
+export const API_BASE_URL = `${BASE_URL}`;
+
+export const EXPERIMENT_API_BASE_URL = `${BASE_URL}/experiment/api`;
+
+export const createEntityUrl = (appName: string, entity: string, id?: string) => {
+ return id ? `${API_BASE_URL}/${appName}/api/${entity}/${id}/` : `${API_BASE_URL}/${appName}/api/${entity}/`;
+}
+
+export const createExperimentEntityUrl = (entity: string, id?: string) => {
+ return id ? `${EXPERIMENT_API_BASE_URL}/${entity}/${id}/` : `${EXPERIMENT_API_BASE_URL}/${entity}/`;
+}
+
+export const createExperimentAPIUrl = (path: string) => {
+ return `${EXPERIMENT_API_BASE_URL}/${path}/`;
+}
+
+export const TOKEN_URL = `${BASE_URL}/api/token/`;
diff --git a/backend/experiment/templates/form/experiment-form/src/constants.ts b/backend/experiment/templates/form/experiment-form/src/constants.ts
new file mode 100644
index 000000000..6268c8609
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/constants.ts
@@ -0,0 +1,186 @@
+export const ISO_LANGUAGES: { [key: string]: string } = {
+ 'ab': 'Abkhazian',
+ 'aa': 'Afar',
+ 'af': 'Afrikaans',
+ 'ak': 'Akan',
+ 'sq': 'Albanian',
+ 'am': 'Amharic',
+ 'ar': 'Arabic',
+ 'an': 'Aragonese',
+ 'hy': 'Armenian',
+ 'as': 'Assamese',
+ 'av': 'Avaric',
+ 'ae': 'Avestan',
+ 'ay': 'Aymara',
+ 'az': 'Azerbaijani',
+ 'bm': 'Bambara',
+ 'ba': 'Bashkir',
+ 'eu': 'Basque',
+ 'be': 'Belarusian',
+ 'bn': 'Bengali',
+ 'bh': 'Bihari languages',
+ 'bi': 'Bislama',
+ 'nb': 'Bokmål, Norwegian; Norwegian Bokmål',
+ 'bs': 'Bosnian',
+ 'br': 'Breton',
+ 'bg': 'Bulgarian',
+ 'my': 'Burmese',
+ 'ca': 'Catalan; Valencian',
+ 'km': 'Central Khmer',
+ 'ch': 'Chamorro',
+ 'ce': 'Chechen',
+ 'ny': 'Chichewa; Chewa; Nyanja',
+ 'zh': 'Chinese',
+ 'cu': 'Church Slavic; Old Slavonic; Church Slavonic; Old Bulgarian; Old Church Slavonic',
+ 'cv': 'Chuvash',
+ 'kw': 'Cornish',
+ 'co': 'Corsican',
+ 'cr': 'Cree',
+ 'hr': 'Croatian',
+ 'cs': 'Czech',
+ 'da': 'Danish',
+ 'dv': 'Divehi; Dhivehi; Maldivian',
+ 'nl': 'Dutch; Flemish',
+ 'dz': 'Dzongkha',
+ 'en': 'English',
+ 'eo': 'Esperanto',
+ 'et': 'Estonian',
+ 'ee': 'Ewe',
+ 'fo': 'Faroese',
+ 'fj': 'Fijian',
+ 'fi': 'Finnish',
+ 'fr': 'French',
+ 'ff': 'Fulah',
+ 'gd': 'Gaelic; Scottish Gaelic',
+ 'gl': 'Galician',
+ 'lg': 'Ganda',
+ 'ka': 'Georgian',
+ 'de': 'German',
+ 'el': 'Greek, Modern (1453-)',
+ 'gn': 'Guarani',
+ 'gu': 'Gujarati',
+ 'ht': 'Haitian; Haitian Creole',
+ 'ha': 'Hausa',
+ 'he': 'Hebrew',
+ 'hz': 'Herero',
+ 'hi': 'Hindi',
+ 'ho': 'Hiri Motu',
+ 'hu': 'Hungarian',
+ 'is': 'Icelandic',
+ 'io': 'Ido',
+ 'ig': 'Igbo',
+ 'id': 'Indonesian',
+ 'ia': 'Interlingua (International Auxiliary Language Association)',
+ 'ie': 'Interlingue; Occidental',
+ 'iu': 'Inuktitut',
+ 'ik': 'Inupiaq',
+ 'ga': 'Irish',
+ 'it': 'Italian',
+ 'ja': 'Japanese',
+ 'jv': 'Javanese',
+ 'kl': 'Kalaallisut; Greenlandic',
+ 'kn': 'Kannada',
+ 'kr': 'Kanuri',
+ 'ks': 'Kashmiri',
+ 'kk': 'Kazakh',
+ 'ki': 'Kikuyu; Gikuyu',
+ 'rw': 'Kinyarwanda',
+ 'ky': 'Kirghiz; Kyrgyz',
+ 'kv': 'Komi',
+ 'kg': 'Kongo',
+ 'ko': 'Korean',
+ 'kj': 'Kuanyama; Kwanyama',
+ 'ku': 'Kurdish',
+ 'lo': 'Lao',
+ 'la': 'Latin',
+ 'lv': 'Latvian',
+ 'li': 'Limburgan; Limburger; Limburgish',
+ 'ln': 'Lingala',
+ 'lt': 'Lithuanian',
+ 'lu': 'Luba-Katanga',
+ 'lb': 'Luxembourgish; Letzeburgesch',
+ 'mk': 'Macedonian',
+ 'mg': 'Malagasy',
+ 'ms': 'Malay',
+ 'ml': 'Malayalam',
+ 'mt': 'Maltese',
+ 'gv': 'Manx',
+ 'mi': 'Maori',
+ 'mr': 'Marathi',
+ 'mh': 'Marshallese',
+ 'mn': 'Mongolian',
+ 'na': 'Nauru',
+ 'nv': 'Navajo; Navaho',
+ 'nd': 'Ndebele, North; North Ndebele',
+ 'nr': 'Ndebele, South; South Ndebele',
+ 'ng': 'Ndonga',
+ 'ne': 'Nepali',
+ 'se': 'Northern Sami',
+ 'no': 'Norwegian',
+ 'nn': 'Norwegian Nynorsk; Nynorsk, Norwegian',
+ 'oc': 'Occitan (post 1500)',
+ 'oj': 'Ojibwa',
+ 'or': 'Oriya',
+ 'om': 'Oromo',
+ 'os': 'Ossetian; Ossetic',
+ 'pi': 'Pali',
+ 'pa': 'Panjabi; Punjabi',
+ 'fa': 'Persian',
+ 'pl': 'Polish',
+ 'pt': 'Portuguese',
+ 'ps': 'Pushto; Pashto',
+ 'qu': 'Quechua',
+ 'ro': 'Romanian; Moldavian; Moldovan',
+ 'rm': 'Romansh',
+ 'rn': 'Rundi',
+ 'ru': 'Russian',
+ 'sm': 'Samoan',
+ 'sg': 'Sango',
+ 'sa': 'Sanskrit',
+ 'sc': 'Sardinian',
+ 'sr': 'Serbian',
+ 'sn': 'Shona',
+ 'ii': 'Sichuan Yi; Nuosu',
+ 'sd': 'Sindhi',
+ 'si': 'Sinhala; Sinhalese',
+ 'sk': 'Slovak',
+ 'sl': 'Slovenian',
+ 'so': 'Somali',
+ 'st': 'Sotho, Southern',
+ 'es': 'Spanish; Castilian',
+ 'su': 'Sundanese',
+ 'sw': 'Swahili',
+ 'ss': 'Swati',
+ 'sv': 'Swedish',
+ 'tl': 'Tagalog',
+ 'ty': 'Tahitian',
+ 'tg': 'Tajik',
+ 'ta': 'Tamil',
+ 'tt': 'Tatar',
+ 'te': 'Telugu',
+ 'th': 'Thai',
+ 'bo': 'Tibetan',
+ 'ti': 'Tigrinya',
+ 'to': 'Tonga (Tonga Islands)',
+ 'ts': 'Tsonga',
+ 'tn': 'Tswana',
+ 'tr': 'Turkish',
+ 'tk': 'Turkmen',
+ 'tw': 'Twi',
+ 'ug': 'Uighur; Uyghur',
+ 'uk': 'Ukrainian',
+ 'ur': 'Urdu',
+ 'uz': 'Uzbek',
+ 've': 'Venda',
+ 'vi': 'Vietnamese',
+ 'vo': 'Volapük',
+ 'wa': 'Walloon',
+ 'cy': 'Welsh',
+ 'fy': 'Western Frisian',
+ 'wo': 'Wolof',
+ 'xh': 'Xhosa',
+ 'yi': 'Yiddish',
+ 'yo': 'Yoruba',
+ 'za': 'Zhuang; Chuang',
+ 'zu': 'Zulu'
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/hooks/useBlockPlaylists.ts b/backend/experiment/templates/form/experiment-form/src/hooks/useBlockPlaylists.ts
new file mode 100644
index 000000000..0ad1c8904
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/hooks/useBlockPlaylists.ts
@@ -0,0 +1,33 @@
+import { useState, useEffect } from 'react';
+import { createEntityUrl } from '../config';
+
+export interface BlockPlaylists {
+ id: string;
+ name: string;
+}
+
+export const useBlockPlaylists = () => {
+ const [playlists, setPlaylists] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchPlaylists = async () => {
+ setLoading(true);
+ try {
+ const response = await fetch(createEntityUrl('section', 'playlists'));
+ if (!response.ok) throw new Error('Failed to fetch playlists');
+ const data = await response.json();
+ setPlaylists(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchPlaylists();
+ }, []);
+
+ return { playlists, loading, error };
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/hooks/useBlockRules.ts b/backend/experiment/templates/form/experiment-form/src/hooks/useBlockRules.ts
new file mode 100644
index 000000000..0226e467b
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/hooks/useBlockRules.ts
@@ -0,0 +1,33 @@
+import { useState, useEffect } from 'react';
+import { createExperimentAPIUrl } from '../config';
+
+export interface BlockRule {
+ id: string;
+ name: string;
+}
+
+export const useBlockRules = () => {
+ const [rules, setRules] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchRules = async () => {
+ setLoading(true);
+ try {
+ const response = await fetch(createExperimentAPIUrl('block-rules'));
+ if (!response.ok) throw new Error('Failed to fetch block rules');
+ const data = await response.json();
+ setRules(data);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Unknown error');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchRules();
+ }, []);
+
+ return { rules, loading, error };
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/hooks/useFetch.ts b/backend/experiment/templates/form/experiment-form/src/hooks/useFetch.ts
new file mode 100644
index 000000000..d912af638
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/hooks/useFetch.ts
@@ -0,0 +1,63 @@
+import { useState, useEffect, useCallback } from "react";
+
+// useFetch is a react hook for getting data from a given url
+export const useFetch = (url: string, method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'GET', body: any = null): [T | null, string | null, boolean, () => void] => {
+
+ const [data, setData] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ const fetchData = useCallback(async () => {
+ setData(null);
+ setError(null);
+ setLoading(true);
+ try {
+
+ let options: RequestInit = {
+ method,
+ }
+
+ if (body) {
+ options = {
+ ...options,
+ body: JSON.stringify(body),
+ headers: { 'Content-Type': 'application/json' }
+ }
+ }
+
+ const jwt = localStorage.getItem('jwt');
+
+ if (jwt) {
+ options = {
+ ...options,
+ headers: {
+ ...options.headers,
+ Authorization: `Bearer ${jwt}`
+ }
+ }
+ }
+
+ const response = await fetch(url, options);
+ const jsonData = await response.json();
+ setData(jsonData);
+ } catch (err) {
+ setData(null);
+ setError(err.toString());
+
+ } finally {
+ setLoading(false);
+ }
+ }, [url, method, body]);
+
+ useEffect(() => {
+ const abortController = new AbortController();
+ fetchData();
+ return () => {
+ abortController.abort();
+ };
+ }, [url, fetchData]);
+
+ return [data, error, loading, fetchData];
+};
+
+export default useFetch;
diff --git a/backend/experiment/templates/form/experiment-form/src/hooks/useMutation.ts b/backend/experiment/templates/form/experiment-form/src/hooks/useMutation.ts
new file mode 100644
index 000000000..1fa80602f
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/hooks/useMutation.ts
@@ -0,0 +1,45 @@
+import { useState, useCallback } from "react";
+
+export const useMutation = (
+ url: string,
+ method: 'POST' | 'PUT' | 'PATCH' | 'DELETE' = 'POST'
+): [(data: T) => Promise, { loading: boolean; error: string | null; data: R | null }] => {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [data, setData] = useState(null);
+
+ const mutate = useCallback(async (body: T) => {
+ setLoading(true);
+ setError(null);
+
+ try {
+ const jwt = localStorage.getItem('jwt');
+ const response = await fetch(url, {
+ method,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(jwt && { Authorization: `Bearer ${jwt}` })
+ },
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(JSON.stringify(errorData));
+ }
+
+ const jsonData = await response.json();
+ setData(jsonData);
+ return jsonData;
+ } catch (err) {
+ setError(err.toString());
+ throw err;
+ } finally {
+ setLoading(false);
+ }
+ }, [url, method]);
+
+ return [mutate, { loading, error, data }];
+};
+
+export default useMutation;
diff --git a/backend/experiment/templates/form/experiment-form/src/index.css b/backend/experiment/templates/form/experiment-form/src/index.css
new file mode 100644
index 000000000..b5c61c956
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/index.css
@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
diff --git a/backend/experiment/templates/form/experiment-form/src/main.tsx b/backend/experiment/templates/form/experiment-form/src/main.tsx
new file mode 100644
index 000000000..3d7150da8
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/main.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App.tsx'
+import './index.css'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/backend/experiment/templates/form/experiment-form/src/types/types.ts b/backend/experiment/templates/form/experiment-form/src/types/types.ts
new file mode 100644
index 000000000..a97aaac9e
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/types/types.ts
@@ -0,0 +1,55 @@
+export interface TranslatedContent {
+ id?: number;
+ index: number;
+ language: string;
+ name: string;
+ description: string;
+ about_content: string;
+ social_media_message: string;
+}
+
+export interface BlockTranslatedContent {
+ index: number;
+ language: string;
+ name: string;
+ description: string;
+}
+
+export interface BlockPlaylist {
+ id: string;
+ name: string;
+}
+
+export interface Block {
+ id?: number;
+ index: number;
+ slug: string;
+ rounds: number;
+ bonus_points: number;
+ rules: string;
+ phase?: number; // Make phase optional
+ translated_contents: BlockTranslatedContent[];
+ playlists: BlockPlaylist[];
+}
+
+export interface Phase {
+ id?: number;
+ index: number;
+ dashboard: boolean;
+ randomize: boolean;
+ blocks: Block[];
+}
+
+export interface Experiment {
+ id?: number;
+ slug: string;
+ active: boolean;
+ translated_content: TranslatedContent[];
+ phases: Phase[];
+}
+
+export type Selection = {
+ type: 'phase' | 'block';
+ phaseIndex: number;
+ blockIndex?: number;
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/utils/languageUtils.ts b/backend/experiment/templates/form/experiment-form/src/utils/languageUtils.ts
new file mode 100644
index 000000000..9e201a796
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/utils/languageUtils.ts
@@ -0,0 +1,28 @@
+import { ISO_LANGUAGES } from '../constants';
+
+// Map of language codes to country codes for flags
+// Note: This is a simplified mapping, some languages might need different country codes
+export const LANGUAGE_TO_COUNTRY: { [key: string]: string } = {
+ en: 'GB',
+ es: 'ES',
+ fr: 'FR',
+ de: 'DE',
+ it: 'IT',
+ pt: 'PT',
+ nl: 'NL',
+ ru: 'RU',
+ zh: 'CN',
+ ja: 'JP',
+ ko: 'KR',
+ ar: 'SA',
+ hi: 'IN',
+ // Add more mappings as needed
+};
+
+export const getCountryCode = (languageCode: string): string => {
+ return LANGUAGE_TO_COUNTRY[languageCode] || languageCode.toUpperCase();
+};
+
+export const getLanguageName = (languageCode: string): string => {
+ return ISO_LANGUAGES[languageCode] || languageCode;
+};
diff --git a/backend/experiment/templates/form/experiment-form/src/utils/store.ts b/backend/experiment/templates/form/experiment-form/src/utils/store.ts
new file mode 100644
index 000000000..63814e3e2
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/utils/store.ts
@@ -0,0 +1,70 @@
+import { StateCreator, create } from "zustand";
+
+import { Experiment } from "../types/types";
+
+interface ExperimentSlice {
+ experiments: Experiment[];
+ experiment?: Experiment;
+ setExperiments: (experiments: Experiment[]) => void;
+ setExperiment: (experiment: Experiment) => void;
+ patchExperiment: (experiment: Partial) => void;
+}
+
+const createExperimentSlice: StateCreator = (set) => ({
+ experiments: [],
+ experiment: undefined,
+ setExperiments: (experiments) => set(() => ({ experiments })),
+ setExperiment: (experiment) => set(() => ({ experiment })),
+ patchExperiment: (experiment) => set((state) => {
+ return { experiment: { ...state.experiment, ...experiment } };
+ })
+});
+
+interface AuthSlice {
+ jwt: string | null;
+ setJwt: (jwt: string | null) => void;
+}
+
+const createAuthSlice: StateCreator = (set) => ({
+ jwt: localStorage.getItem("jwt"),
+ setJwt: (jwt) => {
+ if (jwt) {
+ localStorage.setItem("jwt", jwt);
+ } else {
+ localStorage.removeItem("jwt");
+ }
+ set(() => ({ jwt }));
+ },
+});
+
+export interface Toast {
+ message: string;
+ duration: number;
+ level: "info" | "success" | "warning" | "error";
+}
+
+interface ToastsSlice {
+ toasts: Toast[];
+ addToast: (toast: Toast) => void;
+}
+
+const createToastsSlice: StateCreator = (set) => ({
+ toasts: [],
+ addToast: (toast) => {
+ // Add toast to the list of toasts, then, based on the toast's duration, remove it after a certain amount of time
+ set((state) => ({ toasts: [...state.toasts, toast] }));
+ setTimeout(() => {
+ set((state) => ({ toasts: state.toasts.filter((t) => t !== toast) }));
+ }, toast.duration);
+ },
+});
+
+export const useBoundStore = create<
+ ExperimentSlice & ToastsSlice & AuthSlice
+>((...args) => ({
+ ...createExperimentSlice(...args),
+ ...createToastsSlice(...args),
+ ...createAuthSlice(...args),
+}));
+
+export default useBoundStore;
diff --git a/backend/experiment/templates/form/experiment-form/src/vite-env.d.ts b/backend/experiment/templates/form/experiment-form/src/vite-env.d.ts
new file mode 100644
index 000000000..11f02fe2a
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/backend/experiment/templates/form/experiment-form/tailwind.config.js b/backend/experiment/templates/form/experiment-form/tailwind.config.js
new file mode 100644
index 000000000..e9baf9088
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/tailwind.config.js
@@ -0,0 +1,13 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {},
+ },
+ plugins: [
+ require('@tailwindcss/typography'),
+ ],
+}
diff --git a/backend/experiment/templates/form/experiment-form/tsconfig.json b/backend/experiment/templates/form/experiment-form/tsconfig.json
new file mode 100644
index 000000000..a7fc6fbf2
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/backend/experiment/templates/form/experiment-form/tsconfig.node.json b/backend/experiment/templates/form/experiment-form/tsconfig.node.json
new file mode 100644
index 000000000..97ede7ee6
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/backend/experiment/templates/form/experiment-form/vite.config.ts b/backend/experiment/templates/form/experiment-form/vite.config.ts
new file mode 100644
index 000000000..861b04b35
--- /dev/null
+++ b/backend/experiment/templates/form/experiment-form/vite.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react-swc'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/backend/experiment/urls.py b/backend/experiment/urls.py
index c1e4cf981..1691e0450 100644
--- a/backend/experiment/urls.py
+++ b/backend/experiment/urls.py
@@ -1,27 +1,44 @@
-from django.urls import path
+from django.urls import path, include
+from rest_framework import routers
from django.views.generic.base import TemplateView
-from .views import get_block, get_experiment, post_feedback, render_markdown, add_default_question_series, validate_block_playlist
+from .views import (
+ ExperimentViewSet,
+ get_block,
+ get_experiment,
+ post_feedback,
+ render_markdown,
+ add_default_question_series,
+ validate_block_playlist,
+ block_rules,
+)
-app_name = 'experiment'
+app_name = "experiment"
+
+router = routers.DefaultRouter()
+router.register(r"experiments", ExperimentViewSet, basename="experiment")
urlpatterns = [
- path('render_markdown/', render_markdown, name='render_markdown'),
+ # Experiment API
+ path("api/", include(router.urls)),
+ # Experiment
+ path("render_markdown/", render_markdown, name="render_markdown"),
path(
- 'validate_playlist/',
+ "validate_playlist/",
validate_block_playlist,
- name='validate_block_playlist',
+ name="validate_block_playlist",
),
- path('block//', get_block, name='block'),
- path('block//feedback/', post_feedback, name='feedback'),
+ path("block//", get_block, name="block"),
+ path("block//feedback/", post_feedback, name="feedback"),
path(
- 'block//default_question_series/',
+ "block//default_question_series/",
add_default_question_series,
name="default_question_series",
),
- path('/', get_experiment, name='experiment'),
+ path("/", get_experiment, name="experiment"),
# Robots.txt
path(
"robots.txt",
TemplateView.as_view(template_name="robots.txt", content_type="text/plain"),
),
+ path("api/block-rules/", block_rules, name="block-rules"),
]
diff --git a/backend/experiment/views.py b/backend/experiment/views.py
index 602c4d4af..22c4e9dcd 100644
--- a/backend/experiment/views.py
+++ b/backend/experiment/views.py
@@ -4,6 +4,9 @@
from django.http import Http404, HttpRequest, JsonResponse
from django.utils.translation import gettext_lazy as _, get_language
from django_markup.markup import formatter
+from rest_framework import viewsets, permissions
+from rest_framework.decorators import api_view
+from rest_framework.response import Response
from .models import Block, Experiment, Feedback, Session
from section.models import Playlist
@@ -14,6 +17,8 @@
)
from experiment.rules import BLOCK_RULES
from experiment.actions.utils import EXPERIMENT_KEY
+from experiment.serializers import ExperimentSerializer
+
from participant.models import Participant
from participant.utils import get_participant
from theme.serializers import serialize_theme
@@ -21,6 +26,12 @@
logger = logging.getLogger(__name__)
+class ExperimentViewSet(viewsets.ModelViewSet):
+ queryset = Experiment.objects.all()
+ serializer_class = ExperimentSerializer
+ permission_classes = [permissions.IsAuthenticated]
+
+
def get_block(request: HttpRequest, slug: str) -> JsonResponse:
"""Get block data from active block with given :slug
DO NOT modify session data here, it will break participant_id system
@@ -48,10 +59,7 @@ def get_block(request: HttpRequest, slug: str) -> JsonResponse:
"class_name": class_name, # can be used to override style
"rounds": block.rounds,
"bonus_points": block.bonus_points,
- "playlists": [
- {"id": playlist.id, "name": playlist.name}
- for playlist in block.playlists.all()
- ],
+ "playlists": [{"id": playlist.id, "name": playlist.name} for playlist in block.playlists.all()],
"feedback_info": block.get_rules().feedback_info(),
"loading_text": _("Loading"),
"session_id": session.id,
@@ -138,9 +146,7 @@ def _get_min_session_count(experiment: Experiment, participant: Participant) ->
for phase in phases:
session_counts.extend(
[
- Session.objects.exclude(finished_at__isnull=True)
- .filter(block=block, participant=participant)
- .count()
+ Session.objects.exclude(finished_at__isnull=True).filter(block=block, participant=participant).count()
for block in phase.blocks.all()
]
)
@@ -210,3 +216,10 @@ def validate_block_playlist(request: HttpRequest, rules_id: str) -> JsonResponse
)
return JsonResponse({"status": "ok", "message": "The playlist is valid."})
+
+
+@api_view(["GET"])
+def block_rules(request):
+ """Return a list of available block rules"""
+ rules = [{"id": rule_id, "name": rule_class.__name__} for rule_id, rule_class in BLOCK_RULES.items()]
+ return Response(rules)
diff --git a/backend/requirements.in/base.txt b/backend/requirements.in/base.txt
index 17630439f..8f084c6cd 100644
--- a/backend/requirements.in/base.txt
+++ b/backend/requirements.in/base.txt
@@ -44,3 +44,12 @@ mkdocs-material
# Translate fields in Django models
django-modeltranslation
+
+# Django multiselect with search
+django-select2
+
+# Django REST framework
+djangorestframework
+
+# Django REST JWT
+djangorestframework-simplejwt
diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt
index 88f796827..175f44168 100644
--- a/backend/requirements/dev.txt
+++ b/backend/requirements/dev.txt
@@ -41,11 +41,17 @@ dill==0.3.8
django==4.2.18
# via
# -r requirements.in/base.txt
+ # django-appconf
# django-cors-headers
# django-debug-toolbar
# django-inline-actions
# django-markup
# django-modeltranslation
+ # django-select2
+ # djangorestframework
+ # djangorestframework-simplejwt
+django-appconf==1.0.6
+ # via django-select2
django-cors-headers==3.10.0
# via -r requirements.in/base.txt
django-debug-toolbar==4.3.0
@@ -58,6 +64,14 @@ django-modeltranslation==0.19.9
# via -r requirements.in/base.txt
django-nested-admin==4.1.1
# via -r requirements.in/base.txt
+django-select2==8.2.1
+ # via -r requirements.in/base.txt
+djangorestframework==3.15.2
+ # via
+ # -r requirements.in/base.txt
+ # djangorestframework-simplejwt
+djangorestframework-simplejwt==5.3.1
+ # via -r requirements.in/base.txt
docutils==0.20.1
# via
# django-markup
@@ -144,6 +158,8 @@ pygments==2.17.2
# via
# django-markup
# mkdocs-material
+pyjwt==2.10.1
+ # via djangorestframework-simplejwt
pylint==3.1.0
# via
# -r requirements.in/dev.txt
diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt
index ab6a8130b..682887364 100644
--- a/backend/requirements/prod.txt
+++ b/backend/requirements/prod.txt
@@ -34,10 +34,16 @@ defusedxml==0.7.1
django==4.2.18
# via
# -r requirements.in/base.txt
+ # django-appconf
# django-cors-headers
# django-inline-actions
# django-markup
# django-modeltranslation
+ # django-select2
+ # djangorestframework
+ # djangorestframework-simplejwt
+django-appconf==1.0.6
+ # via django-select2
django-cors-headers==4.0.0
# via -r requirements.in/base.txt
django-inline-actions==2.4.0
@@ -48,6 +54,14 @@ django-modeltranslation==0.19.9
# via -r requirements.in/base.txt
django-nested-admin==4.1.1
# via -r requirements.in/base.txt
+django-select2==8.2.1
+ # via -r requirements.in/base.txt
+djangorestframework==3.15.2
+ # via
+ # -r requirements.in/base.txt
+ # djangorestframework-simplejwt
+djangorestframework-simplejwt==5.3.1
+ # via -r requirements.in/base.txt
docutils==0.20.1
# via
# django-markup
@@ -131,6 +145,8 @@ pygments==2.17.2
# via
# django-markup
# mkdocs-material
+pyjwt==2.10.1
+ # via djangorestframework-simplejwt
pymdown-extensions==10.7.1
# via
# mkdocs-material
diff --git a/backend/section/urls.py b/backend/section/urls.py
index 70b66be94..4eb2abd48 100644
--- a/backend/section/urls.py
+++ b/backend/section/urls.py
@@ -1,10 +1,9 @@
from django.urls import path
+from .views import get_section, playlists
-from .views import get_section
-
-app_name = 'section'
+app_name = "section"
urlpatterns = [
- # Section
path("/", get_section, name="section"),
+ path("api/playlists/", playlists, name="playlists"),
]
diff --git a/backend/section/views.py b/backend/section/views.py
index 2035fc884..ab3a5082a 100644
--- a/backend/section/views.py
+++ b/backend/section/views.py
@@ -1,13 +1,17 @@
from os.path import join
-from django.http import Http404, HttpRequest, FileResponse
+from django.http import Http404, HttpRequest, FileResponse, HttpResponsePermanentRedirect, HttpResponseRedirect
from django.conf import settings
from django.shortcuts import redirect
-from .models import Section
+from .models import Playlist, Section
+from rest_framework.decorators import api_view
+from rest_framework.response import Response
-def get_section(request: HttpRequest, section_id: int) -> Section:
+def get_section(
+ request: HttpRequest, section_id: int
+) -> Section | HttpResponsePermanentRedirect | HttpResponseRedirect | FileResponse:
"""Get section by given id"""
try:
section = Section.objects.get(pk=section_id)
@@ -23,7 +27,7 @@ def get_section(request: HttpRequest, section_id: int) -> Section:
# Advantage: low server load
# Disadvantage: exposes url
- if str(section.filename).startswith('http'):
+ if str(section.filename).startswith("http"):
# external link, redirect
return redirect(str(section.filename))
@@ -51,7 +55,7 @@ def get_section(request: HttpRequest, section_id: int) -> Section:
response = FileResponse(open(filepath, "rb"))
# Header is required to make seeking work in Chrome
- response['Accept-Ranges'] = 'bytes'
+ response["Accept-Ranges"] = "bytes"
# Response may log a ConnectionResetError on the development server
# This has no effect on serving the file
@@ -59,3 +63,10 @@ def get_section(request: HttpRequest, section_id: int) -> Section:
except Section.DoesNotExist:
raise Http404("Section does not exist")
+
+
+@api_view(["GET"])
+def playlists(request):
+ """Return a list of all playlists"""
+ playlists = [{"id": playlist.id, "name": playlist.name} for playlist in Playlist.objects.all()]
+ return Response(playlists)