diff --git a/client/src/components/task_form.tsx b/client/src/components/task_form.tsx index c77b170..5034c34 100644 --- a/client/src/components/task_form.tsx +++ b/client/src/components/task_form.tsx @@ -1,4 +1,4 @@ -import { useEffect,useState } from "react"; +import { useEffect, useState } from "react"; import { TimeInput } from "@/components/time_input"; import { TopicInput } from "@/components/topic_input"; diff --git a/client/src/components/ui/countdown.tsx b/client/src/components/ui/countdown.tsx new file mode 100644 index 0000000..217b02f --- /dev/null +++ b/client/src/components/ui/countdown.tsx @@ -0,0 +1,59 @@ +import React, { ChangeEvent, useEffect,useState } from "react"; + +import Timer from "./timer"; + +interface Timed { + taskTime: string; +} + +// in future use task data as input + +const Countdown = ({ taskTime }: Timed) => { + const [timeRemaining, setTimeRemaining] = useState(0); + const [completed, setCompleted] = useState(false); + + const completeTask = (event : ChangeEvent) => { + // send post request to update db + setCompleted(event.target.checked); + } + + useEffect(() => { + if (taskTime) { + const countdownInterval = setInterval(() => { + const [eventHours, eventMins, eventSec] = taskTime + .split(":") + .map(Number); + const d = new Date(); + let remainingTime = + (eventHours - d.getHours()) * 60 * 60 + + (eventMins - d.getMinutes()) * 60 + + (eventSec - d.getSeconds()); + + if (remainingTime <= 0) { + remainingTime = 0; + clearInterval(countdownInterval); + } + + setTimeRemaining(remainingTime); + }, 1000); + + return () => clearInterval(countdownInterval); + } + }, [taskTime, timeRemaining]); + + return ( + <> + {timeRemaining > 0 ? ( +
+

TIME LEFT

