diff --git a/src/atlas_frontend/src/layouts/GradientBox.tsx b/src/atlas_frontend/src/layouts/GradientBox.tsx
index e89a898..bbd047d 100644
--- a/src/atlas_frontend/src/layouts/GradientBox.tsx
+++ b/src/atlas_frontend/src/layouts/GradientBox.tsx
@@ -7,7 +7,7 @@ interface GradientBoxProps {
const GradientBox = ({ children }: GradientBoxProps) => {
return (
-
diff --git a/src/atlas_frontend/src/layouts/Layout.astro b/src/atlas_frontend/src/layouts/Layout.astro
index 5e0bf8c..39c06fa 100644
--- a/src/atlas_frontend/src/layouts/Layout.astro
+++ b/src/atlas_frontend/src/layouts/Layout.astro
@@ -5,6 +5,7 @@ export interface Props {
}
const { title, lang } = Astro.props;
+import '../styles/global.css';
---
diff --git a/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx b/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx
index f51f8d0..0f1cd9d 100644
--- a/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx
+++ b/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx
@@ -1,21 +1,20 @@
-import React from "react";
+import React, { useRef, useEffect } from "react";
import { yupResolver } from "@hookform/resolvers/yup";
-import { useForm, useFieldArray, type SubmitHandler } from "react-hook-form";
+import { useForm, useFieldArray, type SubmitHandler, type FieldErrorsImpl } from "react-hook-form";
import Button from "../components/Shared/Button";
+import SpaceHeader from "../components/Shared/SpaceHeader";
import * as yup from "yup";
-import { FiPlus } from "react-icons/fi";
-import DecimalInputForm from "../components/Shared/DecimalInputForm";
import { formatUnits, parseUnits } from "ethers";
import { useDispatch, useSelector } from "react-redux";
import { DECIMALS } from "../canisters/ckUsdcLedger/constans";
-import GenericTask from "./tasks/GenericTask";
-import NumericInputForm from "../components/Shared/NumericInputForm";
-import { createNewTask, getSpaceTasks } from "../canisters/atlasSpace/api";
+import { createNewTask, editTask, getSpaceTasks } from "../canisters/atlasSpace/api";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
useAuthAtlasSpaceActor,
useAuthCkUsdcLedgerActor,
useUnAuthCkUsdcLedgerActor,
+ getUnAuthAtlasSpaceActor,
+ useUnAuthAgent,
} from "../hooks/identityKit";
import { useSpaceId } from "../hooks/space";
import toast from "react-hot-toast";
@@ -30,7 +29,34 @@ import {
} from "../store/slices/appSlice";
import { deserialize, type RootState } from "../store/store";
import { getErrorWithInfoToast } from "../utils/errors";
-import { toLocalISOString } from "../utils/date";
+import { getSpacePath, getTaskPath } from "../router/paths";
+import type { Space } from "../store/slices/spacesSlice";
+import { getAtlasSpace } from "../canisters/atlasSpace/api";
+import { RiCloseLargeLine } from "react-icons/ri";
+import { FaPlus } from "react-icons/fa6";
+import { FaCalendar } from "react-icons/fa";
+import NumericInputForm from "../components/Shared/NumericInputForm";
+import DecimalInputForm from "../components/Shared/DecimalInputForm";
+import { runWithLoading } from "../utils/loading";
+import { toLocalISOString, formatDateShortMonth, formatDateShortHour } from "../utils/date";
+import type { Task } from "../../../declarations/atlas_space/atlas_space.did";
+import { mapTaskToForm } from "../utils/taskFormMapper";
+import type { AnswerFormat } from "../../../declarations/atlas_space/atlas_space.did";
+import { sortKeys } from "../utils/sort";
+import DateTimeDisplayPicker from "../components/Shared/DateTimeDisplayPicker";
+import {
+ BlockchainUser,
+ selectUserBlockchainData,
+ type StorableUser,
+} from "../store/slices/userSlice";
+
+export const getAnswerFormatKey = (format: AnswerFormat): string => Object.keys(format)[0];
+const answerFormatDescriptions: Record
= {
+ Small: "Up to 254 characters",
+ Paragraph: "Up to 600 characters",
+ Long: "Up to 2500 characters",
+ List: "Multiple items, each up to 254 characters",
+};
type TaskType = "generic";
const allowedTaskTypes = ["generic"] as const;
@@ -41,16 +67,34 @@ interface CreateNewTaskFormInput {
taskTitle: string;
startTime: string;
endTime: string;
- tasks?: {
+ tasks: ({
taskType: TaskType;
title: string;
description: string;
- allowresubmit: boolean;
- }[];
+ allowResubmit: boolean;
+ answerFormat: keyof typeof answerFormatDescriptions;
+ } | { disabled: boolean })[];
}
+
+type GenericTaskError = {
+ allowResubmit?: { message: string };
+};
+type TaskForm = CreateNewTaskFormInput["tasks"][number];
+type GenericTaskForm = Extract;
+
+
const maxSubtitleLength = 50;
const maxTitleLength = 50;
const maxDescriptionLength = 500;
+const answerFormats: AnswerFormat[] = [
+ { Small: null },
+ { Paragraph: null },
+ { Long: null },
+ { List: null },
+];
+const answerFormatKeys = Object.keys(answerFormatDescriptions) as Array<
+ keyof typeof answerFormatDescriptions
+>;
const taskSchema = yup.object({
taskType: yup.mixed().oneOf(allowedTaskTypes).required(),
@@ -67,14 +111,54 @@ const taskSchema = yup.object({
.min(2)
.required()
.label("Task description"),
- allowresubmit: yup.boolean().required(),
+ allowResubmit: yup.boolean().required(),
+ answerFormat: yup
+ .string()
+ .oneOf(answerFormatKeys as string[])
+ .required()
+ .label("Answer format"),
+});
+
+const taskOrDisabledSchema = yup.lazy((value) => {
+ if (value && "disabled" in value) {
+ return yup.object({
+ disabled: yup.boolean().required(),
+ });
+ }
+ return taskSchema;
});
-interface CreateNewTaskModalArgs {
- callback: () => void;
+export interface EditableTask extends Task {
+ task_id: bigint;
}
-const CreateNewTaskModal = ({ callback }: CreateNewTaskModalArgs) => {
+const CreateNewTaskModal = () => {
+ const { spacePrincipal, taskId } = useParams();
+ const navigate = useNavigate();
+ const { pathname } = useLocation();
+ const title = pathname.endsWith("/edit") ? "Edit mission" : "Create new mission";
+
+ const principal = useSpaceId({
+ spacePrincipal,
+ navigate,
+ });
+ const { user } = useAuth();
+ if (!principal) return <>>;
+ const agent = useUnAuthAgent();
+ const spaceId = principal.toString();
+
+ const space = useSelector((state: RootState) => {
+ const serializedSpace = state.spaces?.spaces?.[principal.toString()] ?? null;
+ return deserialize(serializedSpace);
+ });
+
+ const taskToEdit: EditableTask | null = taskId && space?.tasks?.[taskId] && "timer_id" in space.tasks[taskId]
+ ? {
+ ...space.tasks[taskId],
+ task_id: BigInt(taskId),
+ }
+ : null;
+
const renderedAt = new Date();
const schema = yup.object({
taskTitle: yup
@@ -104,12 +188,10 @@ const CreateNewTaskModal = ({ callback }: CreateNewTaskModalArgs) => {
"is-after-now",
"Start time must be in the future",
function (value) {
- return (
- new Date(value).getTime() >=
- new Date(renderedAt.toISOString().slice(0, 16)).getTime()
- );
+ if (taskToEdit) return true;
+ return new Date(value).getTime() >= Date.now();
}
- ),
+ ),
endTime: yup
.string()
.required()
@@ -125,63 +207,100 @@ const CreateNewTaskModal = ({ callback }: CreateNewTaskModalArgs) => {
.test("is-after-now", "End time must be in the future", function (value) {
return new Date(value).getTime() > Date.now();
}),
- tasks: yup.array().of(taskSchema).min(1),
+ tasks: yup
+ .array()
+ .of(taskOrDisabledSchema)
+ .min(1)
+ .required(),
});
- const { spacePrincipal } = useParams();
- const navigate = useNavigate();
- const location = useLocation();
const dispatch = useDispatch();
- const isLoading = useSelector((state: RootState) => state.app.isLoading);
const {
register,
handleSubmit,
control,
watch,
+ setValue,
formState: { errors },
- } = useForm({
+ } = useForm({
resolver: yupResolver(schema),
- defaultValues: {
- numberOfUses: 1,
- rewardPerUsage: 0.1,
- tasks: [
- {
- taskType: "generic",
- title: "",
- description: "",
- allowresubmit: false,
+ defaultValues: taskToEdit
+ ? mapTaskToForm(taskToEdit)
+ : {
+ numberOfUses: 1,
+ rewardPerUsage: 0.1,
+ taskTitle: "",
+ startTime: toLocalISOString(renderedAt).slice(0, 16),
+ endTime: '',
+ tasks: [
+ {
+ taskType: "generic",
+ title: "",
+ description: "",
+ allowResubmit: false,
+ answerFormat: "Small",
},
- ],
- startTime: toLocalISOString(renderedAt).slice(0, 16),
- },
+ ],
+ },
});
- const { fields, append, remove } = useFieldArray({
+ const { fields, append } = useFieldArray({
control,
name: "tasks",
});
- const principal = useSpaceId({
- spacePrincipal,
- navigate,
- });
- const { user } = useAuth();
- if (!principal) return <>>;
- const spaceId = principal.toString();
+
+ const userBlockchainData = deserialize(
+ useSelector(selectUserBlockchainData)
+ );
+ const userInfo = userBlockchainData
+ ? new BlockchainUser(userBlockchainData)
+ : null;
+
+ const avatarImg = space?.state?.space_logo;
+ const spaceName = space?.state?.space_name;
+ const spaceDescription = space?.state?.space_description;
+ const spaceBackground = space?.state?.space_background
+ const spaceData = space?.state;
+
+ useEffect(() => {
+ if (!agent || spaceData) return;
+
+ const loadSpaceData = async () => {
+ const unAuthAtlasSpace = getUnAuthAtlasSpaceActor(agent, principal);
+ await getAtlasSpace({
+ spaceId,
+ unAuthAtlasSpace,
+ dispatch,
+ });
+ };
+
+ runWithLoading(loadSpaceData, dispatch);
+ }, [dispatch, agent, spaceData, principal, spaceId]);
const authAtlasSpaceActor = useAuthAtlasSpaceActor(principal);
const unAuthCkUsdcActor = useUnAuthCkUsdcLedgerActor();
const authCkUsdcActor = useAuthCkUsdcLedgerActor();
+ const parsedSpacePrincipal = useSpaceId({
+ spacePrincipal,
+ navigate,
+ });
+ if (!parsedSpacePrincipal) return <>>;
const blockchainConfig = deserialize(
useSelector(selectBlockchainConfig)
);
+
+ const calculateDepositAmount = (amount: bigint, fee: bigint, numberOfUses: bigint) => {
+ return amount * numberOfUses + fee * numberOfUses + fee;
+ };
+
const ckUsdcFee = blockchainConfig
? (blockchainConfig.ckusdc_ledger.fee ?? 0n)
: 0n;
-
+
const numberOfUses = watch("numberOfUses");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const rewardPerUsage = watch("rewardPerUsage" as any);
+ const rewardPerUsage = watch("rewardPerUsage") as any;
const numberOfUsesNormalized = isNaN(numberOfUses) ? 0 : numberOfUses;
const rewardPerUsageNormalized =
@@ -192,8 +311,14 @@ const CreateNewTaskModal = ({ callback }: CreateNewTaskModalArgs) => {
DECIMALS
);
const numberOfUsesBn = BigInt(numberOfUsesNormalized);
- const estimatedCost =
- numberOfUsesBn * rewardPerUsageBn + numberOfUsesBn * ckUsdcFee + ckUsdcFee;
+ const estimatedCost = calculateDepositAmount(rewardPerUsageBn, ckUsdcFee, numberOfUsesBn);
+
+ const hasAnySubmissions = !!taskToEdit && taskToEdit.tasks.some(t => {
+ if ("GenericTask" in t) {
+ return t.GenericTask.submission.length > 0;
+ }
+ return false;
+ });
const onSubmit: SubmitHandler = async ({
numberOfUses,
@@ -220,228 +345,524 @@ const CreateNewTaskModal = ({ callback }: CreateNewTaskModalArgs) => {
return;
}
- const taskContent = tasks
- ?.map((task) => {
- if (task.taskType === "generic") {
- return {
- task_type: "generic",
- title: task.title,
- description: task.description,
- allow_resubmit: task.allowresubmit,
- };
- }
- })
- .filter((item) => item !== undefined);
+ const toAnswerFormat = (key: string): AnswerFormat => {
+ switch (key) {
+ case "Small":
+ return { Small: null };
+ case "Paragraph":
+ return { Paragraph: null };
+ case "Long":
+ return { Long: null };
+ case "List":
+ return { List: null };
+ default:
+ throw new Error(`Unknown AnswerFormat key: ${key}`);
+ }
+ };
+
+ const taskContent = tasks.map((task) => {
+ if ("disabled" in task) {
+ return null;
+ }
+ return {
+ task_type: "generic",
+ title: task.title,
+ description: task.description,
+ allow_resubmit: task.allowResubmit,
+ answer_format: toAnswerFormat(task.answerFormat),
+ };
+ });
if (!taskContent || taskContent.length === 0) {
toast.error("Invalid subtasks: the minimum number of subtasks is one.");
return;
}
- const estimatedCost =
- numberOfUsesBn * rewardPerUsageBn +
- numberOfUsesBn * ckUsdcFee +
- ckUsdcFee;
- const getOrSetAllowance = setUserSpaceAllowanceIfNeeded({
- unAuthCkUsd: unAuthCkUsdcActor,
- authCkUsdc: authCkUsdcActor,
- spacePrincipal: principal,
- amount: estimatedCost,
- userPrincipal: user.principal,
- });
- await toast.promise(getOrSetAllowance, {
- loading: "Checking available funds...",
- success: "Funds allowance granted successfully.",
- error: getErrorWithInfoToast("Failed to allocate funds:"),
- });
+ let taskId: bigint;
+ if (taskToEdit) {
+ const oldTaskData = {
+ task_title: taskToEdit.task_title,
+ start_time: taskToEdit.start_time.toString(),
+ end_time: taskToEdit.end_time.toString(),
+ number_of_uses: taskToEdit.number_of_uses.toString(),
+ token_reward: taskToEdit.token_reward.CkUsdc.amount.toString(),
+ tasks: taskToEdit.tasks
+ .map(t => {
+ if ("GenericTask" in t) {
+ return {
+ task_content: t.GenericTask.task_content.TitleAndDescription,
+ };
+ }
+ return null;
+ })
+ .filter(Boolean),
+ };
- const createNewTaskCall = createNewTask({
- authAtlasSpaceActor,
- numberOfUses: numberOfUsesBn,
- rewardPerUsage: rewardPerUsageBn,
- tasks: taskContent,
- taskTitle,
- startTime: BigInt(startTimeUnixSec),
- endTime: BigInt(endTimeUnixSec),
- });
- const taskId = await toast.promise(createNewTaskCall, {
- loading: "Creating new task...",
- success: "Task created successfully.",
- error: getErrorWithInfoToast("Failed to create task:"),
- });
- callback();
- await getSpaceTasks({
- spaceId,
- unAuthAtlasSpace: authAtlasSpaceActor,
- dispatch,
- });
- await getUserBalance({
- unAuthCkUsdc: unAuthCkUsdcActor,
- userPrincipal: user?.principal,
- dispatch,
- });
- navigate(`${location.pathname}/${taskId}`);
+ const newTaskData = {
+ task_title: taskTitle,
+ start_time: startTimeUnixSec.toString(),
+ end_time: endTimeUnixSec.toString(),
+ number_of_uses: numberOfUsesBn.toString(),
+ token_reward: rewardPerUsageBn.toString(),
+ tasks: taskContent.map(task =>
+ task
+ ? {
+ task_content: {
+ task_description: task.description,
+ task_title: task.title,
+ allow_resubmit: task.allow_resubmit,
+ answer_format: task.answer_format,
+ },
+ }
+ : null
+ ),
+ };
+
+ taskId = taskToEdit.task_id;
+
+ const isSameTask = JSON.stringify(sortKeys(oldTaskData)) === JSON.stringify(sortKeys(newTaskData));
+ if (isSameTask) {
+ toast.success("No changes detected, task not updated.");
+ navigate(getTaskPath(principal, taskId.toString()));
+ return;
+ }
+
+ const currentDepositAndFee = calculateDepositAmount(
+ taskToEdit.token_reward.CkUsdc.amount,
+ BigInt(ckUsdcFee),
+ BigInt(taskToEdit.number_of_uses)
+ );
+
+ const newDepositAndFee = calculateDepositAmount(
+ rewardPerUsageBn,
+ BigInt(ckUsdcFee),
+ numberOfUsesBn
+ );
+
+ await runWithLoading(async () => {
+ if (newDepositAndFee > currentDepositAndFee) {
+ const extraCost = newDepositAndFee - currentDepositAndFee + BigInt(ckUsdcFee);
+ const allowanceCheck = setUserSpaceAllowanceIfNeeded({
+ unAuthCkUsd: unAuthCkUsdcActor,
+ authCkUsdc: authCkUsdcActor,
+ spacePrincipal: principal,
+ amount: extraCost,
+ userPrincipal: user.principal,
+ });
+ await toast.promise(allowanceCheck, {
+ loading: "Checking available funds...",
+ success: "Funds allowance granted successfully.",
+ error: getErrorWithInfoToast("Failed to allocate funds:"),
+ });
+ }
+
+ const editedCall = editTask({
+ authAtlasSpace: authAtlasSpaceActor,
+ args: {
+ task_id: taskId,
+ task_title: taskTitle !== taskToEdit.task_title ? [taskTitle] : [],
+ token_reward: rewardPerUsageBn !== taskToEdit.token_reward.CkUsdc.amount ? [{ CkUsdc: { amount: rewardPerUsageBn } }]: [],
+ start_time: startTimeUnixSec !== Number(taskToEdit.start_time) ? [BigInt(startTimeUnixSec)] : [],
+ end_time: endTimeUnixSec !== Number(taskToEdit.end_time) ? [BigInt(endTimeUnixSec)] : [],
+ number_of_uses: numberOfUsesBn !== taskToEdit.number_of_uses ? [numberOfUsesBn] : [],
+ task_content: [
+ taskContent.map(task => task
+ ? [{ TitleAndDescription: {
+ task_title: task.title,
+ task_description: task.description,
+ allow_resubmit: task.allow_resubmit,
+ answer_format: task.answer_format,
+ }}]
+ : []
+ )
+ ],
+ }
+ });
+ await toast.promise(editedCall, {
+ loading: "Saving changes...",
+ success: "Task updated successfully.",
+ error: getErrorWithInfoToast("Failed to update task:"),
+ });
+
+
+ await getSpaceTasks({
+ spaceId,
+ unAuthAtlasSpace: authAtlasSpaceActor,
+ dispatch,
+ });
+ await getUserBalance({
+ unAuthCkUsdc: unAuthCkUsdcActor,
+ userPrincipal: user?.principal,
+ dispatch,
+ });
+ navigate(getTaskPath(principal, taskId.toString()));
+ }, dispatch);
+ } else {
+ await runWithLoading(async () => {
+ const estimatedCost = calculateDepositAmount(rewardPerUsageBn, ckUsdcFee, numberOfUsesBn);
+ const getOrSetAllowance = setUserSpaceAllowanceIfNeeded({
+ unAuthCkUsd: unAuthCkUsdcActor,
+ authCkUsdc: authCkUsdcActor,
+ spacePrincipal: principal,
+ amount: estimatedCost,
+ userPrincipal: user.principal,
+ });
+ await toast.promise(getOrSetAllowance, {
+ loading: "Checking available funds...",
+ success: "Funds allowance granted successfully.",
+ error: getErrorWithInfoToast("Failed to allocate funds:"),
+ });
+
+ const createNewTaskCall = createNewTask({
+ authAtlasSpaceActor,
+ numberOfUses: numberOfUsesBn,
+ rewardPerUsage: rewardPerUsageBn,
+ tasks: taskContent.filter((task) => task !== null),
+ taskTitle,
+ startTime: BigInt(startTimeUnixSec),
+ endTime: BigInt(endTimeUnixSec),
+ });
+ const taskId = await toast.promise(createNewTaskCall, {
+ loading: "Creating new task...",
+ success: "Task created successfully.",
+ error: getErrorWithInfoToast("Failed to create task:"),
+ });
+
+ await getSpaceTasks({
+ spaceId,
+ unAuthAtlasSpace: authAtlasSpaceActor,
+ dispatch,
+ });
+ await getUserBalance({
+ unAuthCkUsdc: unAuthCkUsdcActor,
+ userPrincipal: user?.principal,
+ dispatch,
+ });
+ navigate(getTaskPath(principal, taskId.toString()));
+ }, dispatch);
+ }
+ };
+
+ const formatDisplayDateTime = (dateTimeString: string | null | undefined) => {
+ if (!dateTimeString) return { date: 'N/A', time: 'N/A' };
+ const date = new Date(dateTimeString);
+ if (isNaN(date.getTime())) return { date: 'N/A', time: 'N/A' };
+
+ return {
+ date: formatDateShortMonth(date),
+ time: formatDateShortHour(date)
+ };
+ };
+
+ const currentStartTime = watch("startTime");
+ const currentEndTime = watch("endTime");
+
+ const formattedStartTime = formatDisplayDateTime(currentStartTime);
+ const formattedEndTime = formatDisplayDateTime(currentEndTime);
+
+ const startTimeInputRef = useRef(null);
+ const endTimeInputRef = useRef(null);
+
+ const handleStartTimeClick = () => {
+ startTimeInputRef.current?.showPicker();
+ };
+
+ const handleEndTimeClick = () => {
+ endTimeInputRef.current?.showPicker();
+ };
+
+ const {
+ ref: startTimeRegisterRef,
+ ...startTimeRest
+ } = register("startTime");
+
+ const {
+ ref: endTimeRegisterRef,
+ ...endTimeRest
+ } = register("endTime");
+
+ const resize = (el: HTMLTextAreaElement) => {
+ el.style.height = "auto";
+ el.style.height = `${el.scrollHeight}px`;
};
return (
-