[MAJOR][FINAL] MVP

This commit is contained in:
2025-09-27 19:22:32 +05:30
parent 587a71ebf1
commit cc6844d3a3
18 changed files with 529 additions and 74 deletions

View File

@@ -0,0 +1,126 @@
.c-AddTaskForm {
display: flex;
flex-direction: column;
gap: 1.2rem;
background: transparent;
}
.c-AddTaskForm__field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
label {
font-size: 1rem;
font-weight: 500;
color: #fff;
}
input,
textarea,
select {
padding: 0.5rem 0.7rem;
border-radius: 6px;
border: 1px solid #444;
background: #23232b;
color: #fff;
font-size: 1rem;
outline: none;
transition: border 0.2s;
}
input:focus,
textarea:focus,
select:focus {
border-color: #7c3aed;
}
.c-AddTaskForm__error {
color: #ff4d4f;
font-size: 0.95rem;
margin-bottom: 0.5rem;
}
.c-AddTaskForm__submit {
padding: 0.6rem 1.2rem;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.08);
transition: background 0.2s;
}
.c-AddTaskForm__submit:hover {
background: #5b21b6;
}
.c-AddTaskForm__error {
color: #ff4d4f;
font-size: 0.95rem;
margin-top: 0.2rem;
margin-bottom: 0.2rem;
font-weight: 500;
}
.c-AddTaskForm__actions {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.c-AddTaskForm__submit {
flex: 1;
padding: 0.6rem 1.2rem;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 8px rgba(124, 58, 237, 0.08);
transition: background 0.2s, transform 0.1s;
}
.c-AddTaskForm__submit:active {
transform: scale(0.98);
}
.c-AddTaskForm__submit:disabled {
background: #5b21b6;
cursor: not-allowed;
opacity: 0.7;
}
.c-AddTaskForm__cancel {
flex: 1;
padding: 0.6rem 1.2rem;
background: #23232b;
color: #fff;
border: 1px solid #444;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, border 0.2s;
}
.c-AddTaskForm__cancel:hover {
background: #2d2d3a;
border-color: #7c3aed;
}
@media (max-width: 500px) {
.c-AddTaskForm {
gap: 0.8rem;
}
.c-AddTaskForm__actions {
flex-direction: column;
gap: 0.5rem;
}
.c-AddTaskForm__submit,
.c-AddTaskForm__cancel {
font-size: 0.95rem;
padding: 0.5rem 0.8rem;
}
}

View File

@@ -0,0 +1,8 @@
import React from "react"
import AddTaskForm from "./AddTaskForm"
describe("AddTaskForm", () => {
it("renders without error", () => {
})
})

View File

@@ -0,0 +1,129 @@
import React, { useState, useRef, useEffect, useContext } from "react";
import { API_ROUTES } from "@config/constants";
import { Network } from "@utils";
import { KaContext } from "src/contexts/KaContext";
import "./AddTaskForm.component.scss"
interface AddTaskFormProps {
statusOptions: StatusOption[];
onCancel?: () => void;
}
const AddTaskForm = (props: AddTaskFormProps): JSX.Element => {
const { statusOptions, onCancel } = props;
const { setModal, currentBoard, setCurrentBoard } = useContext(KaContext);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState(statusOptions[0]?.value || "");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const titleRef = useRef<HTMLInputElement>(null);
useEffect(() => {
titleRef.current?.focus();
}, []);
useEffect(() => {
// If statusOptions change, reset status to first
setStatus(statusOptions[0]?.value || "");
}, [statusOptions]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) {
setError("Title is required.");
return;
}
setError("");
setSubmitting(true);
try {
const boardId = currentBoard?.id;
if (!boardId) throw new Error("No board selected");
const url = API_ROUTES.TASKS.replace(":boardId", String(boardId));
const payload = {
title: title.trim(),
description: description.trim(),
status,
};
await Network.post(url, payload, {
headers: { Authorization: "Bearer testtoken" },
});
setSubmitting(false);
setTitle("");
setDescription("");
setStatus(statusOptions[0]?.value || "");
setModal({ open: false, title: "", body: null });
setCurrentBoard({ ...currentBoard }); // Trigger refresh in parent
} catch (err: any) {
setSubmitting(false);
setError(err.message || "Failed to add task");
}
};
const handleCancel = () => {
setTitle("");
setDescription("");
setStatus(statusOptions[0]?.value || "");
setError("");
onCancel?.();
setModal({ open: false, title: "", body: null });
};
return (
<form className="c-AddTaskForm" onSubmit={handleSubmit} aria-label="Add New Task">
<div className="c-AddTaskForm__field">
<label htmlFor="task-title">Title<span style={{ color: 'red' }}>*</span></label>
<input
id="task-title"
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
required
ref={titleRef}
aria-describedby={error ? "task-title-error" : undefined}
/>
{error && <div id="task-title-error" className="c-AddTaskForm__error">{error}</div>}
</div>
<div className="c-AddTaskForm__field">
<label htmlFor="task-desc">Description</label>
<textarea
id="task-desc"
value={description}
onChange={e => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="c-AddTaskForm__field">
<label htmlFor="task-status">Status</label>
<select
id="task-status"
value={status}
onChange={e => setStatus(e.target.value)}
>
{statusOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div className="c-AddTaskForm__actions">
<button
type="submit"
className="c-AddTaskForm__submit"
disabled={submitting}
aria-busy={submitting}
>
{submitting ? "Adding..." : "Add Task"}
</button>
<button
type="button"
className="c-AddTaskForm__cancel"
onClick={handleCancel}
>
Cancel
</button>
</div>
</form>
);
};
export default AddTaskForm;

View File

@@ -0,0 +1,3 @@
import AddTaskForm from "./AddTaskForm.jsx"
export default AddTaskForm

View File

@@ -1,3 +1,58 @@
// Theme toggle switch styles
.c-KaDrawer__sidebar-footer {
background: var(--ka-bg-main);
border-radius: 0.7rem;
}
.c-KaDrawer__theme-toggle-switch {
display: flex;
align-items: center;
padding: 0.5rem 1.2rem;
gap: 1rem;
margin: 0 auto;
}
.c-KaDrawer__theme-label {
font-size: 1rem;
font-weight: 600;
color: var(--ka-color-font-disabled);
transition: color 0.2s;
}
.c-KaDrawer__theme-label.active {
color: var(--ka-color-primary);
}
.c-KaDrawer__theme-toggle-btn {
background: var(--ka-color-primary-faded);
border: none;
border-radius: 1.2rem;
width: 3.2rem;
height: 1.7rem;
position: relative;
display: flex;
align-items: center;
cursor: pointer;
padding: 0;
transition: background 0.2s;
outline: none;
}
.c-KaDrawer__theme-toggle-btn.light {
background: var(--ka-color-primary-faded);
}
.c-KaDrawer__theme-toggle-btn.dark {
background: var(--ka-color-primary);
}
.c-KaDrawer__theme-toggle-knob {
position: absolute;
top: 0.15rem;
left: 0.15rem;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.08);
transition: left 0.2s;
}
.c-KaDrawer__theme-toggle-btn.dark .c-KaDrawer__theme-toggle-knob {
left: 1.65rem;
}
.c-KaDrawer {
flex-basis: 20%;
max-width: 35rem;

View File

@@ -7,53 +7,34 @@ import "./KaDrawer.component.scss"
const USER_ID = 1
const KaDrawer = (): JSX.Element => {
const [loading, setLoading] = useState(true)
const [boards, setBoards] = useState<any[]>([])
const [newBoardTitle, setNewBoardTitle] = useState<string>("")
const [error, setError] = useState<string | null>(null)
const [initBoardAdd, setInitBoardAdd] = useState(false)
const { theme, setTheme, currentBoard, setCurrentBoard } = useContext(KaContext)
const createBoardTextRef = useRef<HTMLInputElement>(null)
interface KaDrawerProps {
boards: any[];
currentBoard: any;
setCurrentBoard: (board: any) => void;
loading: boolean;
error: string | null;
createBoard: (title: string) => void;
}
const KaDrawer = ({ boards, loading, error, createBoard }: KaDrawerProps): JSX.Element => {
const [newBoardTitle, setNewBoardTitle] = useState<string>("");
const [initBoardAdd, setInitBoardAdd] = useState(false);
const { theme, setTheme, currentBoard, setCurrentBoard } = useContext(KaContext);
const createBoardTextRef = useRef<HTMLInputElement>(null);
useEffect(() => {
Network.get(API_ROUTES.BOARDS.replace(":userId", String(USER_ID)), {
headers: { Authorization: "Bearer testtoken" },
})
.then((data) => {
setBoards(data.boards || [])
setCurrentBoard(data.boards?.[0])
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [])
createBoardTextRef.current?.focus();
}, [initBoardAdd]);
useEffect(() => {
createBoardTextRef.current?.focus()
}, [initBoardAdd])
const createBoard = () => {
const handleCreateBoard = () => {
if (!newBoardTitle.trim()) {
alert("Board title cannot be empty");
return;
}
Network.post(API_ROUTES.BOARDS.replace(":userId", String(USER_ID)), {
name: newBoardTitle.trim(),
}, {
headers: { Authorization: "Bearer testtoken" },
})
.then((data) => {
setBoards((prevBoards) => [...prevBoards, data.board]);
setNewBoardTitle("");
setInitBoardAdd(false);
})
.catch((err) => {
alert("Error creating board: " + err.message);
});
}
createBoard(newBoardTitle);
setNewBoardTitle("");
setInitBoardAdd(false);
};
return <aside className="c-KaDrawer d-flex flex-column">
<div className="c-KaDrawer__logo d-flex align-items-center p-4">
@@ -85,14 +66,37 @@ const KaDrawer = (): JSX.Element => {
</>
<div className="c-KaDrawer__create-board mt-3">
{initBoardAdd ? <div className="add-board-container d-flex mx-3">
<input ref={createBoardTextRef} onChange={e => setNewBoardTitle(e.target.value)} onBlur={() => setInitBoardAdd(false)} className="c-KaDrawer__add-board-text me-3" />
<button className="c-KaDrawer__add-board-button" onMouseDown={createBoard}>Create</button>
<input
ref={createBoardTextRef}
onChange={e => setNewBoardTitle(e.target.value)}
onBlur={() => setInitBoardAdd(false)}
onKeyDown={e => {
if (e.key === "Enter") {
e.preventDefault();
handleCreateBoard();
}
}}
className="c-KaDrawer__add-board-text me-3"
/>
<button className="c-KaDrawer__add-board-button" onMouseDown={handleCreateBoard}>Create</button>
</div> : <li className="c-KaDrawer__board cursor-pointer create ps-5 py-2" onClick={() => setInitBoardAdd(true)}>+ Create New Board</li>}
</div>
</ul>
</div>
<div className="c-KaDrawer__sidebar-footer mt-auto">
<button className="c-KaDrawer__theme-toggle btn btn-outline-secondary" aria-label="Toggle theme" onClick={() => setTheme(theme === KaTheme.DARK ? KaTheme.LIGHT : KaTheme.DARK)} />
<div className="d-flex w-100 px-5">
<div className="c-KaDrawer__sidebar-footer mt-auto mb-4">
<div className="c-KaDrawer__theme-toggle-switch">
<span className={`c-KaDrawer__theme-label${theme === KaTheme.LIGHT ? ' active' : ''}`}>LIGHT</span>
<button
className={`c-KaDrawer__theme-toggle-btn${theme === KaTheme.DARK ? ' dark' : ' light'}`}
aria-label="Toggle theme"
onClick={() => setTheme(theme === KaTheme.DARK ? KaTheme.LIGHT : KaTheme.DARK)}
>
<span className="c-KaDrawer__theme-toggle-knob" />
</button>
<span className={`c-KaDrawer__theme-label${theme === KaTheme.DARK ? ' active' : ''}`}>DARK</span>
</div>
</div>
</div>
</aside>
}

View File

@@ -1,4 +1,24 @@
.c-KaHeader {
.menuBtn {
background: var(--ka-color-primary);
color: #fff;
border: none;
border-radius: 1rem;
font-size: 1.7rem;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, box-shadow 0.2s;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.04);
cursor: pointer;
outline: none;
padding: 0.25rem 0.75rem;
}
.menuBtn:hover,
.menuBtn:focus {
background: var(--ka-color-primary-faded);
box-shadow: 0 2px 8px 0 rgba(99, 95, 199, 0.12);
}
background: var(--ka-bg-sidebar);
color: var(--ka-color-font);
display: flex;

View File

@@ -1,13 +1,37 @@
import React from "react"
import React, { useContext } from "react"
import { KaContext } from "src/contexts/KaContext";
import AddTaskForm from "@components/AddTaskForm";
import "./KaHeader.component.scss"
interface KaHeaderProps { }
interface KaHeaderProps {
onMenuClick?: () => void;
menuOpen?: boolean;
statusOptions: StatusOption[];
}
const KaHeader = (props: KaHeaderProps): JSX.Element => {
const { currentBoard, setModal } = useContext(KaContext);
const { onMenuClick, menuOpen, statusOptions } = props;
const [isMobile, setIsMobile] = React.useState(window.innerWidth < 768);
React.useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return (
<header className="c-KaHeader d-flex justify-content-between align-items-center px-3 py-4">
<span className="boardTitle">Platform Launch</span>
<button className="addTaskBtn py-2 px-3 cursor-pointer">+ Add New Task</button>
<header className="c-KaHeader d-flex justify-content-between align-items-center px-3 py-4 mw-100">
{onMenuClick && (
<button className="menuBtn me-3 p-2" onClick={onMenuClick} aria-label={menuOpen ? 'Close menu' : 'Open menu'}>
{menuOpen ? <span>&#10005;</span> : <span>&#9776;</span>}
</button>
)}
<span className="boardTitle">{currentBoard?.name || "Select a board"}</span>
<button className="addTaskBtn py-2 px-3 cursor-pointer" onClick={() => setModal({
open: true,
title: "Add New Task",
body: <AddTaskForm statusOptions={statusOptions} />,
})}>+{isMobile ? "" : " Add New Task"}</button>
</header>
);
}

View File

@@ -1,2 +1,3 @@
.c-KaMain {
overflow-y: hidden !important;
}

View File

@@ -21,7 +21,7 @@ const KaMain = (): JSX.Element => {
}
}, [currentBoard]);
return <main className="c-KaMain d-flex p-3 flex-grow-1">
return <main className="c-KaMain d-flex p-3 flex-grow-1 mw-10 overflow-auto">
{taskGroups?.map(taskGroup => <TaskList taskGroup={taskGroup} />)}
{/* Add a task list to add new list */}
<TaskList type="add-new" taskGroup={{ groupId: "Add New List", tasks: [] }} />

View File

@@ -1,5 +1,6 @@
.c-TaskList {
width: 20rem;
flex: 0 0 20rem;
.list-color {
width: 0.75rem;
height: 0.75rem;
@@ -11,6 +12,7 @@
background-color: var(--ka-bg-sidebar-darker);
border-radius: 0.5rem;
cursor: pointer;
margin-top: 1.75rem;
&:hover {
.add-new-list-btn {
background-color: var(--ka-bg-hover);

View File

@@ -10,8 +10,8 @@ const TaskList = (props: TaskListProps): JSX.Element => {
const { taskGroup, type } = props;
// Generate a random pastel color for the list-color indicator
const randomColor = `hsl(${Math.floor(Math.random() * 360)}, 70%, 70%)`;
return <div className="c-TaskList h-100 mx-2">
{type === "add-new" ? <div className="c-TaskList__new flex-center h-100">
return <div className="c-TaskList h-100 mx-2 d-flex flex-column">
{type === "add-new" ? <div className="c-TaskList__new flex-center h-100 flex-grow-1">
<span className="add-new-list-btn py-2 px-3 cursor-pointer">+ Add New List</span>
</div>
:

View File

@@ -5,6 +5,7 @@ import KaDrawer from "./KaDrawer"
import TaskList from "./TaskList"
import TaskItem from "./TaskItem"
import KaModal from "./KaModal"
import AddTaskForm from "./AddTaskForm"
export {
/* PLOP_INJECT_EXPORT */
@@ -14,4 +15,5 @@ export {
TaskList,
TaskItem,
KaModal,
AddTaskForm,
}

View File

@@ -6,6 +6,8 @@ export const API_ROUTES = {
BOARD: `${API_BASE_URL}/boards/:userId/:boardId`,
TASKS: `${API_BASE_URL}/tasks/:boardId`,
TASK: `${API_BASE_URL}/tasks/:boardId/:taskId`,
TASKLISTS: `${API_BASE_URL}/task-lists`,
TASKLIST: `${API_BASE_URL}/task-lists/:taskId`,
USERS: `${API_BASE_URL}/users`,
USER: `${API_BASE_URL}/users/:id`,
}

View File

@@ -2,17 +2,14 @@ import React from "react"
export type KaTheme = "ka-dark" | "ka-light"
export interface KaContextType {
theme: KaTheme
setTheme: (theme: KaTheme) => void
currentBoard?: Board
setCurrentBoard: (board: Board) => void
modal: {
open: boolean
body?: React.ReactNode
title?: string
[key: string]: any
}
setModal: (modal: Partial<KaContextType['modal']>) => void
theme: KaTheme;
setTheme: (theme: KaTheme) => void;
currentBoard?: Board;
setCurrentBoard: (board: Board) => void;
modal: KaModalState;
setModal: (modal: Partial<KaModalState>) => void;
mobileDrawerOpen: boolean;
setMobileDrawerOpen: (open: boolean) => void;
}
export const KaContext = React.createContext<KaContextType>({
@@ -20,5 +17,7 @@ export const KaContext = React.createContext<KaContextType>({
setTheme: () => { },
setCurrentBoard: (board: Board) => { },
modal: { open: false },
setModal: () => { }
setModal: () => { },
mobileDrawerOpen: false,
setMobileDrawerOpen: () => { }
})

View File

@@ -9,6 +9,7 @@ export const KaProvider: React.FC<KaProviderProps> = ({ children }) => {
const [theme, setTheme] = useState<KaTheme>("ka-dark")
const [currentBoard, setCurrentBoard] = useState<Board>()
const [modal, setModalState] = useState<{ open: boolean; body?: React.ReactNode; title?: string;[key: string]: any }>({ open: false })
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false)
useEffect(() => {
document.documentElement.setAttribute("ar-theme", theme)
@@ -20,7 +21,7 @@ export const KaProvider: React.FC<KaProviderProps> = ({ children }) => {
}
return (
<KaContext.Provider value={{ theme, setTheme, currentBoard, setCurrentBoard, modal, setModal }}>
<KaContext.Provider value={{ theme, setTheme, currentBoard, setCurrentBoard, modal, setModal, mobileDrawerOpen, setMobileDrawerOpen }}>
{children}
</KaContext.Provider>
)

View File

@@ -1,19 +1,86 @@
import React from "react"
import { KaDrawer, KaHeader, KaMain, KaModal } from "@components"
import React from "react";
import { API_ROUTES } from "@config/constants";
import { Network } from "@utils";
import { KaContext } from "src/contexts/KaContext";
import { KaDrawer, KaHeader, KaMain, KaModal } from "@components";
const USER_ID = 1;
const Home: React.FC = () => {
const { mobileDrawerOpen, setMobileDrawerOpen, setCurrentBoard } = React.useContext(KaContext);
const [isMobile, setIsMobile] = React.useState(window.innerWidth < 768);
const [boards, setBoards] = React.useState<any[]>([]);
const [currentBoard, setCurrentBoardLocal] = React.useState<any>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
React.useEffect(() => {
Network.get(API_ROUTES.BOARDS.replace(":userId", String(USER_ID)), {
headers: { Authorization: "Bearer testtoken" },
})
.then((data) => {
setBoards(data.boards || []);
setCurrentBoardLocal(data.boards?.[0] || null);
setCurrentBoard(data.boards?.[0] || null);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
}, [setCurrentBoard]);
// Example: statusOptions from currentBoard
const statusOptions: StatusOption[] = currentBoard?.statuses?.map((s: any) => ({ value: s.value, label: s.label })) || [
{ value: "todo", label: "To Do" },
{ value: "in_progress", label: "In Progress" },
{ value: "done", label: "Done" },
];
const handleCreateBoard = async (title: string) => {
if (!title.trim()) return;
try {
const res = await Network.post(API_ROUTES.BOARDS.replace(":userId", String(USER_ID)), { name: title.trim() }, {
headers: { Authorization: "Bearer testtoken" },
});
setBoards(prev => [...prev, res.board]);
setCurrentBoardLocal(res.board);
setCurrentBoard(res.board);
} catch (err) {
alert("Error creating board: " + ((err as any).message || err));
}
}
return (
<div className="c-Home d-flex" style={{ height: '100vh' }}>
<KaDrawer />
<div className="c-Home__main d-flex flex-column flex-grow-1">
<KaHeader />
<div className="c-Home d-flex mw-100" style={{ height: '100vh', overflowY: 'hidden' }}>
{(!isMobile || mobileDrawerOpen) && <KaDrawer
boards={boards}
currentBoard={currentBoard}
setCurrentBoard={setCurrentBoardLocal}
loading={loading}
error={error}
createBoard={handleCreateBoard}
/>}
<div className="c-Home__main d-flex flex-column flex-grow-1 mw-100">
<KaHeader
onMenuClick={isMobile ? () => setMobileDrawerOpen(!mobileDrawerOpen) : undefined}
menuOpen={mobileDrawerOpen}
statusOptions={statusOptions}
/>
<KaMain />
</div>
<KaModal />
</div>
)
}
);
};
export default Home
export default Home;

12
src/types/entity.d.ts vendored
View File

@@ -1,3 +1,10 @@
interface KaModalState {
open: boolean
body?: React.ReactNode
title?: string
[key: string]: any
}
interface Board {
id: number
userId: number
@@ -21,3 +28,8 @@ interface TaskGroup {
groupId: string
tasks: Task[]
}
type StatusOption = {
value: string
label: string
}