Compare commits
2 Commits
6cfd244b41
...
938f59d10d
| Author | SHA1 | Date |
|---|---|---|
|
|
938f59d10d | |
|
|
171ec92b74 |
|
|
@ -7,6 +7,7 @@ import { SideNav } from "@/components/nav/side-nav";
|
|||
import { ProjectsProvider } from "@/components/projects/projects-context";
|
||||
import { ScheduledPanelProvider } from "@/components/scheduled/scheduled-panel-provider";
|
||||
import { ScheduledPanel } from "@/components/scheduled/scheduled-panel";
|
||||
import { BoardViewProvider } from "@/components/board/board-view-provider";
|
||||
|
||||
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await auth();
|
||||
|
|
@ -26,11 +27,13 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
<SideNavProvider>
|
||||
<ScheduledPanelProvider>
|
||||
<ProjectsProvider initialProjects={projects}>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
<ScheduledPanel />
|
||||
</div>
|
||||
<BoardViewProvider>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
<ScheduledPanel />
|
||||
</div>
|
||||
</BoardViewProvider>
|
||||
</ProjectsProvider>
|
||||
</ScheduledPanelProvider>
|
||||
</SideNavProvider>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "organize:board-view";
|
||||
|
||||
// A plain string union rather than an enum -- adding a future view is just
|
||||
// one more literal here plus one more radio item in ViewSwitcher.
|
||||
export type BoardView = "default" | "compact";
|
||||
|
||||
interface BoardViewContextValue {
|
||||
view: BoardView;
|
||||
setView: (view: BoardView) => void;
|
||||
}
|
||||
|
||||
const BoardViewContext = createContext<BoardViewContextValue | null>(null);
|
||||
|
||||
export function BoardViewProvider({ children }: { children: React.ReactNode }) {
|
||||
// Default to "default" on both server and first client render to avoid a
|
||||
// hydration mismatch; the real persisted value is applied right after
|
||||
// mount, trading a one-frame flash for zero hydration warnings.
|
||||
const [view, setViewState] = useState<BoardView>("default");
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "default" || stored === "compact") setViewState(stored);
|
||||
}, []);
|
||||
|
||||
function setView(next: BoardView) {
|
||||
setViewState(next);
|
||||
localStorage.setItem(STORAGE_KEY, next);
|
||||
}
|
||||
|
||||
return (
|
||||
<BoardViewContext.Provider value={{ view, setView }}>
|
||||
{children}
|
||||
</BoardViewContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBoardView() {
|
||||
const ctx = useContext(BoardViewContext);
|
||||
if (!ctx) throw new Error("useBoardView must be used within a BoardViewProvider");
|
||||
return ctx;
|
||||
}
|
||||
|
|
@ -18,13 +18,23 @@ import { GroupCard } from "@/components/board/group-card";
|
|||
import { AddGroupPopover } from "@/components/board/add-group-popover";
|
||||
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { useBoardView } from "@/components/board/board-view-provider";
|
||||
import type { CategoryDTO } from "@/types/board";
|
||||
|
||||
export function CategoryLane({ category }: { category: CategoryDTO }) {
|
||||
const { removeCategory } = useBoard();
|
||||
const { view } = useBoardView();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const isEmpty = category.groups.length === 0;
|
||||
|
||||
// Compact hides any group with no unchecked to-dos left -- which also
|
||||
// covers a brand-new group with no to-dos at all, since it has zero
|
||||
// unchecked ones too.
|
||||
const visibleGroups =
|
||||
view === "compact"
|
||||
? category.groups.filter((g) => g.todos.some((t) => !t.completed))
|
||||
: category.groups;
|
||||
|
||||
// The lane itself is sortable (so lanes can be reordered), which
|
||||
// registers its *entire* rect -- header, cards, empty space, all of it --
|
||||
// as a droppable. Dragging a group card over empty lane space then had
|
||||
|
|
@ -64,7 +74,7 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
|
|||
data: { type: "category-dropzone", categoryId: category.id },
|
||||
});
|
||||
|
||||
const groupIds = category.groups.map((g) => g.id);
|
||||
const groupIds = visibleGroups.map((g) => g.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -124,7 +134,7 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
|
|||
)}
|
||||
>
|
||||
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
||||
{category.groups.map((group) => (
|
||||
{visibleGroups.map((group) => (
|
||||
<GroupCard key={group.id} group={group} />
|
||||
))}
|
||||
</SortableContext>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import { CSS } from "@dnd-kit/utilities";
|
|||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Archive,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
GripVertical,
|
||||
MoreVertical,
|
||||
Pencil,
|
||||
Plus,
|
||||
Sparkles,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
|
|
@ -28,8 +30,10 @@ import {
|
|||
} from "@/components/ui/dropdown-menu";
|
||||
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { useBoardView } from "@/components/board/board-view-provider";
|
||||
import { NotesDialog } from "@/components/board/notes-dialog";
|
||||
import { TodoAiDialog } from "@/components/board/todo-ai-dialog";
|
||||
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
||||
import { TodoCreatePopover } from "@/components/board/todo-create-popover";
|
||||
import { TodoEditDialog } from "@/components/board/todo-edit-dialog";
|
||||
import { TodoProgressPie } from "@/components/board/todo-progress-pie";
|
||||
|
|
@ -38,14 +42,60 @@ import { StatusUpdateDialog } from "@/components/board/status-update-dialog";
|
|||
import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog";
|
||||
import type { GroupDTO, TodoDTO } from "@/types/board";
|
||||
|
||||
// Compact's "+" -- sits inline on the last visible to-do row (its sibling
|
||||
// text button is flex-1, so this shrink-0 trigger naturally lands at the
|
||||
// row's right edge) instead of on a row of its own.
|
||||
function AddTodoMenu({
|
||||
group,
|
||||
aiConfigured,
|
||||
onAddClick,
|
||||
onAiClick,
|
||||
}: {
|
||||
group: GroupDTO;
|
||||
aiConfigured: boolean;
|
||||
onAddClick: () => void;
|
||||
onAiClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
className="flex size-6 shrink-0 items-center justify-center self-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label={`Add a to-do to ${group.title}`}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={onAddClick}>
|
||||
<Plus className="size-4" />
|
||||
Add to-do
|
||||
</DropdownMenuItem>
|
||||
{aiConfigured && (
|
||||
<DropdownMenuItem onClick={onAiClick}>
|
||||
<Sparkles className="size-4" />
|
||||
Add using AI
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupCard({ group }: { group: GroupDTO }) {
|
||||
const { toggleTodoDone, removeGroup, archiveGroup, aiConfigured } = useBoard();
|
||||
const { view } = useBoardView();
|
||||
const compact = view === "compact";
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [todoAiOpen, setTodoAiOpen] = useState(false);
|
||||
const [todoCreateOpen, setTodoCreateOpen] = useState(false);
|
||||
const [statusUpdateOpen, setStatusUpdateOpen] = useState(false);
|
||||
const [expandedWhileComplete, setExpandedWhileComplete] = useState(false);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
|
|
@ -91,6 +141,29 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
// with no to-dos yet, or with any still open, isn't "finished" yet.
|
||||
const canArchive = group.todos.length > 0 && group.todos.every((t) => t.completed);
|
||||
const completedCount = group.todos.filter((t) => t.completed).length;
|
||||
// Compact only ever surfaces open work -- checked-off todos are dropped
|
||||
// from the list entirely rather than shown crossed-out.
|
||||
const visibleTodos = compact ? group.todos.filter((t) => !t.completed) : group.todos;
|
||||
|
||||
// In the default view, a fully-checked-off group collapses down to just
|
||||
// its header (title, notes icon, kebab menu) with a disclosure triangle
|
||||
// in place of the drag-grip dots, so a board full of "done" groups
|
||||
// doesn't stay as visually loud as one still full of open work. The
|
||||
// triangle re-expands it back to the normal full card. Compact already
|
||||
// hides finished groups outright (see CategoryLane), so this only
|
||||
// applies to the default view.
|
||||
const showDisclosure = !compact && canArchive;
|
||||
// Reset back to collapsed each time the group freshly becomes fully
|
||||
// done, rather than remembering a stale expanded choice from its last
|
||||
// completion cycle. Adjusting state during render (comparing against
|
||||
// last render's value) instead of in a useEffect avoids an extra render
|
||||
// pass -- see https://react.dev/learn/you-might-not-need-an-effect.
|
||||
const [wasComplete, setWasComplete] = useState(canArchive);
|
||||
if (canArchive !== wasComplete) {
|
||||
setWasComplete(canArchive);
|
||||
if (canArchive) setExpandedWhileComplete(false);
|
||||
}
|
||||
const isCollapsed = showDisclosure && !expandedWhileComplete;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -117,9 +190,26 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
className="group flex min-w-0 flex-1 cursor-grab touch-none items-start gap-1 rounded-md outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
aria-label={`Drag to move ${group.title}`}
|
||||
>
|
||||
<span className="mt-1 flex shrink-0 items-center justify-center text-muted-foreground/60 group-hover:text-muted-foreground">
|
||||
<GripVertical className="size-4" />
|
||||
</span>
|
||||
{showDisclosure ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedWhileComplete((v) => !v);
|
||||
}}
|
||||
className="mt-1 flex shrink-0 items-center justify-center text-muted-foreground/60 hover:text-foreground"
|
||||
aria-label={expandedWhileComplete ? `Collapse ${group.title}` : `Expand ${group.title}`}
|
||||
aria-expanded={expandedWhileComplete}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn("size-4 transition-transform", expandedWhileComplete && "rotate-90")}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="mt-1 flex shrink-0 items-center justify-center text-muted-foreground/60 group-hover:text-muted-foreground">
|
||||
<GripVertical className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
|
|
@ -156,80 +246,118 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="mt-1 space-y-1">
|
||||
{group.todos.map((todo) => {
|
||||
const todoButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTodo(todo)}
|
||||
style={todo.completed ? undefined : { color: todoTextColor }}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline",
|
||||
todo.completed && "text-muted-foreground line-through"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{todo.title}</span>
|
||||
{todo.details && (
|
||||
<StickyNote className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{visibleTodos.map((todo, index) => {
|
||||
const todoButton = (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTodo(todo)}
|
||||
style={todo.completed ? undefined : { color: todoTextColor }}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline",
|
||||
todo.completed && "text-muted-foreground line-through"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{todo.title}</span>
|
||||
{todo.details && (
|
||||
<StickyNote className="size-3 shrink-0 text-muted-foreground/70" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<li key={todo.id} className="flex items-start gap-2 py-0.5">
|
||||
<TodoCheckbox
|
||||
checked={todo.completed}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleTodoDone(todo.id, group.id, group.categoryId, checked)
|
||||
}
|
||||
accentColor={borderColor}
|
||||
isDark={isDark}
|
||||
className="mt-0.5"
|
||||
aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`}
|
||||
// In compact, the "+" rides along on the last visible row
|
||||
// instead of taking a row of its own -- one less line per
|
||||
// group. (A compact group always has at least one unchecked
|
||||
// to-do -- CategoryLane hides it otherwise -- so there's
|
||||
// always a last row to put it on.)
|
||||
const isLastRow = compact && index === visibleTodos.length - 1;
|
||||
|
||||
return (
|
||||
<li key={todo.id} className="flex items-start gap-2 py-0.5">
|
||||
<TodoCheckbox
|
||||
checked={todo.completed}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleTodoDone(todo.id, group.id, group.categoryId, checked)
|
||||
}
|
||||
accentColor={borderColor}
|
||||
isDark={isDark}
|
||||
className="mt-0.5"
|
||||
aria-label={`Mark "${todo.title}" ${todo.completed ? "incomplete" : "complete"}`}
|
||||
/>
|
||||
{todo.details ? (
|
||||
<MouseFollowTooltip content={todo.details}>{todoButton}</MouseFollowTooltip>
|
||||
) : (
|
||||
todoButton
|
||||
)}
|
||||
{isLastRow && (
|
||||
<AddTodoMenu
|
||||
group={group}
|
||||
aiConfigured={aiConfigured}
|
||||
onAddClick={() => setTodoCreateOpen(true)}
|
||||
onAiClick={() => setTodoAiOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{compact ? (
|
||||
// Defensive fallback -- shouldn't normally happen, since
|
||||
// CategoryLane already hides a compact group with zero
|
||||
// visible to-dos, but keeps the "+" reachable if that ever
|
||||
// changes.
|
||||
visibleTodos.length === 0 && (
|
||||
<div className="mt-1 flex justify-end">
|
||||
<AddTodoMenu
|
||||
group={group}
|
||||
onAddClick={() => setTodoCreateOpen(true)}
|
||||
onAiClick={() => setTodoAiOpen(true)}
|
||||
aiConfigured={aiConfigured}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1">
|
||||
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
|
||||
</div>
|
||||
|
||||
{aiConfigured && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground"
|
||||
onClick={() => setTodoAiOpen(true)}
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
Add using AI
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canArchive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground"
|
||||
onClick={() => archiveGroup(group.id, group.categoryId)}
|
||||
>
|
||||
<Archive className="size-3.5" />
|
||||
Archive
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TodoProgressPie
|
||||
completed={completedCount}
|
||||
total={group.todos.length}
|
||||
accentColor={pieAccentColor}
|
||||
/>
|
||||
{todo.details ? (
|
||||
<MouseFollowTooltip content={todo.details}>{todoButton}</MouseFollowTooltip>
|
||||
) : (
|
||||
todoButton
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="mt-1">
|
||||
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
|
||||
</div>
|
||||
|
||||
{aiConfigured && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground"
|
||||
onClick={() => setTodoAiOpen(true)}
|
||||
>
|
||||
<Sparkles className="size-3.5" />
|
||||
Add using AI
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canArchive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground"
|
||||
onClick={() => archiveGroup(group.id, group.categoryId)}
|
||||
>
|
||||
<Archive className="size-3.5" />
|
||||
Archive
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<TodoProgressPie
|
||||
completed={completedCount}
|
||||
total={group.todos.length}
|
||||
accentColor={pieAccentColor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editingTodo && (
|
||||
|
|
@ -246,6 +374,13 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
|
||||
<TodoAiDialog group={group} open={todoAiOpen} onOpenChange={setTodoAiOpen} />
|
||||
|
||||
<TodoCreateDialog
|
||||
groupId={group.id}
|
||||
categoryId={group.categoryId}
|
||||
open={todoCreateOpen}
|
||||
onOpenChange={setTodoCreateOpen}
|
||||
/>
|
||||
|
||||
<StatusUpdateDialog group={group} open={statusUpdateOpen} onOpenChange={setStatusUpdateOpen} />
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { CategoryLane } from "@/components/board/category-lane";
|
|||
import { AddCategoryLane } from "@/components/board/add-category-lane";
|
||||
import { GroupCardOverlay } from "@/components/board/group-card-overlay";
|
||||
import { EmptyState } from "@/components/board/empty-state";
|
||||
import { ViewSwitcher } from "@/components/board/view-switcher";
|
||||
import type { CategoryDTO, GroupDTO } from "@/types/board";
|
||||
|
||||
function Board({ title }: { title: string }) {
|
||||
|
|
@ -115,7 +116,10 @@ function Board({ title }: { title: string }) {
|
|||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<h1 className="truncate px-1 text-2xl font-bold">{title}</h1>
|
||||
<div className="flex items-center justify-between gap-2 px-1">
|
||||
<h1 className="truncate text-2xl font-bold">{title}</h1>
|
||||
<ViewSwitcher />
|
||||
</div>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<div className="flex flex-1 items-center gap-4 overflow-x-auto">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { MDEditor } from "@/components/markdown/markdown-widgets";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
|
||||
const TITLE_MAX = 20;
|
||||
|
||||
// Same fields and `addTodo` call as TodoCreatePopover's inline form, but as
|
||||
// a controlled Dialog instead of owning its own popover trigger -- this is
|
||||
// opened from the compact view's "+" dropdown menu rather than from an
|
||||
// inline "+ Add to-do" button.
|
||||
export function TodoCreateDialog({
|
||||
groupId,
|
||||
categoryId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
groupId: string;
|
||||
categoryId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { addTodo } = useBoard();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [details, setDetails] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) {
|
||||
setTitle("");
|
||||
setDetails("");
|
||||
}
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
setPending(true);
|
||||
const ok = await addTodo(groupId, categoryId, trimmed, details.trim() || undefined);
|
||||
setPending(false);
|
||||
if (ok) handleOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg" data-color-mode={colorMode}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add to-do</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`create-todo-title-${groupId}`}>Title</Label>
|
||||
<Input
|
||||
id={`create-todo-title-${groupId}`}
|
||||
value={title}
|
||||
maxLength={TITLE_MAX}
|
||||
autoFocus
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Buy milk"
|
||||
/>
|
||||
<p className="text-right text-xs text-muted-foreground">
|
||||
{title.length}/{TITLE_MAX}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`create-todo-details-${groupId}`}>Details (optional)</Label>
|
||||
<MDEditor
|
||||
value={details}
|
||||
onChange={(v) => setDetails(v ?? "")}
|
||||
height={160}
|
||||
textareaProps={{ id: `create-todo-details-${groupId}` }}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={pending || !title.trim()}>
|
||||
{pending ? "Adding…" : "Add to-do"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { LayoutList, Rows3 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useBoardView, type BoardView } from "@/components/board/board-view-provider";
|
||||
|
||||
// New views just need one more entry here (and one more BoardView literal)
|
||||
// -- no redesign of the trigger or menu required.
|
||||
const OPTIONS: { value: BoardView; label: string; icon: typeof LayoutList }[] = [
|
||||
{ value: "default", label: "Default", icon: LayoutList },
|
||||
{ value: "compact", label: "Compact", icon: Rows3 },
|
||||
];
|
||||
|
||||
export function ViewSwitcher() {
|
||||
const { view, setView } = useBoardView();
|
||||
const current = OPTIONS.find((o) => o.value === view) ?? OPTIONS[0];
|
||||
const Icon = current.icon;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="sm" className="gap-2 text-muted-foreground">
|
||||
<Icon className="size-4" />
|
||||
{current.label}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuRadioGroup value={view} onValueChange={(v) => setView(v as BoardView)}>
|
||||
{OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem key={option.value} value={option.value}>
|
||||
<option.icon className="size-4" />
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -92,6 +92,10 @@ export function ScheduledPanel() {
|
|||
}
|
||||
|
||||
const overdueCount = board?.overdue.length ?? 0;
|
||||
// Today's occurrences list includes already-checked-off ones (so they can
|
||||
// still be toggled back), unlike overdue -- so this needs its own filter
|
||||
// rather than just `board.today.occurrences.length`.
|
||||
const todayCount = board?.today.occurrences.filter((o) => !o.completed).length ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -102,7 +106,12 @@ export function ScheduledPanel() {
|
|||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<CollapsedRail overdueCount={overdueCount} onAdd={handleAdd} onExpand={toggle} />
|
||||
<CollapsedRail
|
||||
overdueCount={overdueCount}
|
||||
todayCount={todayCount}
|
||||
onAdd={handleAdd}
|
||||
onExpand={toggle}
|
||||
/>
|
||||
) : (
|
||||
<ExpandedPanel
|
||||
board={board}
|
||||
|
|
@ -128,38 +137,78 @@ export function ScheduledPanel() {
|
|||
|
||||
function CollapsedRail({
|
||||
overdueCount,
|
||||
todayCount,
|
||||
onAdd,
|
||||
onExpand,
|
||||
}: {
|
||||
overdueCount: number;
|
||||
todayCount: number;
|
||||
onAdd: () => void;
|
||||
onExpand: () => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center p-3">
|
||||
<div className="flex flex-col items-center gap-1.5 p-3">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
className="relative flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
className="flex size-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
aria-label="Expand scheduled to-dos"
|
||||
>
|
||||
<CalendarClock className="size-5" />
|
||||
{overdueCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 flex size-4 animate-pulse items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-white">
|
||||
{overdueCount > 9 ? "9+" : overdueCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="left">
|
||||
{overdueCount > 0 ? `${overdueCount} overdue` : "Scheduled to-dos"}
|
||||
</TooltipContent>
|
||||
<TooltipContent side="left">Scheduled to-dos</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Sits right below the calendar icon rather than as a corner
|
||||
overlay -- a ping ring behind the solid circle makes overdue
|
||||
work as loud/eye-catching as possible. */}
|
||||
{overdueCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
className="relative flex size-5 items-center justify-center"
|
||||
aria-label={`${overdueCount} overdue`}
|
||||
>
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-destructive opacity-75" />
|
||||
<span className="relative flex size-5 items-center justify-center rounded-full bg-destructive text-[10px] font-bold text-white">
|
||||
{overdueCount > 9 ? "9+" : overdueCount}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="left">{overdueCount} overdue</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Below the overdue circle when both are present, otherwise right
|
||||
below the calendar icon -- a plain opacity pulse (no ping ring)
|
||||
keeps it visibly calmer than overdue's. */}
|
||||
{todayCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExpand}
|
||||
className="flex size-5 animate-pulse items-center justify-center rounded-full bg-white text-[10px] font-bold text-black shadow-sm ring-1 ring-border"
|
||||
aria-label={`${todayCount} due today`}
|
||||
>
|
||||
{todayCount > 9 ? "9+" : todayCount}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="left">{todayCount} due today</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-end gap-1 p-2">
|
||||
|
|
|
|||
Loading…
Reference in New Issue