mirror of
https://github.com/AnoyRC/priospace.git
synced 2026-08-31 01:36:26 +08:00
404 lines
12 KiB
JavaScript
404 lines
12 KiB
JavaScript
"use client";
|
|
|
|
import { useState, useRef } from "react";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { Calendar, Check, Clock, RotateCcw } from "lucide-react";
|
|
import { formatFocusTime } from "@/utils/time"; // Assuming this utility exists
|
|
|
|
export function TaskList({
|
|
tasks,
|
|
customTags,
|
|
onToggleTask,
|
|
onDeleteTask, // onDeleteTask is not used in the current render logic
|
|
onTaskClick,
|
|
}) {
|
|
const [holdingTask, setHoldingTask] = useState(null);
|
|
const [holdProgress, setHoldProgress] = useState(0);
|
|
|
|
// Audio refs for sound effects
|
|
const holdAudioRef = useRef(null);
|
|
const completeAudioRef = useRef(null);
|
|
|
|
// Initialize audio elements
|
|
if (typeof window !== "undefined") {
|
|
if (!holdAudioRef.current) {
|
|
holdAudioRef.current = new Audio("/music/hold.mp3");
|
|
holdAudioRef.current.volume = 0.5; // Adjust volume as needed
|
|
}
|
|
if (!completeAudioRef.current) {
|
|
completeAudioRef.current = new Audio("/music/complete.mp3");
|
|
completeAudioRef.current.volume = 0.3; // Adjust volume as needed
|
|
}
|
|
}
|
|
|
|
const playHoldSound = () => {
|
|
if (holdAudioRef.current) {
|
|
holdAudioRef.current.currentTime = 0; // Reset to beginning
|
|
holdAudioRef.current
|
|
.play()
|
|
.catch((e) => console.log("Hold sound play failed:", e));
|
|
}
|
|
};
|
|
|
|
const stopHoldSound = () => {
|
|
if (holdAudioRef.current) {
|
|
holdAudioRef.current.pause();
|
|
holdAudioRef.current.currentTime = 0;
|
|
}
|
|
};
|
|
|
|
const playCompleteSound = () => {
|
|
if (completeAudioRef.current) {
|
|
completeAudioRef.current.currentTime = 0; // Reset to beginning
|
|
completeAudioRef.current
|
|
.play()
|
|
.catch((e) => console.log("Complete sound play failed:", e));
|
|
}
|
|
};
|
|
|
|
const handleMouseDown = (taskId, event) => {
|
|
event.preventDefault();
|
|
const task = tasks.find((t) => t.id === taskId);
|
|
if (task?.completed) return;
|
|
|
|
setHoldingTask(taskId);
|
|
playHoldSound(); // Play hold sound when starting to hold
|
|
|
|
let progress = 0;
|
|
let holdCompleted = false;
|
|
let animationFrameId;
|
|
|
|
const animateProgress = () => {
|
|
progress += 2;
|
|
setHoldProgress(progress);
|
|
if (progress >= 100) {
|
|
cancelAnimationFrame(animationFrameId);
|
|
holdCompleted = true;
|
|
stopHoldSound(); // Stop hold sound
|
|
playCompleteSound(); // Play complete sound
|
|
onToggleTask(taskId);
|
|
setHoldingTask(null);
|
|
setHoldProgress(0);
|
|
} else {
|
|
animationFrameId = requestAnimationFrame(animateProgress);
|
|
}
|
|
};
|
|
|
|
animationFrameId = requestAnimationFrame(animateProgress);
|
|
|
|
const cleanup = () => {
|
|
cancelAnimationFrame(animationFrameId);
|
|
stopHoldSound(); // Stop hold sound if releasing early
|
|
setHoldingTask(null);
|
|
setHoldProgress(0);
|
|
event.target.holdCompleted = holdCompleted;
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
cleanup();
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
document.removeEventListener("touchend", handleMouseUp);
|
|
};
|
|
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
document.addEventListener("touchend", handleMouseUp);
|
|
};
|
|
|
|
const handleTaskClick = (task, event) => {
|
|
if (holdingTask) return;
|
|
const holdCompleted = event.target.holdCompleted;
|
|
if (holdCompleted) {
|
|
event.target.holdCompleted = false;
|
|
return;
|
|
}
|
|
onTaskClick(task);
|
|
};
|
|
|
|
const formatTime = (minutes) => {
|
|
const hours = Math.floor(minutes / 60);
|
|
const mins = minutes % 60;
|
|
if (hours > 0) {
|
|
return `${hours}h ${mins}m`;
|
|
}
|
|
return `${mins}m`;
|
|
};
|
|
|
|
const getTagInfo = (tagId) => {
|
|
return customTags.find((tag) => tag.id === tagId);
|
|
};
|
|
|
|
const regularTasks = tasks.filter((task) => !task.isHabit);
|
|
const habitTasks = tasks.filter((task) => task.isHabit);
|
|
|
|
// Animation variants
|
|
const containerVariants = {
|
|
hidden: { opacity: 0 },
|
|
visible: {
|
|
opacity: 1,
|
|
transition: {
|
|
staggerChildren: 0.05,
|
|
delayChildren: 0.1,
|
|
},
|
|
},
|
|
exit: {
|
|
opacity: 0,
|
|
transition: {
|
|
duration: 0.15,
|
|
},
|
|
},
|
|
};
|
|
|
|
const taskVariants = {
|
|
hidden: { opacity: 0, y: 20 },
|
|
visible: {
|
|
opacity: 1,
|
|
y: 0,
|
|
transition: { duration: 0.4, ease: "easeOut" },
|
|
},
|
|
exit: {
|
|
opacity: 0,
|
|
x: -30,
|
|
transition: { duration: 0.2, ease: "easeIn" },
|
|
},
|
|
};
|
|
|
|
// Header animation variants
|
|
const headerVariants = {
|
|
hidden: { opacity: 0, y: -10 },
|
|
visible: {
|
|
opacity: 1,
|
|
y: 0,
|
|
transition: { duration: 0.3, ease: "easeOut" },
|
|
},
|
|
exit: {
|
|
opacity: 0,
|
|
y: -10,
|
|
transition: { duration: 0.2, ease: "easeIn" },
|
|
},
|
|
};
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto hide-scroll overflow-x-hidden p-4 px-0">
|
|
<AnimatePresence>
|
|
{/* Habits Section */}
|
|
{habitTasks.length > 0 && (
|
|
<motion.div
|
|
key="habits-section"
|
|
className="mb-6 mt-3"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<motion.div
|
|
className="text-sm text-primary font-extrabold uppercase tracking-wide mb-3 flex items-center gap-2"
|
|
variants={headerVariants}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
>
|
|
<RotateCcw className="h-4 w-4" /> Habits
|
|
</motion.div>
|
|
<motion.div
|
|
className=""
|
|
variants={containerVariants}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
>
|
|
{habitTasks.map((task) => (
|
|
<TaskItem
|
|
key={task.id}
|
|
task={task}
|
|
holdingTask={holdingTask}
|
|
holdProgress={holdProgress}
|
|
onMouseDown={handleMouseDown}
|
|
onTaskClick={handleTaskClick}
|
|
formatTime={formatTime}
|
|
getTagInfo={getTagInfo}
|
|
isHabit={true}
|
|
variants={taskVariants}
|
|
isLastTask={task.id === habitTasks[habitTasks.length - 1].id}
|
|
/>
|
|
))}
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{/* Regular Tasks Section */}
|
|
{regularTasks.length > 0 && (
|
|
<motion.div
|
|
key="tasks-section"
|
|
className="mb-6"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.3 }}
|
|
>
|
|
<motion.div
|
|
className="text-sm text-primary font-extrabold uppercase tracking-wide mb-3 flex items-center gap-2"
|
|
variants={headerVariants}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
>
|
|
<Calendar className="h-4 w-4" /> Tasks
|
|
</motion.div>
|
|
<motion.div
|
|
className=""
|
|
variants={containerVariants}
|
|
initial="hidden"
|
|
animate="visible"
|
|
exit="exit"
|
|
>
|
|
{regularTasks.map((task) => (
|
|
<TaskItem
|
|
key={task.id}
|
|
task={task}
|
|
holdingTask={holdingTask}
|
|
holdProgress={holdProgress}
|
|
onMouseDown={handleMouseDown}
|
|
onTaskClick={handleTaskClick}
|
|
formatTime={formatTime}
|
|
getTagInfo={getTagInfo}
|
|
isHabit={false}
|
|
variants={taskVariants}
|
|
isLastTask={
|
|
task.id === regularTasks[regularTasks.length - 1].id
|
|
}
|
|
/>
|
|
))}
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{tasks.length === 0 && (
|
|
<motion.div
|
|
key="no-tasks-message"
|
|
initial={{ opacity: 0, y: 15 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -15 }}
|
|
transition={{ duration: 0.3 }}
|
|
className="text-center py-12 text-primary/60 font-bold"
|
|
>
|
|
<p>No tasks yet. Add one to get started!</p>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TaskItem({
|
|
task,
|
|
holdingTask,
|
|
holdProgress,
|
|
onMouseDown,
|
|
onTaskClick,
|
|
formatTime,
|
|
getTagInfo,
|
|
isHabit,
|
|
variants,
|
|
isLastTask,
|
|
}) {
|
|
const tagInfo = getTagInfo(task.tag);
|
|
|
|
return (
|
|
<motion.div
|
|
variants={variants}
|
|
layout
|
|
className={`relative p-4 border-t border-dashed cursor-pointer select-none overflow-hidden ${
|
|
task.completed
|
|
? ""
|
|
: "border-primary/50 dark:border-primary-700 hover:bg-primary/5"
|
|
}
|
|
${isLastTask ? "border-b" : ""}`}
|
|
onMouseDown={(e) => onMouseDown(task.id, e)}
|
|
onTouchStart={(e) => onMouseDown(task.id, e)}
|
|
onClick={(e) => onTaskClick(task, e)}
|
|
whileHover={{ scale: 1.01 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
>
|
|
{/* Hold Progress Bar */}
|
|
{holdingTask === task.id && (
|
|
<motion.div
|
|
className="absolute inset-0 bg-primary/20"
|
|
initial={{ width: 0 }}
|
|
animate={{ width: `${holdProgress}%` }}
|
|
transition={{ duration: 0.1 }}
|
|
layout
|
|
/>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between relative z-10">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{tagInfo && (
|
|
<div className="flex items-center gap-1.5">
|
|
<div
|
|
className="w-2 h-2 rounded-full"
|
|
style={{ backgroundColor: tagInfo.color }}
|
|
/>
|
|
<span className="text-xs text-gray-500 dark:text-gray-400 font-bold">
|
|
{tagInfo.name}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<motion.span
|
|
animate={{
|
|
scale: holdingTask === task.id ? 0.98 : 1,
|
|
opacity: task.completed ? 0.7 : 1,
|
|
}}
|
|
className={`block font-extrabold text-lg ${
|
|
task.completed
|
|
? "line-through text-gray-600 dark:text-gray-100"
|
|
: "text-gray-600 dark:text-gray-100"
|
|
}`}
|
|
>
|
|
{task.title}
|
|
</motion.span>
|
|
|
|
{(task.timeSpent > 0 || task.focusTime > 0) && (
|
|
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500 dark:text-gray-400">
|
|
{task.timeSpent > 0 && (
|
|
<div className="flex items-center gap-1 font-extrabold opacity-80">
|
|
<Clock className="h-3 w-3" />
|
|
<span>{formatTime(task.timeSpent)}</span>
|
|
</div>
|
|
)}
|
|
{task.focusTime > 0 && (
|
|
<div className="flex items-center gap-1 font-extrabold opacity-80">
|
|
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
|
|
<span className="text-blue-600 dark:text-blue-400">
|
|
{formatFocusTime(task.focusTime)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completion Animation */}
|
|
{task.completed && (
|
|
<motion.div
|
|
initial={{ scale: 0 }}
|
|
animate={{ scale: 1 }}
|
|
className="absolute right-5 top-7 w-6 h-6 bg-primary rounded-full flex items-center justify-center z-20"
|
|
>
|
|
<motion.div
|
|
initial={{ pathLength: 0 }}
|
|
animate={{ pathLength: 1 }}
|
|
transition={{ duration: 0.5, delay: 0.2 }}
|
|
className="text-background"
|
|
>
|
|
<Check className="h-3 w-3" />
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</motion.div>
|
|
);
|
|
}
|