react-frontend basic implementation
This commit is contained in:
119
app/page.tsx
119
app/page.tsx
@@ -1,65 +1,62 @@
|
|||||||
import Image from "next/image";
|
// Next.js Server Component (no 'use client' needed here)
|
||||||
|
// This page runs on the server, reads from SQLite directly, and renders HTML
|
||||||
|
|
||||||
|
import { getTasks } from '@/src/lib/tasks';
|
||||||
|
import TaskForm from '@/src/lib/components/TaskForm';
|
||||||
|
import TaskCard from '@/src/lib/components/TaskCard';
|
||||||
|
import TaskFilterToolbar from '@/src/lib/components/TaskFilterToolbar';
|
||||||
|
|
||||||
|
// Next.js 15 page props for async searchParams
|
||||||
|
interface PageProps {
|
||||||
|
searchParams: Promise<{ sortBy?: 'due_date' | 'topic' | 'status' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomePage({ searchParams }: PageProps) {
|
||||||
|
// Await search parameters from the URL (e.g., ?sortBy=topic)
|
||||||
|
const params = await searchParams;
|
||||||
|
const sortBy = params.sortBy || 'due_date';
|
||||||
|
|
||||||
|
// Direct SQLite query on the server to get active tasks sorted
|
||||||
|
const tasks = getTasks(sortBy);
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<main className="max-w-4xl mx-auto py-10 px-4 space-y-8 min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
{/* Header */}
|
||||||
<Image
|
<header className="space-y-1">
|
||||||
className="dark:invert"
|
<h1 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100">
|
||||||
src="/next.svg"
|
📝 Todo App
|
||||||
alt="Next.js logo"
|
</h1>
|
||||||
width={100}
|
<p className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
height={20}
|
Local-first SQLite task manager with dynamic overdue detection.
|
||||||
priority
|
</p>
|
||||||
/>
|
</header>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
{/* Task Creation Form */}
|
||||||
To get started, edit the page.tsx file.
|
<TaskForm />
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
{/* Task Sorting Toolbar */}
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
<TaskFilterToolbar />
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
{/* List of Tasks */}
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
<section className="space-y-4">
|
||||||
>
|
<h2 className="text-lg font-semibold text-zinc-800 dark:text-zinc-200">
|
||||||
Templates
|
Tasks ({tasks.length})
|
||||||
</a>{" "}
|
</h2>
|
||||||
or the{" "}
|
|
||||||
<a
|
{/* Empty state if no tasks exist */}
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
{tasks.length === 0 ? (
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
<div className="text-center py-12 border border-dashed rounded-xl border-zinc-300 dark:border-zinc-800">
|
||||||
>
|
<p className="text-zinc-500 dark:text-zinc-400">No active tasks found. Create one above!</p>
|
||||||
Learning
|
</div>
|
||||||
</a>{" "}
|
) : (
|
||||||
center.
|
/* Render grid of task cards */
|
||||||
</p>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
</div>
|
{tasks.map(task => (
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<TaskCard key={task.id} task={task} />
|
||||||
<a
|
))}
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
</div>
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
)}
|
||||||
target="_blank"
|
</section>
|
||||||
rel="noopener noreferrer"
|
</main>
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export async function createTaskAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
createTask({ title, description, due_date, topic, status });
|
createTask({ title, description, due_date, topic, status });
|
||||||
revalidatePath('/tasks');
|
revalidatePath('/tasks');
|
||||||
|
revalidatePath('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
export async function updateTaskAction(id: number, formData: FormData) {
|
export async function updateTaskAction(id: number, formData: FormData) {
|
||||||
@@ -29,11 +30,13 @@ export async function updateTaskAction(id: number, formData: FormData) {
|
|||||||
}
|
}
|
||||||
updateTask(id, { title, description, due_date, topic, status });
|
updateTask(id, { title, description, due_date, topic, status });
|
||||||
revalidatePath('/tasks');
|
revalidatePath('/tasks');
|
||||||
|
revalidatePath('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
export async function archiveTaskAction(id: number) {
|
export async function archiveTaskAction(id: number) {
|
||||||
archiveTask(id);
|
archiveTask(id);
|
||||||
revalidatePath('/tasks');
|
revalidatePath('/tasks');
|
||||||
|
revalidatePath('/');
|
||||||
revalidatePath('/archived');
|
revalidatePath('/archived');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
108
src/lib/components/TaskCard.tsx
Normal file
108
src/lib/components/TaskCard.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
// Tells Next.js that this component runs in the browser (needed for click handlers & useState)
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
// Import React hook to keep track of state (like whether the edit modal is open)
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
// Import the TypeScript definition of a Task object from our types file
|
||||||
|
import { Task } from '../types';
|
||||||
|
|
||||||
|
// Import the Server Action function to archive a task in SQLite
|
||||||
|
import { archiveTaskAction } from '../actions';
|
||||||
|
|
||||||
|
// Import the pop-up modal component used to edit tasks
|
||||||
|
import TaskEditModal from './TaskEditModal';
|
||||||
|
|
||||||
|
// Define what props (inputs) this component expects (it needs 1 Task object)
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskCard({ task }: TaskCardProps) {
|
||||||
|
// Local state: tracks if the edit modal pop-up is open (true or false)
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
// Local state: tracks if we are currently archiving (used to dim the card)
|
||||||
|
const [isArchiving, setIsArchiving] = useState(false);
|
||||||
|
|
||||||
|
// Function called when the user clicks the "Archive" button
|
||||||
|
const handleArchive = async () => {
|
||||||
|
// Ask the user to confirm before archiving
|
||||||
|
if (confirm(`Are you sure you want to archive "${task.title}"?`)) {
|
||||||
|
setIsArchiving(true); // Dim the card visually
|
||||||
|
await archiveTaskAction(task.id); // Call the server action to update SQLite
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Outer card container div (dims opacity if isArchiving is true)
|
||||||
|
<div className={`p-5 rounded-xl border shadow-sm transition-all bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 ${isArchiving ? 'opacity-50' : ''}`}>
|
||||||
|
|
||||||
|
{/* Header section: Title and Status badge side by side */}
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
{/* Task Title */}
|
||||||
|
<h3 className="font-semibold text-lg text-zinc-900 dark:text-zinc-100">{task.title}</h3>
|
||||||
|
|
||||||
|
{/* Status Badge: Green for Complete, Yellow for In-Progress, Blue for Todo */}
|
||||||
|
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${
|
||||||
|
task.status === 'Complete'
|
||||||
|
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300'
|
||||||
|
: task.status === 'In-Progress'
|
||||||
|
? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300'
|
||||||
|
: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300'
|
||||||
|
}`}>
|
||||||
|
{task.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task Description (only renders if a description was provided) */}
|
||||||
|
{task.description && (
|
||||||
|
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-2">{task.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer section: Topic tag, Due Date, and Dynamic OVERDUE badge */}
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 text-xs text-zinc-500 dark:text-zinc-400 border-t border-zinc-100 dark:border-zinc-800 pt-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Topic Badge */}
|
||||||
|
<span className="bg-zinc-100 dark:bg-zinc-800 px-2 py-0.5 rounded font-mono text-zinc-700 dark:text-zinc-300">
|
||||||
|
#{task.topic}
|
||||||
|
</span>
|
||||||
|
{/* Due Date display */}
|
||||||
|
<span>Due: {task.due_date}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dynamic Overdue Badge: Only renders if task.is_overdue is calculated as true */}
|
||||||
|
{task.is_overdue && (
|
||||||
|
<span className="bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300 px-2 py-0.5 rounded font-semibold animate-pulse">
|
||||||
|
⚠️ OVERDUE
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons: Edit and Archive */}
|
||||||
|
<div className="mt-4 flex justify-end gap-2 text-xs">
|
||||||
|
{/* Click to open the Edit Modal */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="px-3 py-1.5 rounded bg-zinc-100 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-zinc-800 dark:text-zinc-200 font-medium transition"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Click to archive the task */}
|
||||||
|
<button
|
||||||
|
onClick={handleArchive}
|
||||||
|
disabled={isArchiving}
|
||||||
|
className="px-3 py-1.5 rounded bg-red-50 hover:bg-red-100 dark:bg-red-950/50 dark:hover:bg-red-900/50 text-red-600 dark:text-red-400 font-medium transition"
|
||||||
|
>
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal Pop-up: Only renders when isEditing === true */}
|
||||||
|
{isEditing && (
|
||||||
|
<TaskEditModal task={task} onClose={() => setIsEditing(false)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
137
src/lib/components/TaskEditModal.tsx
Normal file
137
src/lib/components/TaskEditModal.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Client component directive for browser interactive pop-up modal
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
// Import state hook from React
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
// Import Task and TaskStatus types
|
||||||
|
import { Task, TaskStatus } from '../types';
|
||||||
|
|
||||||
|
// Import the update server action function
|
||||||
|
import { updateTaskAction } from '../actions';
|
||||||
|
|
||||||
|
// Props needed for the modal: the task to edit, and an onClose function to close the pop-up
|
||||||
|
interface TaskEditModalProps {
|
||||||
|
task: Task;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TaskEditModal({ task, onClose }: TaskEditModalProps) {
|
||||||
|
// State to track if saving is in progress (shows 'Saving...' on button)
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Form submit handler to update task details
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault(); // Stop normal page refresh
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
const formData = new FormData(e.currentTarget); // Get data entered into form inputs
|
||||||
|
try {
|
||||||
|
// Call the server action to update SQLite database using formData directly
|
||||||
|
await updateTaskAction(task.id, formData);
|
||||||
|
onClose(); // Close modal upon successful update
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to update task');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Modal Overlay: dark background overlay that covers the entire screen
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||||
|
{/* Modal Dialog Box */}
|
||||||
|
<div className="w-full max-w-lg rounded-xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-6 shadow-xl">
|
||||||
|
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Edit Task #{task.id}</h2>
|
||||||
|
{/* Close button (X) */}
|
||||||
|
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 text-lg">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Form */}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Title input (pre-populated with task.title) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">Title *</label>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
defaultValue={task.title}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-transparent text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{/* Topic input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">Topic *</label>
|
||||||
|
<input
|
||||||
|
name="topic"
|
||||||
|
defaultValue={task.topic}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-transparent text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Due date picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">Due Date *</label>
|
||||||
|
<input
|
||||||
|
name="due_date"
|
||||||
|
type="date"
|
||||||
|
defaultValue={task.due_date}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-transparent text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status dropdown */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={task.status}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="Todo">Todo</option>
|
||||||
|
<option value="In-Progress">In-Progress</option>
|
||||||
|
<option value="Complete">Complete</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description text area */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
defaultValue={task.description}
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-transparent text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Action buttons */}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 text-zinc-800 dark:text-zinc-200 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSaving}
|
||||||
|
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 transition"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
src/lib/components/TaskFilterToolbar.tsx
Normal file
35
src/lib/components/TaskFilterToolbar.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Client component directive for drop-down navigation
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
// Next.js navigation hooks to change URL search parameters (like ?sortBy=due_date)
|
||||||
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function TaskFilterToolbar() {
|
||||||
|
const router = useRouter(); // Hook to navigate/push new URL
|
||||||
|
const searchParams = useSearchParams(); // Hook to read current URL search query (?sortBy=...)
|
||||||
|
const currentSort = searchParams.get('sortBy') || 'due_date'; // Default sort is 'due_date'
|
||||||
|
|
||||||
|
// Called whenever user changes the dropdown selection
|
||||||
|
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
params.set('sortBy', e.target.value); // Set new sortBy query value
|
||||||
|
router.push(`/?${params.toString()}`); // Update browser URL, triggering server re-query
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 p-4 rounded-xl shadow-sm">
|
||||||
|
<span className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Sort Tasks By:</span>
|
||||||
|
|
||||||
|
{/* Dropdown for sorting options */}
|
||||||
|
<select
|
||||||
|
value={currentSort}
|
||||||
|
onChange={handleSortChange}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100"
|
||||||
|
>
|
||||||
|
<option value="due_date">Due Date</option>
|
||||||
|
<option value="topic">Topic</option>
|
||||||
|
<option value="status">Status</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/lib/components/TaskForm.tsx
Normal file
111
src/lib/components/TaskForm.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Client component directive for form interactivity
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
// useRef gets direct reference to the DOM form element; useState manages submission state
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
// Import server action to create tasks in SQLite
|
||||||
|
import { createTaskAction } from '../actions';
|
||||||
|
|
||||||
|
export default function TaskForm() {
|
||||||
|
const formRef = useRef<HTMLFormElement>(null); // Used to reset input fields after submit
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false); // Prevents multiple fast clicks
|
||||||
|
|
||||||
|
// Form submit handler
|
||||||
|
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault(); // Prevents normal full-page browser reload
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const formData = new FormData(e.currentTarget); // Gathers values from inputs by their 'name'
|
||||||
|
try {
|
||||||
|
await createTaskAction(formData); // Call server action to insert task into SQLite
|
||||||
|
formRef.current?.reset(); // Clear all form inputs
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to create task');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
ref={formRef}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl p-5 shadow-sm space-y-4"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Create New Task</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Title Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">Title *</label>
|
||||||
|
<input
|
||||||
|
name="title"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="e.g. Finish Lab 1 Report"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-transparent text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Topic Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">Topic *</label>
|
||||||
|
<input
|
||||||
|
name="topic"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
placeholder="e.g. University / SDP"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-transparent text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Due Date Input */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">Due Date *</label>
|
||||||
|
<input
|
||||||
|
name="due_date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-transparent text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Selector */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">Status</label>
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue="Todo"
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="Todo">Todo</option>
|
||||||
|
<option value="In-Progress">In-Progress</option>
|
||||||
|
<option value="Complete">Complete</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1">Description</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Additional notes or details..."
|
||||||
|
className="w-full px-3 py-2 text-sm rounded-lg border border-zinc-300 dark:border-zinc-700 bg-transparent text-zinc-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition shadow"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Adding Task...' : '+ Add Task'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user