310 lines
17 KiB
TypeScript
310 lines
17 KiB
TypeScript
import { type Component, createMemo, For, Show } from "solid-js";
|
|
import { type Task, calculateUrgencyFromDate, setActiveTaskId, store, copyTask, updateTask, currentTaskContext, now, getFavoriteTag, dismissTaskUpdateIndicator, isTaskUpdatedByAnotherUser, collapseTaskUntilUpdated, expandCollapsedTask, isTaskCollapsedUntilUpdated, isTaskCollaborativelyShared, getVisibleTaskTags } from "@/store";
|
|
import { cn } from "@/lib/utils";
|
|
import { Clock, ArrowUpCircle, Copy, Users2, Star, ChevronRight } from "lucide-solid";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { useTheme } from "./ThemeProvider";
|
|
import { getThemeAdjustedColor } from "@/lib/colors";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
|
import { getStatusOptionsForTask } from "@/lib/constants";
|
|
import { getRecurrenceProgress } from "@/lib/recurrence";
|
|
import { StatusCircle } from "./StatusCircle";
|
|
|
|
export const TaskCard: Component<{ task: Task, isShaking?: boolean }> = (props) => {
|
|
const { resolvedTheme } = useTheme();
|
|
const urgencyLevel = createMemo(() => calculateUrgencyFromDate(props.task.dueDate));
|
|
|
|
const urgencyColor = () => {
|
|
const u = urgencyLevel();
|
|
if (u >= 8) return "text-red-500";
|
|
if (u >= 6) return "text-orange-500";
|
|
if (u >= 4) return "text-blue-500";
|
|
return "text-muted-foreground";
|
|
};
|
|
|
|
const formattedDate = () => {
|
|
const date = new Date(props.task.dueDate);
|
|
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
|
|
const visibleTags = createMemo(() => {
|
|
return getVisibleTaskTags(props.task, currentTaskContext());
|
|
});
|
|
|
|
const isDueWithin12Hours = createMemo(() => {
|
|
if (!props.task.dueDate) return false;
|
|
try {
|
|
const dueMs = new Date(props.task.dueDate).getTime();
|
|
const diffMs = dueMs - now();
|
|
const twelveHoursMs = 12 * 60 * 60 * 1000;
|
|
return Math.abs(diffMs) <= twelveHoursMs;
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
const canCollapseUntilUpdated = createMemo(() => isTaskCollaborativelyShared(props.task));
|
|
const statusOptions = createMemo(() => {
|
|
const baseOptions = getStatusOptionsForTask(props.task.recurrence);
|
|
if (!canCollapseUntilUpdated()) return baseOptions;
|
|
return [
|
|
...baseOptions,
|
|
{ value: "__collapse_until_updated__", label: "Collapse until updated" }
|
|
];
|
|
});
|
|
const recurrenceProgress = createMemo(() => {
|
|
if (!props.task.completed && props.task.status !== 10) return null;
|
|
return getRecurrenceProgress(props.task.recurrence, props.task.updated, now());
|
|
});
|
|
const shouldQuickloadFade = createMemo(() => store.quickloadFadeTaskIds.includes(props.task.id));
|
|
const showUpdateIndicator = createMemo(() => isTaskUpdatedByAnotherUser(props.task));
|
|
const isCollapsed = createMemo(() => isTaskCollapsedUntilUpdated(props.task));
|
|
|
|
return (
|
|
<div
|
|
class={cn(
|
|
"group relative flex items-center bg-card border border-border rounded-2xl transition-[background-color,border-color,box-shadow,opacity] duration-500 hover:duration-100 hover:shadow-xl hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer w-full min-w-0 overflow-hidden",
|
|
isCollapsed()
|
|
? "py-1.5 pl-5 pr-5 sm:py-2 sm:pl-6 sm:pr-6"
|
|
: "py-3 pl-6 pr-6 sm:py-4 sm:pl-7 sm:pr-7",
|
|
props.task.completed && "opacity-60",
|
|
props.isShaking && "animate-task-shake bg-accent/20",
|
|
shouldQuickloadFade() && "animate-task-quickload-fade"
|
|
)}
|
|
onClick={() => setActiveTaskId(props.task.id)}
|
|
>
|
|
{/* Today indicator */}
|
|
<Show when={isDueWithin12Hours() && !props.task.completed}>
|
|
<div class="absolute left-0 top-3 bottom-3 w-3 sm:w-[0.875rem] rounded-r-md bg-primary/65 text-primary-foreground/90">
|
|
<span
|
|
class="absolute inset-0 flex items-center justify-center text-[0.4375rem] sm:text-[0.5rem] font-semibold uppercase tracking-[0.08em] [writing-mode:vertical-rl] [text-orientation:mixed]"
|
|
>
|
|
{!isCollapsed() && "Today"}
|
|
</span>
|
|
</div>
|
|
</Show>
|
|
<Show when={showUpdateIndicator()}>
|
|
<button
|
|
type="button"
|
|
class="absolute right-0 top-3 bottom-3 w-3 sm:w-[0.875rem] rounded-l-md bg-primary/65 text-primary-foreground/90 transition-colors hover:bg-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30"
|
|
title="Hide updates until the next change from another user"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
dismissTaskUpdateIndicator(props.task.id, props.task.updated);
|
|
}}
|
|
>
|
|
<span
|
|
class="absolute inset-0 flex items-center justify-center text-[0.4375rem] sm:text-[0.5rem] font-semibold uppercase tracking-[0.08em] [writing-mode:vertical-rl] [text-orientation:mixed]"
|
|
>
|
|
{!isCollapsed() && "Updates"}
|
|
</span>
|
|
</button>
|
|
</Show>
|
|
{/* Status Dropdown */}
|
|
<Show when={!isCollapsed()}>
|
|
<div class="mr-2 sm:mr-3 shrink-0" onClick={(e) => e.stopPropagation()}>
|
|
<Select<any>
|
|
value={(props.task.status ?? 0).toString()}
|
|
onChange={(val: any) => {
|
|
const value = typeof val === 'object' ? val?.value : val;
|
|
if (!value) return;
|
|
if (value === "__collapse_until_updated__") {
|
|
collapseTaskUntilUpdated(props.task.id, props.task.updated);
|
|
return;
|
|
}
|
|
|
|
const s = parseInt(value);
|
|
if (!isNaN(s)) updateTask(props.task.id, { status: s });
|
|
}}
|
|
options={statusOptions()}
|
|
optionValue="value"
|
|
optionTextValue="label"
|
|
placeholder="Status"
|
|
itemComponent={(props: any) => <SelectItem item={props.item}>{props.item.rawValue.label}</SelectItem>}
|
|
>
|
|
<SelectTrigger
|
|
class="h-9 w-9 p-0 bg-transparent border-transparent hover:bg-muted/50 data-[expanded]:bg-muted/50 shadow-none focus:ring-0 shrink-0 overflow-hidden flex items-center justify-center [&>svg]:hidden"
|
|
onDoubleClick={(e: MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
updateTask(props.task.id, { status: 10 });
|
|
}}
|
|
>
|
|
<StatusCircle status={props.task.status ?? 0} completed={props.task.completed} size={26} recurrenceProgress={recurrenceProgress()} />
|
|
</SelectTrigger>
|
|
<SelectContent />
|
|
</Select>
|
|
</div>
|
|
</Show>
|
|
|
|
<div class={cn("flex-1 min-w-0", isCollapsed() ? "flex items-center justify-between gap-2" : "flex flex-col")}>
|
|
<Show
|
|
when={isCollapsed()}
|
|
fallback={
|
|
<>
|
|
<h3 class={cn(
|
|
"text-sm sm:text-base font-semibold truncate transition-all",
|
|
props.task.completed && "line-through"
|
|
)}>
|
|
{props.task.title}
|
|
</h3>
|
|
{props.task.content && (
|
|
<div class="text-[0.6875rem] sm:text-xs text-muted-foreground/60 line-clamp-1 mt-1 leading-tight whitespace-pre">
|
|
{(() => {
|
|
let html = props.task.content || "";
|
|
// Replace block tags with newlines first to preserve structure
|
|
html = html.replace(/<\/p>/gi, '\n')
|
|
.replace(/<\/div>/gi, '\n')
|
|
.replace(/<br\s*\/?>/gi, '\n')
|
|
.replace(/<\/li>/gi, '\n')
|
|
.replace(/<\/h[1-6]>/gi, '\n');
|
|
|
|
const tmp = document.createElement("DIV");
|
|
tmp.innerHTML = html;
|
|
const text = tmp.textContent || tmp.innerText || "";
|
|
|
|
// Replace newlines (and surrounding whitespace) with a wide gap
|
|
// Using 5 non-breaking spaces worth of visual space
|
|
return text.trim().replace(/\s*[\r\n]+\s*/g, ' ');
|
|
})()}
|
|
</div>
|
|
)}
|
|
<div class="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-1 min-w-0">
|
|
{/* Priority */}
|
|
<div class="flex items-center gap-1.5 shrink-0">
|
|
<ArrowUpCircle size={12} class="text-primary opacity-60" />
|
|
<span class="text-[0.625rem] font-medium">P{props.task.priority}</span>
|
|
</div>
|
|
|
|
{/* Shared Indicator - driven by task context tags */}
|
|
{(() => {
|
|
if (props.task.shareRefs.length === 0) return null;
|
|
|
|
const tooltip = props.task.shareRefs.length === 1
|
|
? 'Shared via 1 context'
|
|
: `Shared via ${props.task.shareRefs.length} contexts`;
|
|
|
|
return (
|
|
<div class="flex items-center gap-1 shrink-0" title={tooltip}>
|
|
<Users2 size={11} class="text-muted-foreground/50" />
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{/* Tags */}
|
|
{visibleTags().length > 0 && (
|
|
<div class="flex flex-wrap items-center gap-1 min-w-0">
|
|
<For each={visibleTags()}>
|
|
{(tag) => (
|
|
<Badge
|
|
variant="secondary"
|
|
class="h-4 px-1.5 text-[0.5625rem] gap-1 bg-muted/50 border-border/50 shrink-0"
|
|
style={{
|
|
"background-color": (() => {
|
|
const def = store.tagDefinitions.find(d => d.name === tag);
|
|
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
|
return color ? `${color}15` : undefined;
|
|
})(),
|
|
"border-color": (() => {
|
|
const def = store.tagDefinitions.find(d => d.name === tag);
|
|
const color = getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme());
|
|
return color ? `${color}30` : undefined;
|
|
})(),
|
|
"color": (() => {
|
|
const def = store.tagDefinitions.find(d => d.name === tag);
|
|
return getThemeAdjustedColor(def?.color, def?.theme, resolvedTheme()) || "inherit";
|
|
})()
|
|
}}
|
|
>
|
|
<div
|
|
class="w-1 h-1 rounded-full"
|
|
style={{
|
|
"background-color": (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) > 5 ? "#22c55e" : (store.tagDefinitions.find(d => d.name === tag)?.value ?? 5) < 5 ? "#ef4444" : "#94a3b8"
|
|
}}
|
|
/>
|
|
{tag}
|
|
</Badge>
|
|
)}
|
|
</For>
|
|
</div>
|
|
)}
|
|
|
|
{/* Due Date + Urgency Slide-out */}
|
|
<div class="relative group/date flex items-center h-5 cursor-help shrink-0 min-w-0">
|
|
{/* Due Date - Static Layer on Top */}
|
|
<div class="flex items-center space-x-1 text-[0.5625rem] font-bold text-muted-foreground uppercase tracking-wider bg-card relative z-20 pr-1">
|
|
<Clock size={11} class="text-primary/60" />
|
|
<span class="truncate">{formattedDate()}</span>
|
|
</div>
|
|
|
|
{/* Urgency Number - Slides out from behind with fade */}
|
|
<div class={cn(
|
|
"absolute left-4 top-0 bottom-0 flex items-center transition-all duration-300 ease-out z-10 opacity-0 group-hover/date:opacity-100 group-hover/date:left-full whitespace-nowrap",
|
|
urgencyColor()
|
|
)}>
|
|
<span class="text-[0.5625rem] font-bold ml-2 transition-all duration-300 transform translate-x-[-8px] group-hover/date:translate-x-0 group-hover/date:blur-none blur-[2px]">
|
|
Urgency {urgencyLevel()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
}
|
|
>
|
|
<h3 class={cn(
|
|
"text-[0.6875rem] sm:text-xs font-semibold truncate text-muted-foreground/90",
|
|
props.task.completed && "line-through"
|
|
)}>
|
|
{props.task.title}
|
|
</h3>
|
|
<button
|
|
type="button"
|
|
class="shrink-0 rounded-full p-1 text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
|
|
title="Expand task"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
expandCollapsedTask(props.task.id);
|
|
}}
|
|
>
|
|
<ChevronRight size={14} />
|
|
</button>
|
|
</Show>
|
|
</div>
|
|
|
|
<Show when={!isCollapsed()}>
|
|
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-1">
|
|
<button
|
|
class="p-1.5 hover:bg-muted rounded-full transition-all text-muted-foreground hover:text-primary opacity-0 group-hover:opacity-100"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
copyTask(props.task.id);
|
|
}}
|
|
title="Duplicate Task"
|
|
>
|
|
<Copy size={16} />
|
|
</button>
|
|
<button
|
|
class={cn(
|
|
"p-1.5 rounded-full transition-all",
|
|
props.task.tags?.includes(getFavoriteTag())
|
|
? "text-amber-500 bg-amber-500/10 hover:bg-amber-500/20 opacity-100"
|
|
: "text-muted-foreground hover:text-amber-500 hover:bg-muted opacity-0 group-hover:opacity-100"
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const favTag = getFavoriteTag();
|
|
const currentTags = props.task.tags || [];
|
|
const isFav = currentTags.includes(favTag);
|
|
const newTags = isFav ? currentTags.filter(t => t !== favTag) : [...currentTags, favTag];
|
|
updateTask(props.task.id, { tags: newTags });
|
|
}}
|
|
title="Toggle Favorite"
|
|
>
|
|
<Star size={16} class={cn(props.task.tags?.includes(getFavoriteTag()) && "fill-amber-500")} />
|
|
</button>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
};
|