+
{Timer(timeRemaining)}
+ +
+ ) : ( +
+ )} + + ); +}; + +export default Countdown; diff --git a/client/src/components/ui/currentTask.tsx b/client/src/components/ui/currentTask.tsx new file mode 100644 index 0000000..8cb03a7 --- /dev/null +++ b/client/src/components/ui/currentTask.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from "react"; + +import Countdown from "./countdown"; +import TimeDisplay from "./timeDisplay"; + +interface Task { + id: number; + start_time: string; + end_time: string; +} + +interface Tasks { + tasks: Task[]; +} + +const CurrentTask = ({ tasks }: Tasks) => { + const [currentTasks, setCurrentTasks] = useState([]); + const [currentTask, setCurrentTask] = useState( + { + id : 1, + start_time : "00:00:00", + end_time : "00:00:00" + } + ); + + useEffect(() => { + function getCurrentTask() { + setCurrentTasks([]); + tasks.forEach((task) => { + const [startHours, startMins, startSec] = task.start_time + .split(":") + .map(Number); + const [endHours, endMins, endSec] = task.end_time + .split(":") + .map(Number); + + const d = new Date(); + const start_time = startHours * 60 * 60 + startMins * 60 + startSec; + const end_time = endHours * 60 * 60 + endMins * 60 + endSec; + const cur_time = + d.getHours() * 60 * 60 + d.getMinutes() * 60 + d.getSeconds(); + console.log( + startHours, + " ", + endHours, + " ", + endHours, + " ", + endMins, + " ", + d.getHours(), + " ", + d.getMinutes(), + ); + console.log(start_time, " ", end_time, " ", cur_time); + + if (start_time <= cur_time && end_time >= cur_time) { + setCurrentTasks((prevTasks) => [...prevTasks, task]); + } + }); + setCurrentTask(currentTasks[0]); + } + + getCurrentTask(); + }, [tasks]); + + const updateTask = (status : string) => { + if (status == "finished") { + setCurrentTask(currentTasks[0]); + } + } + + return ( + <> +
    + {currentTasks.length <= 0 ? ( + <> +
    +

    NO CURRENT TASKS

    +
    MAKE ONE?
    +
    + + ) : ( + <> + {currentTasks.map((task: Task) => ( +
  • + +
  • + ))} + + )} +
+
+ {currentTasks.length > 0 ? ( + + ):( + <> + )} +
+ + ); +}; + +export default CurrentTask; diff --git a/client/src/components/ui/logout.tsx b/client/src/components/ui/logout.tsx new file mode 100644 index 0000000..22d787b --- /dev/null +++ b/client/src/components/ui/logout.tsx @@ -0,0 +1,22 @@ +"use client"; +import { useRouter } from "next/navigation"; + +export function LogoutButton() { + const router = useRouter(); + function logout() { + localStorage.removeItem("access"); + localStorage.removeItem("refresh"); + localStorage.removeItem("user_id"); + + router.push("/login"); + } + + return ( + + ); +} diff --git a/client/src/components/ui/navbar.tsx b/client/src/components/ui/navbar.tsx new file mode 100644 index 0000000..8706edf --- /dev/null +++ b/client/src/components/ui/navbar.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { LogoutButton } from "@/components/ui/logout"; + +export function Navbar() { + return ( + + ); +} diff --git a/client/src/components/ui/taskDisplay.tsx b/client/src/components/ui/taskDisplay.tsx new file mode 100644 index 0000000..4ccc74e --- /dev/null +++ b/client/src/components/ui/taskDisplay.tsx @@ -0,0 +1,12 @@ +import React from "react"; + +export default function TaskDisplay(task_name: string) { + return ( +
+

Current Focus

+
+ {task_name} +
+
+ ); +} diff --git a/client/src/components/ui/timeDisplay.tsx b/client/src/components/ui/timeDisplay.tsx new file mode 100644 index 0000000..965ffd9 --- /dev/null +++ b/client/src/components/ui/timeDisplay.tsx @@ -0,0 +1,61 @@ +import React, { useEffect, useState } from "react"; + +import Timer from "./timer"; + +interface TimeDisplayProps { + statusSignal: (data: string) => void; + time: string; + current: boolean; +} + +export default function TimeDisplay({ + statusSignal, + time, + current, +}: TimeDisplayProps) { + const [timeRemaining, setTimeRemaining] = useState(0); + + useEffect(() => { + const getTimeRemaining = setInterval(() => { + const [Hours, Mins, Sec] = time.split(":").map(Number); + + const d = new Date(); + const time_serial = Hours * 60 * 60 + Mins * 60 + Sec; + const cur_time = + d.getHours() * 60 * 60 + d.getMinutes() * 60 + d.getSeconds(); + + let remaining_time = 0; + + remaining_time = time_serial - cur_time; + if (remaining_time <= 1) { + clearInterval(getTimeRemaining); + } + + setTimeRemaining(remaining_time); + }, 1000); + + return () => clearInterval(getTimeRemaining); + }, [time, timeRemaining]); + + useEffect(() => { + if (timeRemaining <= 1) { + statusSignal("finished"); + } + }, [timeRemaining, statusSignal]); + + return ( +
+ {current ? ( + <> +

Timer

+ + ) : ( + <> +

Next Task

+ + )} +
{Timer(timeRemaining)}
+
+
+ ); +} diff --git a/client/src/components/ui/timer.tsx b/client/src/components/ui/timer.tsx new file mode 100644 index 0000000..353ac7a --- /dev/null +++ b/client/src/components/ui/timer.tsx @@ -0,0 +1,33 @@ +const Timer = (timeRemaining: number) => { + const formatTime = (time: number) => { + const seconds = Math.floor(time % 60); + const minutes = Math.floor((time / 60) % 60); + const hours = Math.floor((time / (60 * 60)) % 24); + + return ( +
+
+ {hours > 0 ? ( + <> + {hours.toString()} + : + + ) : ( + <> + )} + {minutes.toString().padStart(2, "0")} + : + {seconds.toString().padStart(2, "0")} +
+
+ ); + }; + + return ( +
+ {formatTime(timeRemaining)} +
+ ); +}; + +export default Timer; diff --git a/client/src/pages/[id]/tasks.tsx b/client/src/pages/[id]/tasks.tsx index fc8f329..3da6179 100644 --- a/client/src/pages/[id]/tasks.tsx +++ b/client/src/pages/[id]/tasks.tsx @@ -29,21 +29,44 @@ interface Item { export default function TasksPage() { const router = useRouter(); - const { id } = router.query; + const [userId, setUserId] = useState(null); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [availableTopics, setAvailableTopics] = useState([]); useEffect(() => { - if (!id) return; - - async function fetchTasks() { + async function fetchData() { try { - // Remove the query bit when authentication is added. - const response = await fetch( - `http://localhost:8000/api/planner/tasks/?user_id=${id}`, + const token = localStorage.getItem("access"); + if (!token) { + router.push("/login"); //redirect to login if unauthorised + return; + } + const auth = await fetch( + "http://localhost:8000/api/planner/protected/", + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, ); - const data = await response.json(); + if (!auth.ok) { + router.push("/login"); //redirect to login if unauthorised + return; + } + + const user = await auth.json(); + setUserId(user.user_id); + const tasksFetch = await fetch( + `http://localhost:8000/api/planner/tasks/`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + const data = await tasksFetch.json(); setItems(data); } catch (err) { console.error("Failed to load tasks:", err); @@ -51,8 +74,8 @@ export default function TasksPage() { setLoading(false); } } - fetchTasks(); - }, [id]); + fetchData(); + }, [router]); useEffect(() => { fetch("http://localhost:8000/api/planner/topic/") @@ -116,7 +139,7 @@ export default function TasksPage() {

Add Task

- +
); diff --git a/client/src/pages/_app.tsx b/client/src/pages/_app.tsx index 628e9f2..69bedab 100644 --- a/client/src/pages/_app.tsx +++ b/client/src/pages/_app.tsx @@ -3,13 +3,21 @@ import "@/styles/globals.css"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import type { AppProps } from "next/app"; +import { useRouter } from "next/router"; + +import { Navbar } from "@/components/ui/navbar"; const queryClient = new QueryClient(); export default function App({ Component, pageProps }: AppProps) { + const router = useRouter(); + const noNavbarRoutes = ["/login", "/register", "/"]; + const showNavbar = !noNavbarRoutes.includes(router.pathname); + return ( + {showNavbar && } ); diff --git a/client/src/pages/index.tsx b/client/src/pages/index.tsx index df27d53..cbb2744 100644 --- a/client/src/pages/index.tsx +++ b/client/src/pages/index.tsx @@ -1,36 +1,18 @@ -import { Inter as FontSans } from "next/font/google"; -import { useState } from "react"; - -import { usePings } from "@/hooks/pings"; -import { cn } from "@/lib/utils"; - -import { Button } from "../components/ui/button"; - -const fontSans = FontSans({ - subsets: ["latin"], - variable: "--font-sans", -}); - -export default function Home() { - const [clicked, setClicked] = useState(false); - const { data, isLoading } = usePings({ - enabled: clicked, - }); +import { useRouter } from "next/navigation"; +export default function Landing() { + const router = useRouter(); + function ToSignIn() { + router.push("/register"); + } return ( -
-

wTest title

- -

- Response from server: {data as string} -

-
+
+ +
); } diff --git a/client/src/pages/login.tsx b/client/src/pages/login.tsx new file mode 100644 index 0000000..48b2c77 --- /dev/null +++ b/client/src/pages/login.tsx @@ -0,0 +1,119 @@ +"use client"; +import { useRouter } from "next/navigation"; + +export default function Login() { + const router = useRouter(); + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + const formData = new FormData(e.currentTarget); + + const res = await fetch("http://127.0.0.1:8000/api/auth/login/", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: formData.get("username"), + email: formData.get("email"), + password: formData.get("password"), + }), + }); + + const data = await res.json(); + if (!res.ok) { + alert("Invalid login"); + return; + } + + localStorage.setItem("access", data.access); + localStorage.setItem("refresh", data.refresh); + + const token = localStorage.getItem("access"); + + const userFetch = await fetch( + "http://127.0.0.1:8000/api/planner/protected", + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + const user = await userFetch.json(); + router.push(`/${user.user_id}/tasks`); + } + + return ( +
+
+

+ Log in to your account +

+
+ +
+
+ +
+ +
+ + +
+ +
+ +
+
+ + +
+
+ +
+
+ +
+ +
+
+
+
+ ); +} diff --git a/client/src/pages/register.tsx b/client/src/pages/register.tsx new file mode 100644 index 0000000..14ccd81 --- /dev/null +++ b/client/src/pages/register.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; + +export default function Register() { + const router = useRouter(); + const [error, setError] = useState(""); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + + const res = await fetch("http://127.0.0.1:8000/api/planner/register/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + username: formData.get("username"), + email: formData.get("email"), + password: formData.get("password"), + }), + }); + + const data = await res.json(); + if (!res.ok) { + setError(data.error || "Registration failed"); + return; + } + router.push("/login"); + } + + return ( +
+
+

+ Create an Account +

+
+ +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {error &&

{error}

} +
+
+
+
+ ); +} diff --git a/server/server/settings.py b/server/server/settings.py index 1e05ddf..b9edee7 100644 --- a/server/server/settings.py +++ b/server/server/settings.py @@ -46,6 +46,7 @@ "django.contrib.staticfiles", # "django_extensions", "rest_framework", + "rest_framework_simplejwt", "corsheaders", "healthcheck", "user_profile", @@ -53,6 +54,7 @@ ] MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", @@ -60,7 +62,6 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", - "corsheaders.middleware.CorsMiddleware", ] diff --git a/server/server/urls.py b/server/server/urls.py index d9eaca5..801886e 100644 --- a/server/server/urls.py +++ b/server/server/urls.py @@ -28,4 +28,7 @@ path("api/healthcheck/", include("healthcheck.urls")), path("api/user/", include("user_profile.urls")), path("api/planner/", include("task_planner.urls")), + path('', include("django.contrib.auth.urls")), + path("api/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/auth/refresh/", TokenObtainPairView.as_view(), name="token_refresh"), ] diff --git a/server/task_planner/urls.py b/server/task_planner/urls.py index 694537e..d0e9522 100644 --- a/server/task_planner/urls.py +++ b/server/task_planner/urls.py @@ -1,6 +1,8 @@ from django.urls import path from . import views +from .views import ProtectedView +from .views import RegisterView app_name = "task_planner" urlpatterns = [ @@ -9,4 +11,6 @@ path("tasks/", views.TaskViewSet.as_view({'get': 'list', 'post': 'create'}), name="task-viewset"), path("tasks//", views.TaskViewSet.as_view({'get': 'retrieve', 'put': 'update', 'delete': 'destroy'}), name="task-detail"), path("tasks//toggle_complete/", views.TaskViewSet.as_view({'patch': 'toggle_complete'}), name="task-toggle-complete"), + path("protected/", ProtectedView.as_view()), + path('register/', RegisterView.as_view()), ] diff --git a/server/task_planner/views.py b/server/task_planner/views.py index 2fff80d..3b32ef7 100644 --- a/server/task_planner/views.py +++ b/server/task_planner/views.py @@ -7,13 +7,52 @@ from rest_framework.viewsets import ModelViewSet from rest_framework import status from rest_framework.decorators import action -#from rest_framework.permissions import IsAuthenticated +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.authentication import JWTAuthentication +from django.contrib.auth.models import User from .models import Task, Topic, Time from .serializers import TaskReadSerializer, TaskWriteSerializer, TopicReadSerializer, TimeReadSerializer, TaskCompleteSerializer +class RegisterView(APIView): + def post(self, request): + username = request.data.get('username') + email = request.data.get('email') + password = request.data.get('password') -# Create your views here. + if not email or not password or not username: + return Response( + {"error": "Missing fields required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + if User.objects.filter(email=email).exists(): + return Response( + {"error": "Email already exists"}, + status=status.HTTP_400_BAD_REQUEST + ) + + if User.objects.filter(username=username).exists(): + return Response( + {"error": "Username already exists"}, + status=status.HTTP_400_BAD_REQUEST + ) + + user = User.objects.create_user(username=username,email=email, password=password) + return Response( + {"message": "User created successfully"}, + status=status.HTTP_201_CREATED + ) + +class ProtectedView(APIView): + permission_classes = [IsAuthenticated] + authentication_classes = [JWTAuthentication] + def get(self, request): + return Response({ + "user_id": request.user.id, + "username": request.user.username, + "email": request.user.email, + }) class TopicList(APIView): def get(self, request): @@ -29,8 +68,8 @@ def get(self, request): return Response(serializer.data) class TaskViewSet(ModelViewSet): - - #permission_classes = [IsAuthenticated] # Uncomment when authentication is added. + authentication_classes = [JWTAuthentication] + permission_classes = [IsAuthenticated] def get_serializer_class(self): if self.action in ['list', 'retrieve']: @@ -38,13 +77,7 @@ def get_serializer_class(self): return TaskWriteSerializer def get_queryset(self): - # -- Temporary Code -- - user_id = self.request.query_params.get('user_id') - if user_id: - return Task.objects.filter(user_id=user_id) - # -- End Temporary Code -- - - #return Task.objects.filter(user=self.request.user) # Will restrict tasks to a given user when authentication is added. + return Task.objects.filter(user=self.request.user) # Will restrict tasks to a given user when authentication is added. return Task.objects.all() def perform_create(self, serializer):